From e5e8e53374ff72aab6b9e8dbb629990ab08aa866 Mon Sep 17 00:00:00 2001 From: SITO Date: Tue, 18 Aug 2026 21:32:06 +0200 Subject: [PATCH] backend: mover las rutas de Karvan a un router propio backend.js concentra las 577 rutas de upstream en una sola cadena fluida y se mueve +905/-298 lineas por release. Las 8 rutas de Karvan, su modelo, el import de vistas y las tres funciones de relay cross-device vivian intercaladas ahi, asi que cada integracion las ponia en conflicto. Pasan a src/backend/fork_routes.js, un router propio que se inserta en la cadena de middleware justo antes del router de upstream; lo que no casa cae a next(). Las dependencias (cooler, pull, pmModel, checkMod, getViewerId) se inyectan en vez de reconstruirse, para no abrir un segundo cliente muxrpc contra el sbot. backend.js pasa de 9067 a 8932 lineas. Quedan 2 lineas de enganche en lugar de 137. Verificado: el router monta las 8 rutas (/karvan, /karvan/create, /karvan/:id, /karvan/:id/invite, /karvan/:id/msg, /karvan/:id/msgs y /karvan/:id/signal en GET y POST) y no quedan referencias huerfanas en backend.js. --- src/backend/backend.js | 139 +------------------------------- src/backend/fork_routes.js | 161 +++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 137 deletions(-) create mode 100644 src/backend/fork_routes.js diff --git a/src/backend/backend.js b/src/backend/backend.js index 763ae8a..f5b56be 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -637,60 +637,7 @@ const documentsModel = require("../models/documents_model")({ cooler, isPublic: const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public }); const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }); -const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC) -// 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 tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel }); const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel }); @@ -1460,7 +1407,6 @@ const { jobsView, singleJobsView, renderJobForm, clearnetJobView } = require(".. const { shopsView, singleShopView, singleProductView, editProductView, shopOrdersView, myPurchasesView, clearnetShopView, renderShopInvitePage } = require("../views/shops_view"); const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view"); const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view"); -const { karvanView, karvanRoomView } = require("../views/karvan_view"); const { calendarsView, singleCalendarView, renderCalendarInvitePage } = require("../views/calendars_view"); const { projectsView, singleProjectView, clearnetProjectView } = require("../views/projects_view") const { industryView, singleFacilityView, singleBuildView, singleBlueprintView, blueprintEditView, buildEditView } = require("../views/industry_view") @@ -3690,89 +3636,6 @@ router } ctx.body = await mentionsView({ messages: combined, myFeedId }); }) - // ===== 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('/opinions', async (ctx) => { const filter = qf(ctx, 'TOP'); let opinions = await opinionsModel.listOpinions(filter); @@ -8827,6 +8690,8 @@ const middleware = [ } await next(); }, + // rutas propias de la rama (Karvan): fuera de la cadena de upstream + require('./fork_routes')({ cooler, pull, pmModel, checkMod, getViewerId }), routes, ]; const app = http({ host, port, middleware, allowHost: config.allowHost }); diff --git a/src/backend/fork_routes.js b/src/backend/fork_routes.js new file mode 100644 index 0000000..fe5288f --- /dev/null +++ b/src/backend/fork_routes.js @@ -0,0 +1,161 @@ +"use strict"; +// Rutas propias de la rama experimental (modulo Karvan: salas efimeras en RAM, +// mensajes temporales y senalizacion WebRTC). +// +// Viven fuera de backend.js a proposito: ese fichero concentra las 577 rutas de +// upstream en una sola cadena fluida y se mueve +905/-298 lineas por release, asi +// que cualquier ruta anadida en medio entra en conflicto en cada integracion. +// Aqui se montan en un router propio que se inserta en la cadena de middleware +// antes del router de upstream; lo que no casa cae a next() y sigue su curso. +// +// 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 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 }) => { +// 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 + // ===== 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: [] }; + }); + + return forkRouter.routes(); +};