diff --git a/.env.example b/.env.example index 745b697..95990f0 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ DATABASE_PATH=/data/database/hop.sqlite UPLOAD_DIR=/data/uploads # 실제 접속 IP 전달을 검증한 후에만 활성화합니다. SAME_NETWORK_DISCOVERY_ENABLED=false +LOCATION_DISCOVERY_ENABLED=true +LOCATION_DISCOVERY_RADIUS_METERS=100 TRUSTED_PROXY_CIDRS= CLIENT_IP_SOURCE=forwarded PROXY_DIAGNOSTIC_TOKEN= diff --git a/README.md b/README.md index edd6813..31599e9 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,5 @@ QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10 이번 NAS 업데이트는 [deploy.md](deploy.md)의 7단계부터 진행합니다. 운영 자동 발견은 실제 Tunnel → NPM → hop. 경로를 진단해 인접 프록시만 신뢰하며, Cloudflare의 원본 IP로 같은 네트워크 기기를 구분합니다. 공개 주소는 HTTPS를 사용합니다. 모바일 상단 설치 버튼으로 PWA 설치 또는 iPhone 홈 화면 추가를 안내합니다. 파일·계정 기록·API 응답은 오프라인 캐시에 넣지 않습니다. 로그인 계정의 임시 저장과 받은 파일을 합쳐 개인별 2GiB로 제한합니다. 같은 원본은 한 계정에서 한 번 계산하고 다른 계정의 한도와 분리합니다. 한도가 차면 파일 수신·다운로드는 계속하고 받은 항목에 저장하지 않았다는 안내를 표시합니다. 익명 업로드는 기기 세션당 2GiB, 서버 전체 20GiB이며 화면에는 내역을 표시하지 않습니다. + +근처 위치 검색과 기종 구분은 [기기 발견 문서](docs/NEARBY_DISCOVERY.md)를 참고하세요. 양쪽 기기가 위치를 허용하면 약 100m 내 기기를 표시하며, 대기 화면은 반복 목록 검색을 하지 않습니다. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 9a5ad64..38cb7d2 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -110,6 +110,72 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) socket.join(`account:${socket.data.accountId}`); socket.join(`account-session:${socket.data.accountSessionId}`); } + const currentGuest = () => { + const guest = service.guest(app.parseCookie(socket.request.headers.cookie ?? '').hop_guest); + if (!guest) fail(401, 'SESSION_EXPIRED', '기기 세션이 종료되었습니다.'); + return guest!; + }; + socket.on('location:update', (input, acknowledge) => { + try { + const guest = currentGuest(); + service.limiter.hit(`location-guest:${guest.id}`, 6); + service.limiter.hit( + `location-ip:${socket.data.ip ?? socket.request.socket.remoteAddress}`, + 120, + ); + if (!cfg.locationDiscoveryEnabled) + fail(409, 'LOCATION_DISABLED', '근처 검색을 사용할 수 없습니다.'); + if (!service.device(guest.id).discoverable || service.activeRoom(guest.id)) + fail(409, 'LOCATION_UNAVAILABLE', '현재 기기 상태에서는 근처 검색을 사용할 수 없습니다.'); + const position = z + .object({ + latitude: z.number().finite().min(-90).max(90), + longitude: z.number().finite().min(-180).max(180), + accuracy: z.number().finite().min(0).max(100000), + }) + .strict() + .parse(input); + if (position.accuracy > Math.max(100, cfg.locationRadiusMeters)) + fail( + 409, + 'LOCATION_INACCURATE', + '위치 오차가 커서 근처 검색을 켜지 못했어요. 다시 확인하거나 QR로 연결해 주세요.', + ); + service.nearby.set(guest.id, socket.id, position); + if (typeof acknowledge === 'function') acknowledge({ ok: true }); + } catch (error) { + if (typeof acknowledge === 'function') + acknowledge({ + ok: false, + code: error instanceof ApiError ? error.code : 'LOCATION_INVALID', + message: error instanceof ApiError ? error.message : '위치를 확인하지 못했습니다.', + }); + } + }); + socket.on('location:accept', (input) => { + try { + const guest = currentGuest(); + service.limiter.hit(`location-accept:${guest.id}`, 20); + const requestId = z.object({ requestId: z.uuid() }).strict().parse(input).requestId; + const request = service.request(guest, requestId); + if ( + request.recipientGuestId === guest.id && + request.status === 'pending' && + request.expiresAt > Date.now() && + cfg.locationDiscoveryEnabled && + service.nearby.nearby(request.senderGuestId, guest.id) && + service.canReceive(request.senderGuestId, guest.id) + ) + service.locationAcceptances.set(requestId, { + guestId: guest.id, + until: Date.now() + 15000, + }); + } catch { + /* Invalid messages cannot grant an acceptance. */ + } + }); + socket.on('location:clear', () => service.nearby.clear(socket.id)); + socket.on('disconnect', () => service.nearby.clear(socket.id)); }); const auth = (req: FastifyRequest) => { const guest = service.guest(req.cookies.hop_guest); @@ -162,7 +228,10 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) .header('X-Content-Type-Options', 'nosniff') .header('Referrer-Policy', 'no-referrer') .header('X-Frame-Options', 'DENY') - .header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), bluetooth=()'); + .header( + 'Permissions-Policy', + `camera=(), microphone=(), geolocation=${cfg.locationDiscoveryEnabled ? '(self)' : '()'}, bluetooth=()`, + ); reply.header( 'Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", @@ -262,6 +331,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) device: service.device(g.id), devices: service.peers(g), sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, + locationDiscovery: { + enabled: cfg.locationDiscoveryEnabled, + radiusMeters: cfg.locationRadiusMeters, + refreshMs: 120000, + }, requests: r, items: list.items, nextCursor: list.nextCursor, @@ -868,12 +942,19 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) }); } let storageCleanup: Promise | undefined; - const cleanup = setInterval(() => { - service.cleanup(); - service.auth.enforceSessions(); + const cleanStorage = () => { storageCleanup = service.storage .cleanup() .catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed')); + }; + cleanStorage(); + const storageTimer = setInterval(cleanStorage, 60000); + storageTimer.unref(); + const cleanup = setInterval(() => { + for (const [id, permission] of service.locationAcceptances) + if (permission.until <= Date.now()) service.locationAcceptances.delete(id); + service.cleanup(); + service.auth.enforceSessions(); }, 1000); cleanup.unref(); app.addHook('preClose', async () => { @@ -882,7 +963,9 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) }); app.addHook('onClose', async () => { clearInterval(cleanup); + clearInterval(storageTimer); presence.close(); + service.nearby.close(); service.storage.close(); await storageCleanup; await new Promise((resolve) => io.close(() => resolve())); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index a941c82..1982c98 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -7,6 +7,8 @@ export type Config = { sessionSecret: string; discoverySecret: string; discoveryEnabled: boolean; + locationDiscoveryEnabled: boolean; + locationRadiusMeters: number; trustedProxies: string[]; clientIpSource: 'forwarded' | 'cloudflare'; proxyDiagnosticToken?: string; @@ -63,6 +65,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { throw Error('Invalid CLIENT_IP_SOURCE'); if (env.PROXY_DIAGNOSTIC_TOKEN && env.PROXY_DIAGNOSTIC_TOKEN.length < 32) 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'); return { production, appUrl, @@ -74,6 +78,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { env.SAME_NETWORK_DISCOVERY_ENABLED === undefined ? !production : env.SAME_NETWORK_DISCOVERY_ENABLED === 'true', + locationDiscoveryEnabled: env.LOCATION_DISCOVERY_ENABLED !== 'false', + locationRadiusMeters: num('LOCATION_DISCOVERY_RADIUS_METERS', 100), trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '') .split(',') .map((v) => v.trim()) diff --git a/apps/server/src/nearby.js b/apps/server/src/nearby.js new file mode 100644 index 0000000..cee5dae --- /dev/null +++ b/apps/server/src/nearby.js @@ -0,0 +1,94 @@ +// Consent-based location candidates. Coordinates stay in this process only. +export function metersBetween(a, b) { + const rad = Math.PI / 180; + const latitude = (b.latitude - a.latitude) * rad; + const longitude = (b.longitude - a.longitude) * rad; + const value = + Math.sin(latitude / 2) ** 2 + + Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) * Math.sin(longitude / 2) ** 2; + return 6371000 * 2 * Math.asin(Math.sqrt(Math.min(1, Math.max(0, value)))); +} +export class NearbyLocations { + constructor(radius, changed) { + this.radius = radius; + this.changed = changed; + this.points = new Map(); + this.guests = new Map(); + this.timers = new Map(); + } + nearby(a, b) { + const now = Date.now(); + for (const left of this.guests.get(a)?.values() ?? []) { + if (left.until <= now) continue; + for (const right of this.guests.get(b)?.values() ?? []) { + if (right.until > now && metersBetween(left, right) <= this.radius) return true; + } + } + return false; + } + neighbors(id) { + return new Set( + [...this.guests.keys()].filter((other) => other !== id && this.nearby(id, other)), + ); + } + notify(id, before) { + const after = this.neighbors(id); + const delta = [...before] + .filter((other) => !after.has(other)) + .concat([...after].filter((other) => !before.has(other))); + if (delta.length) this.changed([id, ...delta]); + } + set(guestId, socketId, position) { + const before = this.neighbors(guestId); + clearTimeout(this.timers.get(socketId)); + const point = { + guestId, + latitude: Math.round(position.latitude * 10000) / 10000, + longitude: Math.round(position.longitude * 10000) / 10000, + until: Date.now() + 150000, + }; + this.points.set(socketId, point); + const sockets = this.guests.get(guestId) ?? new Map(); + sockets.set(socketId, point); + this.guests.set(guestId, sockets); + const timer = setTimeout(() => this.clear(socketId), 150000); + timer.unref(); + this.timers.set(socketId, timer); + this.notify(guestId, before); + } + clear(socketId) { + const point = this.points.get(socketId); + if (!point) return; + // Include expired relationships so their subscribers get removal events. + const now = Date.now(); + const before = new Set( + [...this.guests.keys()].filter( + (other) => + other !== point.guestId && + [...(this.guests.get(point.guestId)?.values() ?? [])].some( + (left) => + (left === point || left.until > now) && + [...(this.guests.get(other)?.values() ?? [])].some( + (right) => right.until > now && metersBetween(left, right) <= this.radius, + ), + ), + ), + ); + clearTimeout(this.timers.get(socketId)); + this.timers.delete(socketId); + this.points.delete(socketId); + const sockets = this.guests.get(point.guestId); + sockets?.delete(socketId); + if (!sockets?.size) this.guests.delete(point.guestId); + this.notify(point.guestId, before); + } + clearGuest(id) { + for (const socketId of this.guests.get(id)?.keys() ?? []) this.clear(socketId); + } + close() { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + this.points.clear(); + this.guests.clear(); + } +} diff --git a/apps/server/src/presence.ts b/apps/server/src/presence.ts index 90b8f4b..53786a8 100644 --- a/apps/server/src/presence.ts +++ b/apps/server/src/presence.ts @@ -3,6 +3,13 @@ import type { Config } from './config.js'; import { hmac, publicIp } from './security.js'; export class Presence { private connections = new Map>(); + private deviceInfos = new Map< + string, + { model: string; platform: string; manufacturer: string } + >(); + info(id: string) { + return this.deviceInfos.get(id); + } private graceIps = new Map>(); private timers = new Map>(); constructor( @@ -18,6 +25,24 @@ export class Presence { const sockets = this.connections.get(guestId) ?? new Map(); sockets.set(socket.id, ip); this.connections.set(guestId, sockets); + const hint = socket.handshake.auth.deviceInfo; + if (hint && typeof hint === 'object') { + const clean = (value: unknown, max: number) => + typeof value === 'string' + ? value + .replace(/[\u0000-\u001f\u007f]/g, '') + .trim() + .slice(0, max) + : ''; + const model = clean(hint.model, 80); + // Infer only known prefixes; never trust a client-provided manufacturer label. + const manufacturer = /^SM-|^GT-/i.test(model) + ? 'Samsung' + : /^Pixel(?: |$)/i.test(model) + ? 'Google' + : ''; + this.deviceInfos.set(guestId, { model, platform: clean(hint.platform, 40), manufacturer }); + } socket.join(`guest:${guestId}`); this.changed(); socket.on('disconnect', () => this.disconnect(guestId, socket.id)); @@ -30,6 +55,7 @@ export class Presence { this.graceIps.set(guestId, new Set([oldIp])); const timer = setTimeout(() => { this.connections.delete(guestId); + this.deviceInfos.delete(guestId); this.graceIps.delete(guestId); this.timers.delete(guestId); this.changed(); @@ -65,6 +91,7 @@ export class Presence { revoke(id: string) { this.io.in(`guest:${id}`).disconnectSockets(true); this.connections.delete(id); + this.deviceInfos.delete(id); this.graceIps.delete(id); const timer = this.timers.get(id); if (timer) clearTimeout(timer); @@ -72,6 +99,7 @@ export class Presence { this.changed(); } close() { + this.deviceInfos.clear(); for (const t of this.timers.values()) clearTimeout(t); } } diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts index 694788a..9f36c57 100644 --- a/apps/server/src/service.ts +++ b/apps/server/src/service.ts @@ -3,6 +3,7 @@ import type { Auth } from './auth.js'; import type { Accounts } from './accounts.js'; 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 { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm'; @@ -25,6 +26,8 @@ const day = 86400000; export class Service { readonly limiter = new Limiter(); presence!: Presence; + nearby: NearbyLocations; + readonly locationAcceptances = new Map(); auth!: Auth; accounts!: Accounts; io?: Server; @@ -37,6 +40,9 @@ export class Service { ) { this.storage = new Storage(this); this.resumable = new ResumableUploads(this); + this.nearby = new NearbyLocations(cfg.locationRadiusMeters, (ids: string[]) => + this.refresh(ids), + ); this.vault = new Vault(this); } guest(token: string | undefined) { @@ -56,7 +62,13 @@ export class Service { device(guestId: string): Device { const d = this.db.select().from(s.devices).where(eq(s.devices.guestId, guestId)).get(); if (!d) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다.'); - return { id: d!.id, name: d!.name, code: d!.code, discoverable: d!.discoverable }; + return { + id: d!.id, + name: d!.name, + code: d!.code, + discoverable: d!.discoverable, + info: this.presence?.info(guestId), + }; } deviceGuest(id: string) { return this.db.select().from(s.devices).where(eq(s.devices.id, id)).get(); @@ -111,6 +123,7 @@ export class Service { .set({ ...input, lastSeen: Date.now() }) .where(eq(s.devices.guestId, g.id)) .run(); + if (input.discoverable === false) this.nearby.clearGuest(g.id); this.refresh(); } reset(g: Guest) { @@ -215,7 +228,10 @@ export class Service { scope(a: string, b: string) { if (this.isBlocked(a, b)) return false; if (this.activeRoom(a) || this.activeRoom(b)) return this.sharedSpace(a, b); - return this.presence.sameNetwork(a, b); + return ( + this.presence.sameNetwork(a, b) || + (this.cfg.locationDiscoveryEnabled && this.nearby.nearby(a, b)) + ); } restrictTransfers(leaving: string[] = []) { const active = this.db @@ -274,12 +290,18 @@ export class Service { ? ['network' as const] : []), ...(this.sharedSpace(g.id, id) ? ['space' as const] : []), + ...(!this.activeRoom(g.id) && + this.cfg.locationDiscoveryEnabled && + this.nearby.nearby(g.id, id) + ? ['nearby' as const] + : []), ], })); } - refresh() { + refresh(subset?: string[]) { if (!this.presence) return; - for (const id of this.presence.ids()) { + for (const id of subset ?? this.presence.ids()) { + if (!this.presence.online(id)) continue; const g = this.db.select().from(s.guests).where(eq(s.guests.id, id)).get(); if (!g || g.revokedAt || g.expiresAt <= Date.now()) { this.presence.revoke(id); @@ -384,6 +406,7 @@ export class Service { }) .run(); }); + this.nearby.clearGuest(g.id); this.restrictTransfers(); this.refresh(); return { space: this.space(g, id), code, token }; @@ -431,6 +454,7 @@ export class Service { }) .run(); }); + this.nearby.clearGuest(g.id); this.restrictTransfers(); this.refresh(); return { space: this.space(g, row!.id) }; @@ -638,7 +662,20 @@ export class Service { if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 수락할 수 있습니다.'); if (row.status !== 'pending' || row.expiresAt <= Date.now()) fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.'); - if (!this.canReceive(row.senderGuestId, g.id)) + const permission = this.locationAcceptances.get(id); + const reserved = + this.cfg.locationDiscoveryEnabled && + permission?.guestId === g.id && + permission.until > Date.now() && + !this.activeRoom(g.id) && + !this.activeRoom(row.senderGuestId) && + this.presence.active(g.id) && + this.presence.active(row.senderGuestId) && + this.device(g.id).discoverable && + this.device(row.senderGuestId).discoverable && + !this.isBlocked(row.senderGuestId, g.id); + this.locationAcceptances.delete(id); + if (!this.canReceive(row.senderGuestId, g.id) && !reserved) fail(409, 'TARGET_UNAVAILABLE', '현재 연결 상태에서 수락할 수 없습니다.'); if (row.kind === 'file' && !this.vault.source(id)) { const sender = this.db diff --git a/apps/server/test/device-info.test.js b/apps/server/test/device-info.test.js new file mode 100644 index 0000000..b6d7fc1 --- /dev/null +++ b/apps/server/test/device-info.test.js @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readDeviceInfo } from '../../web/src/deviceInfo.js'; + +test('모델 힌트가 없거나 거절된 브라우저도 정상 연결할 수 있다', async () => { + assert.deepEqual(await readDeviceInfo({}), { model: '', platform: '', manufacturer: '' }); + assert.deepEqual( + await readDeviceInfo({ + userAgentData: { + getHighEntropyValues: async () => { + throw Error('withheld'); + }, + }, + }), + {}, + ); +}); +test('Android 모델을 구분하고 브라우저 브랜드를 제조사로 사용하지 않는다', async () => { + const nav = { + userAgentData: { + getHighEntropyValues: async (hints) => { + assert.deepEqual(hints, ['model']); + return { model: 'SM-S938B', platform: 'Android', brands: [{ brand: 'Chrome' }] }; + }, + }, + }; + assert.deepEqual(await readDeviceInfo(nav), { + model: 'SM-S938B', + platform: 'Android', + manufacturer: 'Samsung', + }); + nav.userAgentData.getHighEntropyValues = async () => ({ + model: 'Unknown phone', + platform: 'Android', + }); + assert.equal((await readDeviceInfo(nav)).manufacturer, ''); +}); diff --git a/apps/server/test/nearby.test.js b/apps/server/test/nearby.test.js new file mode 100644 index 0000000..19f3862 --- /dev/null +++ b/apps/server/test/nearby.test.js @@ -0,0 +1,184 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { io } from 'socket.io-client'; +import { randomUUID } from 'node:crypto'; +import { fixture } from './helpers.js'; +import { metersBetween, NearbyLocations } from '../src/nearby.js'; +const point = { latitude: 37.5665, longitude: 126.978, accuracy: 20 }; + +test('거리 계산과 동의한 양쪽 좌표, 반경·만료·여러 탭·변경 알림을 검사한다', () => { + const changes = [], + locations = new NearbyLocations(100, (ids) => changes.push(ids)); + try { + assert.equal(metersBetween(point, point), 0); + assert( + metersBetween({ latitude: 0, longitude: 179.9999 }, { latitude: 0, longitude: -179.9999 }) < + 30, + ); + locations.set('a', 'a1', point); + assert.equal(changes.length, 0, '혼자서는 불필요한 목록 갱신을 하지 않음'); + locations.set('b', 'b1', { ...point, latitude: point.latitude + 0.0004 }); + assert.equal(locations.nearby('a', 'b'), true); + const before = changes.length; + locations.set('b', 'b1', { ...point, latitude: point.latitude + 0.0004 }); + assert.equal(changes.length, before, '같은 위치를 갱신해도 목록 재전송 없음'); + locations.set('a', 'a2', point); + locations.clear('a1'); + assert.equal(locations.nearby('a', 'b'), true); + locations.clear('a2'); + assert.equal(locations.nearby('a', 'b'), false); + locations.set('a', 'a1', point); + locations.points.get('a1').until = Date.now() - 1; + assert.equal(locations.nearby('a', 'b'), false); + locations.clear('a1'); + assert(changes.at(-1).includes('b')); + locations.set('a', 'a1', { ...point, latitude: point.latitude + 0.01 }); + assert.equal(locations.nearby('a', 'b'), false); + } finally { + locations.close(); + } +}); +async function positioned(f, ip) { + const client = await f.create(ip, false); + client.socket = io(f.url, { + transports: ['websocket'], + reconnection: false, + auth: { csrfToken: client.csrf }, + extraHeaders: { Origin: f.cfg.appUrl, Cookie: client.cookie, 'X-Forwarded-For': ip }, + }); + await new Promise((resolve, reject) => { + client.socket.once('connect', resolve); + client.socket.once('connect_error', reject); + }); + return client; +} +const publish = (client, position) => + client.socket.timeout(3000).emitWithAck('location:update', position); + +test('다른 네트워크도 양쪽 위치 허용 시 발견·전송하고 좌표는 목록과 DB에 공개하지 않는다', async () => { + const f = await fixture(false), + clients = []; + try { + const a = await positioned(f, '8.8.8.8'), + b = await positioned(f, '1.1.1.1'), + c = await positioned(f, '9.9.9.9'); + clients.push(a, b, c); + const list = async (client) => (await f.call(client, '/discovery/devices')).json().devices; + assert.equal((await list(a)).length, 0); + assert.equal((await publish(a, point)).ok, true); + assert.equal((await list(a)).length, 0); + assert.equal((await publish(b, { ...point, latitude: point.latitude + 0.0004 })).ok, true); + assert.equal((await publish(c, { ...point, latitude: point.latitude + 0.01 })).ok, true); + assert.deepEqual( + (await list(a)).map((device) => device.id), + [b.deviceId], + ); + assert.deepEqual((await list(a))[0].sources, ['nearby']); + const snapshot = await f.call(a, '/device/snapshot'); + assert(!snapshot.body.includes('latitude') && !snapshot.body.includes('longitude')); + assert( + !f.sqlite + .prepare("SELECT sql FROM sqlite_master WHERE type='table'") + .all() + .some((row) => /latitude|longitude/i.test(row.sql)), + ); + const request = await f.request(a, b, 'https://example.com/nearby'); + assert.equal(request.statusCode, 200, request.body); + assert.equal( + ( + await f.call( + b, + `/transfer-requests/${request.json().requestId}/accept`, + 'POST', + {}, + randomUUID(), + ) + ).statusCode, + 200, + ); + assert.equal( + ( + await f.call( + a, + `/transfers/${request.json().requestId}/complete`, + 'POST', + { urls: ['https://example.com/nearby'] }, + randomUUID(), + ) + ).statusCode, + 200, + ); + b.socket.emit('location:clear'); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal((await list(a)).length, 0); + assert.equal((await f.request(a, b, 'https://example.com/outside')).statusCode, 403); + } finally { + clients.forEach((client) => client.socket.disconnect()); + await f.close(); + } +}); +test('기기 숨김·방 참여·세션 종료·부정확한 위치와 잘못된 위치를 검사한다', async () => { + const f = await fixture(false), + clients = []; + try { + const a = await positioned(f, '8.8.8.8'), + b = await positioned(f, '1.1.1.1'); + clients.push(a, b); + assert.equal((await publish(a, { ...point, latitude: 91 })).ok, false); + assert.equal((await publish(a, { ...point, accuracy: 10000 })).code, 'LOCATION_INACCURATE'); + await publish(a, point); + await publish(b, point); + await f.call(b, '/device/me', 'PATCH', { discoverable: false }); + assert.equal(f.service.nearby.guests.has(f.service.guest(b.cookie.split('=')[1]).id), false); + assert.equal((await publish(b, point)).code, 'LOCATION_UNAVAILABLE'); + await f.call(b, '/device/me', 'PATCH', { discoverable: true }); + await f.space(a, b); + assert.equal((await publish(a, point)).code, 'LOCATION_UNAVAILABLE'); + const d = await positioned(f, '9.9.9.9'); + clients.push(d); + await publish(d, point); + assert.equal( + (await f.call(d, '/discovery/devices')).json().devices.length, + 0, + '방 안 기기는 외부 위치 목록에 나오지 않음', + ); + const guestId = f.service.guest(d.cookie.split('=')[1]).id; + d.socket.disconnect(); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(f.service.nearby.guests.has(guestId), false); + } finally { + clients.forEach((client) => client.socket.disconnect()); + await f.close(); + } +}); +test('수락 클릭으로 새 탭이 열려 위치가 제거돼도 해당 요청만 완료할 수 있다', async () => { + const f = await fixture(false), + clients = []; + try { + const a = await positioned(f, '8.8.8.8'), + b = await positioned(f, '1.1.1.1'); + clients.push(a, b); + await publish(a, point); + await publish(b, point); + const response = await f.request(a, b, 'https://example.com/accepted'), + id = response.json().requestId; + b.socket.emit('location:accept', { requestId: id }); + b.socket.emit('location:clear'); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal( + f.service.nearby.nearby( + f.service.guest(a.cookie.split('=')[1]).id, + f.service.guest(b.cookie.split('=')[1]).id, + ), + false, + ); + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 200, + ); + assert.equal(f.service.locationAcceptances.has(id), false); + } finally { + clients.forEach((client) => client.socket.disconnect()); + await f.close(); + } +}); diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 4d439cc..3aeebf9 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -34,6 +34,7 @@ import QRCode from 'qrcode'; import AccountPanel from './AccountPanel.vue'; import SendComposer from './SendComposer.vue'; import InstallButton from './InstallButton.vue'; +import { createNearbySearch } from './nearbySearch'; import { createDeliveryReceiver } from './deliveryReceiver'; import { initialTheme, applyTheme as setTheme } from './theme'; import { @@ -41,7 +42,12 @@ import { account, connected, discoveryEnabled, + locationSettings, + publishLocation, + clearLocation, + reserveLocationAcceptance, peers, + pendingPeers, requests, items, spaces, @@ -97,6 +103,14 @@ const receiving = computed(() => r.transferStatus !== 'failed', ), ); +createNearbySearch({ + connected, + device, + spaces, + settings: locationSettings, + publish: publishLocation, + clear: clearLocation, +}); const receiver = createDeliveryReceiver({ download, markRead, onError: showError }); const blockedLinks = receiver.blockedLinks; watch([requests, items], ([currentRequests, currentItems]) => { @@ -105,7 +119,10 @@ watch([requests, items], ([currentRequests, currentItems]) => { onUnmounted(receiver.dispose); async function receiveAction(r, action) { if (busy.value) return; - if (action === 'accept') receiver.reserve(r); + if (action === 'accept') { + reserveLocationAcceptance(r.id); + receiver.reserve(r); + } await act(async () => { try { await requestAction(r.id, action); @@ -136,8 +153,29 @@ const connectionCards = computed(() => { }); } } + for (const id of pendingPeers.value) + if (!cards.has(id)) cards.set(id, { peer: { id }, skeleton: true }); return [...cards.values()]; }); +function leaveDevice(el) { + const { width, height } = el.getBoundingClientRect(); + Object.assign(el.style, { + left: `${el.offsetLeft}px`, + top: `${el.offsetTop}px`, + width: `${width}px`, + height: `${height}px`, + }); +} +function deviceLabel(peer) { + return /^(Android|iPhone|iPad|Mac|Windows|Linux|기기)$/i.test(peer.name) && peer.info?.model + ? peer.info.model + : peer.name; +} +function deviceDetail(peer) { + return [peer.info?.manufacturer, peer.info?.model !== deviceLabel(peer) ? peer.info?.model : ''] + .filter(Boolean) + .join(' · '); +} async function toggleDeviceMenu(id) { if (deviceMenu.value === id) { deviceMenu.value = null; @@ -534,9 +572,14 @@ async function spaceAction(space, action, memberId) {

연결을 복구하고 있어요.

-
- -

기기를 연결하고 있어요.

+
+
+

연결 정보를 확인하고 있어요.