"use strict"; // Rutas del modulo Karvan (salas efimeras en RAM, mensajes temporales y // senalizacion WebRTC), mas las dos pantallas que la rama anade a los ajustes. // // Upstream pone todas sus rutas en backend.js: 625 en una sola cadena fluida. // Estas viven aparte porque ese fichero se reescribe entero en cada release // (+833/-286 en la 0.9.2, +266/-31 en la 0.9.1), asi que una ruta anadida en // medio entra en conflicto cada vez. Se montan en un router propio insertado // antes del de upstream; lo que no casa cae a next() y sigue su curso. // // El nombre sigue a la familia del modulo: karvan_model.js, karvan_view.js, // karvan.js, karvan.css. // // Las dependencias se inyectan en lugar de reconstruirse: cooler abre la conexion // al sbot y duplicarla abriria un segundo cliente muxrpc. const koaRouter = require("../server/node_modules/@koa/router"); const { koaBody } = require("../server/node_modules/koa-body"); const { stripDangerousTags } = require("./sanitizeHtml"); const { buildIceServers } = require("./turnCredentials"); const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC) const { karvanView, karvanRoomView } = require("../views/karvan_view"); module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage }) => { // FORK_IA_UX Fase 2 (cross-device): relay de MENSAJES de Karvan por privados SSB. Funciona vía un pub // (el privado se replica al otro móvil); los SIGNAL de WebRTC NO se relayan (serían demasiados/lentos por el log). // No toca el arranque del SSB. Ingesta por stream EN VIVO (no polling) → eficiente. const karvanSeenRelay = new Set(); // mids ya inyectados (evita duplicados/bucles) let karvanRelaySub = false; // ¿suscriptor del log en vivo montado? async function karvanPublishRelay(roomId, mid, from, text) { try { const feeds = karvanModel.getRemoteFeeds(roomId); if (!feeds.length) return; const ssbClient = await cooler.open(); const recps = [...new Set([ssbClient.id, ...feeds])].slice(0, 8); await new Promise((res, rej) => ssbClient.private.publish( { type: 'karvan-relay', roomId, mid: String(mid || '').slice(0, 60), from: String(from || '?').slice(0, 80), text: String(text || '').slice(0, 2000), private: true }, recps, (e, m) => e ? rej(e) : res(m))); } catch (e) {} } async function karvanStartRelay() { if (karvanRelaySub) return; karvanRelaySub = true; try { const ssbClient = await cooler.open(); const me = ssbClient.id; pull( ssbClient.createLogStream({ old: false, live: true }), pull.drain((m) => { try { if (!m || !m.value) return; let dec; try { dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.timestamp }); } catch (_) { return; } const c = dec && dec.value && dec.value.content; if (!c || c.type !== 'karvan-relay' || !c.roomId || !c.mid) return; if (dec.value.author === me) return; // no reinyectar lo propio (evita bucle A→B→A) if (karvanSeenRelay.has(c.mid)) return; if (!karvanModel.getRoom(c.roomId)) return; // solo si tenemos esa sala localmente karvanSeenRelay.add(c.mid); if (karvanSeenRelay.size > 5000) karvanSeenRelay.clear(); karvanModel.postMessage(c.roomId, { from: c.from, text: c.text, mid: c.mid }); } catch (e) {} }) ); } catch (e) { karvanRelaySub = false; } } // ¿quién me invitó a esta sala? (para registrar su feed en el espejo local) — reutiliza el lector de privados async function karvanInviteAuthor(roomId) { try { const msgs = await pmModel.listAllPrivate(); for (const m of (msgs || [])) { const c = m && m.value && m.value.content; if (c && c.subject === 'KARVAN_INVITE' && typeof c.text === 'string' && c.text.indexOf('/karvan/' + roomId) !== -1) return m.value.author; } } catch (e) {} return null; } const forkRouter = new koaRouter(); forkRouter // Configuracion ICE para las llamadas. Solo loopback: la credencial TURN va // firmada con el secreto del nodo y no debe salir de la maquina. .get('/karvan/ice', async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = { error: 'forbidden' }; return; } let who = ''; try { who = getViewerId() || ''; } catch (e) {} let rtc = null; try { rtc = (getConfig() || {}).rtc || null; } catch (e) {} ctx.body = buildIceServers(rtc, who); }) // ===== FORK_IA_UX: módulo KARVAN (mensajes temporales RAM + WebRTC data-channel) ===== .get('/karvan', async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; } let selfId = ''; try { selfId = getViewerId(); } catch (e) {} ctx.body = karvanView(karvanModel.listRooms(), { selfId }); }) .post('/karvan/create', koaBody(), async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body || {}; const room = karvanModel.createRoom({ title: stripDangerousTags(String(b.title || '').trim()).slice(0, 80), ttlMinutes: b.ttl, }); ctx.redirect('/karvan/' + room.id); }) .get('/karvan/:id', async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; } let room = karvanModel.getRoom(ctx.params.id); // unirse por enlace/invitación crea un espejo local, pero SOLO en una navegación real (documento): // un /subrecurso de una web maliciosa NO debe poder crear/expulsar salas por GET (CSRF). El navegador // controla sec-fetch-dest/accept, así que son señales fiables. let justAdopted = false; if (!room) { const isNav = ctx.get('sec-fetch-dest') === 'document' || (ctx.get('accept') || '').includes('text/html'); if (isNav) { room = karvanModel.adoptRoom({ id: ctx.params.id }); justAdopted = !!room; } } if (!room) { ctx.redirect('/karvan'); return; } if (justAdopted) { // al unirte por enlace, registra el feed de quien te invitó → relay cross-device const author = await karvanInviteAuthor(ctx.params.id); if (author) { karvanModel.addRemoteFeed(ctx.params.id, author); karvanStartRelay(); } } let selfId = ''; try { selfId = getViewerId(); } catch (e) {} const msgs = karvanModel.listMessages(room.id, 0) || []; ctx.body = karvanRoomView(room, msgs, { selfId, invite: ctx.query.invite || '' }); }) .post('/karvan/:id/invite', koaBody(), async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } const id = ctx.params.id; const room = karvanModel.getRoom(id); if (!room) { ctx.redirect('/karvan'); return; } const to = String((ctx.request.body || {}).to || '').trim(); if (!/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(to)) { ctx.redirect('/karvan/' + id + '?invite=bad'); return; } try { // reutiliza el patrón de invitación por mensaje privado de Oasis (como el módulo industry): // le llega al INBOX del contacto con un enlace a la sala. Sin código SSB nuevo. await pmModel.sendMessage([to], 'KARVAN_INVITE', 'You have been invited to an ephemeral Karvan room' + (room.title ? ' ("' + room.title + '")' : '') + ' -> /karvan/' + id); karvanModel.addRemoteFeed(id, to); karvanStartRelay(); // relay cross-device hacia el invitado ctx.redirect('/karvan/' + id + '?invite=ok'); } catch (e) { ctx.redirect('/karvan/' + id + '?invite=fail'); } }) .post('/karvan/:id/msg', koaBody(), async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } const b = ctx.request.body || {}; let selfId = ''; try { selfId = getViewerId(); } catch (e) {} const msg = karvanModel.postMessage(ctx.params.id, { from: String(b.from || selfId || '?').slice(0, 80), text: stripDangerousTags(String(b.text || '')).slice(0, 2000), mid: b.mid, }); if (!msg) { ctx.status = 404; ctx.body = { error: 'no room' }; return; } karvanPublishRelay(ctx.params.id, msg.mid || (msg.from + '-' + msg.ts + '-' + msg.seq), msg.from, msg.text); // → feeds remotos (si hay) const wantsJson = (ctx.get('accept') || '').indexOf('application/json') !== -1 || (ctx.get('content-type') || '').indexOf('application/json') !== -1; if (wantsJson) ctx.body = { ok: true, seq: msg.seq }; else ctx.redirect('/karvan/' + ctx.params.id); }) .get('/karvan/:id/msgs', async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } const msgs = karvanModel.listMessages(ctx.params.id, ctx.query.since || 0); ctx.body = { messages: msgs || [] }; }) .post('/karvan/:id/signal', koaBody(), async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } const b = ctx.request.body || {}; karvanModel.postSignal(ctx.params.id, { from: b.from, to: b.to, payload: b.payload }); ctx.body = { ok: true }; }) .get('/karvan/:id/signal', async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } const r = karvanModel.getSignals(ctx.params.id, { forId: ctx.query.for || '', since: ctx.query.since || 0 }); ctx.body = r || { signals: [], members: [] }; }) .get("/settings/bottombar", async (ctx) => { ctx.body = require("../views/main_views").bottomBarPickerView(); }) .post("/settings/bottombar", koaBody(), async (ctx) => { const cfg = getConfig(); const MAX = 4; const body = ctx.request.body || {}; const action = String(body.action || '').trim(); const key = String(body.key || '').trim(); const catalog = require("../views/main_views").PINNABLE_MODULES || []; let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : []; if (action === 'add') { if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key); } else if (action === 'remove') { pins = pins.filter(k => k !== key); } else if (action === 'clear') { pins = []; } cfg.bottomBarPins = pins.slice(0, MAX); saveConfig(cfg); ctx.redirect('/settings/bottombar'); }) .post("/settings/identity/use", koaBody(), async (ctx) => { // solo anota cual sera la del proximo arranque: cambiarla en caliente dejaria // media aplicacion hablando con el sbot anterior const accounts = require('./accounts.js'); const name = String((ctx.request.body || {}).account || '').trim(); if (!accounts.isValid(name) || !accounts.list().includes(name)) { sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 }); return; } accounts.setActive(name); ctx.redirect('/settings'); }) .post("/settings/identity/create", koaBody(), async (ctx) => { const accounts = require('./accounts.js'); const name = String((ctx.request.body || {}).account || '').trim(); if (!accounts.isValid(name)) { sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 }); return; } // no se genera el secret aqui: lo crea ssb-config al arrancar con esa cuenta, // que es quien sabe como hacerlo. Basta con anotarla y reiniciar. accounts.setActive(name); ctx.redirect('/settings'); }) .post("/settings/identity/restart", koaBody(), async (ctx) => { // Solo en movil: alli el backend vive en un servicio en primer plano que // sobrevive a cerrar la app, asi que la unica forma de arrancar con otra // identidad es terminar el proceso. nodejs-mobile no permite volver a // levantar el runtime dentro del mismo proceso: hay que salir y que la app // lo relance al abrirse. if (process.env.OASIS_MOBILE !== '1') { ctx.status = 404; ctx.body = ''; return; } ctx.body = require("../views/main_views").template( require('../views/main_views').i18n.identitiesTitle, require("../server/node_modules/hyperaxe").section( require("../server/node_modules/hyperaxe").p(require('../views/main_views').i18n.identitiesRestarting) ) ); // se responde primero: si se sale antes, el navegador se queda sin pagina setTimeout(() => process.exit(0), 500); }); return forkRouter.routes(); };