Add automatic nearby discovery and simplify device presence UI

This commit is contained in:
2026-10-12 17:53:10 +09:00
parent 7e9bb1ec36
commit 5f431ee153
23 changed files with 1148 additions and 106 deletions

View File

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

View File

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

View File

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

View File

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