Add automatic nearby discovery and simplify device presence UI
This commit is contained in:
@@ -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<void> | 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<void>((resolve) => io.close(() => resolve()));
|
||||
|
||||
@@ -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())
|
||||
|
||||
94
apps/server/src/nearby.js
Normal file
94
apps/server/src/nearby.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import type { Config } from './config.js';
|
||||
import { hmac, publicIp } from './security.js';
|
||||
export class Presence {
|
||||
private connections = new Map<string, Map<string, string | null>>();
|
||||
private deviceInfos = new Map<
|
||||
string,
|
||||
{ model: string; platform: string; manufacturer: string }
|
||||
>();
|
||||
info(id: string) {
|
||||
return this.deviceInfos.get(id);
|
||||
}
|
||||
private graceIps = new Map<string, Set<string | null>>();
|
||||
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, { guestId: string; until: number }>();
|
||||
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
|
||||
|
||||
37
apps/server/test/device-info.test.js
Normal file
37
apps/server/test/device-info.test.js
Normal file
@@ -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, '');
|
||||
});
|
||||
184
apps/server/test/nearby.test.js
Normal file
184
apps/server/test/nearby.test.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user