"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"); const compose = require("../server/node_modules/koa-compose"); const { isLocalPath } = require("./request_guards"); const { ALL_MODULES } = require("../models/workflows_model"); const { mobileModulesView, STEMS } = require("../views/mobile_modules_view"); require("../models/fork_workflows").register(); const isMobile = () => process.env.OASIS_MOBILE === '1'; // Seccion apagada: upstream responde con una redireccion a /modules sin decir de // donde venia. Este envoltorio la ve pasar y anota que modulo era, para que la // pantalla de Modulos del movil lo diga y ofrezca activarlo. La app es de una // persona, asi que basta con recordar el ultimo unos segundos. const SEGMENT_MOD = { tribe: 'tribes', vote: 'votes', document: 'docs', documents: 'docs', blog: 'blogs', event: 'events', task: 'tasks', calendar: 'calendars', image: 'images', video: 'videos', audio: 'audios', bookmark: 'bookmarks', file: 'files', poll: 'polls', report: 'reports', project: 'projects', shop: 'shops', job: 'jobs', campaign: 'campaigns', podcast: 'podcasts', room: 'rooms', map: 'maps', transfer: 'transfers', pad: 'pads', chat: 'chats', torrent: 'torrents' }; const modOfPath = (p) => { const seg = String(p || '').split('/')[1] || ''; const mod = STEMS[seg] ? seg : SEGMENT_MOD[seg]; return mod && ALL_MODULES.includes(mod) ? mod : null; }; let lastBlocked = null; const takeBlocked = () => { const b = lastBlocked && Date.now() - lastBlocked.at < 15000 ? lastBlocked : null; lastBlocked = null; return b; }; const wrapUpstream = async (ctx, next) => { await next(); if (!isMobile() || ctx.status < 300 || ctx.status >= 400) return; const to = String(ctx.response.get('Location') || ''); // guardar en Modulos: volver con el aviso de guardado if (ctx.method === 'POST' && ctx.path === '/save-modules') { ctx.redirect('/modules?saved=1'); return; } if (ctx.method === 'GET' && ctx.path !== '/modules' && /^(https?:\/\/[^/]+)?\/modules$/.test(to)) { const mod = modOfPath(ctx.path); if (mod) lastBlocked = { mod, path: ctx.path, at: Date.now() }; } }; 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 // Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream .get('/modules', async (ctx, next) => { if (!isMobile()) return next(); ctx.body = mobileModulesView({ blocked: takeBlocked(), saved: ctx.query.saved === '1' }); }) // activar un solo modulo desde el aviso de Modulos y volver a donde se queria ir .post('/modules/enable', koaBody(), async (ctx) => { if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } const mod = String((ctx.request.body || {}).mod || '').replace(/Mod$/, ''); if (!ALL_MODULES.includes(mod)) { ctx.redirect('/modules'); return; } const cfg = getConfig(); cfg.modules[`${mod}Mod`] = 'on'; saveConfig(cfg); const back = String((ctx.request.body || {}).returnTo || ''); ctx.redirect(isLocalPath(back) ? back : `/${mod}`); }) // 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 compose([wrapUpstream, forkRouter.routes()]); };