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
|
||||
|
||||
Reference in New Issue
Block a user