docs/FRONTERA_UPSTREAM.md mide como cambia upstream (39 releases de la 1.0.1 a la 1.3.3; backend.js en 34, main_views en 31, OasisMobile.css en 21) y fija que se toca: backend, middleware, modelos, server y traducciones de upstream, no; vistas de upstream, solo una linea de gancho; hojas, vistas, router y modelos propios, si. Fase A, lo del 19/10: - backend.js, workflows_model.js y modules_view.js vuelven a como estaban - seccion apagada, /modules/enable y ?saved=1: en el router propio, que envuelve las redirecciones de upstream en vez de cambiar checkMod - Modulos del movil en views/mobile_modules_view.js, servida por el router propio - workflow propio "acampada" registrado desde models/fork_workflows.js en vez de redefinir "activists" - votaciones, tribus, ajustes, borrar, pie y bienvenida: la logica en views/mobile_ui.js; en cada vista de upstream queda la llamada
291 lines
15 KiB
JavaScript
291 lines
15 KiB
JavaScript
"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 <img>/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()]);
|
|
};
|