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