backend: lo del movil sale de backend.js a mobile_layer.js

De 20 trozos nuestros en backend.js quedan 9 (21 lineas). Pasan a
src/backend/mobile_layer.js, que monta el router propio delante de las rutas
del dev:
- blockexplorer, actualizar con git y presets de modulos, cortados en el movil
- los modulos que no van en el movil, apagados al arrancar y al guardar
- avatar de la barra de arriba, filtro y lanzador
- fotograma en los videos y pestaña actual marcada
- descargas de la copia de seguridad por GET
El cambio de tema del movil lo atiende karvan_routes.js.
This commit is contained in:
hacklab 2026-10-19 13:31:24 +02:00
parent e7892c4bf9
commit 1148e6a472
4 changed files with 164 additions and 89 deletions

View file

@ -69,6 +69,9 @@ Aquí se puede hacer lo que haga falta.
- `src/views/hive_views.js`: lanzador y barras de arriba y abajo. - `src/views/hive_views.js`: lanzador y barras de arriba y abajo.
- `src/views/mobile_icons.js`: los iconos. - `src/views/mobile_icons.js`: los iconos.
- `src/backend/karvan_routes.js`: nuestro router. Va justo delante del del dev. - `src/backend/karvan_routes.js`: nuestro router. Va justo delante del del dev.
- `src/backend/mobile_layer.js`: lo que el móvil necesita del backend (rutas que se
cortan, módulos que no van, avatar y lanzador, retoques del HTML, descargas de la
copia de seguridad). La monta `karvan_routes.js`.
- `src/models/fork_workflows.js`: nuestros workflows. - `src/models/fork_workflows.js`: nuestros workflows.
- `src/client/assets/translations/i18n_fork.js`: nuestros textos. - `src/client/assets/translations/i18n_fork.js`: nuestros textos.
- `src/client/assets/images/ux-*.svg`: nuestras imágenes. - `src/client/assets/images/ux-*.svg`: nuestras imágenes.
@ -172,8 +175,12 @@ inspeccionar la WebView desde `chrome://inspect`.
Cosas de antes de estas normas que aún no las cumplen, por orden de lo que más Cosas de antes de estas normas que aún no las cumplen, por orden de lo que más
duele: duele:
1. Quedan 21 trozos nuestros en `backend.js` (copia de seguridad, avatar de la barra 1. En `backend.js` quedan 9 trozos pequeños (21 líneas) que no se pueden sacar desde
de arriba, vídeos, filtro del lanzador...). Tienen que irse al router propio. fuera: tres expresiones regulares que con el Node 18 de Android tumbaban el
arranque (esto habría que contárselo al dev), no abrir el navegador al arrancar,
no repetir fotos ya limpiadas, el modo chats, Karvan en la lista de Módulos de
escritorio y la línea que monta nuestro router. Lo demás ya está en
`src/backend/mobile_layer.js`.
2. En `workflows_model.js` queda la logica de skins del movil (`themeOf`, `sameTheme`): 2. En `workflows_model.js` queda la logica de skins del movil (`themeOf`, `sameTheme`):
`settings_view.js` coge `currentWorkflow` al cargar y no se puede envolver desde `settings_view.js` coge `currentWorkflow` al cargar y no se puede envolver desde
fuera sin tocar esa vista. Es poco, pero es del dev. fuera sin tocar esa vista. Es poco, pero es del dev.

View file

@ -1644,17 +1644,6 @@ debug("Current configuration: %O", config);
debug(`You can save the above to ${defaultConfigFile} to make \ debug(`You can save the above to ${defaultConfigFile} to make \
these settings the default. See the readme for details.`); these settings the default. See the readme for details.`);
const { saveConfig, getConfig } = require('../configs/config-manager'); const { saveConfig, getConfig } = require('../configs/config-manager');
if (process.env.OASIS_MOBILE === '1') {
try {
const cfg = getConfig();
const forced = ['ai', 'aiNav', 'dev', 'games', 'logs', 'maps', 'pixelia'].filter(m => cfg.modules && cfg.modules[`${m}Mod`] !== 'off');
let dirty = false;
if (forced.length) { forced.forEach(m => { cfg.modules[`${m}Mod`] = 'off'; }); dirty = true; }
const w = cfg.wallet || {};
if (w.url === 'http://127.0.0.1:17474' && w.user === 'oasis' && w.pass === 'testpass123') { cfg.wallet = { ...w, url: 'http://127.0.0.1:7474', user: '', pass: '' }; dirty = true; }
if (dirty) saveConfig(cfg);
} catch (_) {}
}
const oasisCheckPath = "/.well-known/oasis"; const oasisCheckPath = "/.well-known/oasis";
process.on("uncaughtException", function (err) { process.on("uncaughtException", function (err) {
if (err["code"] === "EADDRINUSE") { if (err["code"] === "EADDRINUSE") {
@ -5114,7 +5103,6 @@ router
}) })
.get('/blockexplorer', async (ctx) => { .get('/blockexplorer', async (ctx) => {
if (process.env.OASIS_MOBILE === '1') { ctx.redirect('/'); return; }
const userId = getViewerId(); const userId = getViewerId();
const query = ctx.query || {}; const query = ctx.query || {};
const search = { const search = {
@ -5165,7 +5153,6 @@ router
ctx.body = renderBlockchainView(blockchainData, filter, userId, search, { inspect, censusBlocks }); ctx.body = renderBlockchainView(blockchainData, filter, userId, search, { inspect, censusBlocks });
}) })
.get('/blockexplorer/block/:id', async (ctx) => { .get('/blockexplorer/block/:id', async (ctx) => {
if (process.env.OASIS_MOBILE === '1') { ctx.redirect('/'); return; }
const userId = getViewerId(); const userId = getViewerId();
const query = ctx.query || {}; const query = ctx.query || {};
const search = { const search = {
@ -8487,7 +8474,7 @@ router
profile: true, profile: true,
ux: true, ux: true,
workflow: isLoopbackRequest(ctx), workflow: isLoopbackRequest(ctx),
theme: true, // en el movil ofrece los skins de movil (WIZARD_THEMES) theme: true,
community: checkMod(ctx, 'invitesMod'), community: checkMod(ctx, 'invitesMod'),
greeting: checkMod(ctx, 'feedMod') greeting: checkMod(ctx, 'feedMod')
}; };
@ -11522,40 +11509,6 @@ router
try { await forumModel.removeOpenInvite(ctx.params.id); } catch (_) { actionFail(ctx); return; } try { await forumModel.removeOpenInvite(ctx.params.id); } catch (_) { actionFail(ctx); return; }
ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(ctx.params.id)}`, ['/forum'])); ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(ctx.params.id)}`, ['/forum']));
}) })
.get('/backup/keys/export', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (process.env.OASIS_MOBILE !== '1') { ctx.redirect('/backup'); return; }
const pw = String(ctx.query.password || '');
if (!pw || pw.length < 32) return ctx.redirect('/backup');
try {
const { filename, data } = await backupModel.exportKeys({ password: pw });
ctx.set('Content-Type', 'application/octet-stream');
ctx.set('Content-Disposition', `attachment; filename="${filename}"`);
ctx.set('Content-Length', String(data.length));
ctx.body = data;
try { onboardingModel.markStep('backup'); } catch (_) {}
} catch (error) { sendErrorPage(ctx, error.message); }
})
.get('/backup/export', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (process.env.OASIS_MOBILE !== '1') { ctx.redirect('/backup'); return; }
if (!checkMod(ctx, 'backupMod')) return ctx.redirect('/modules');
const b = ctx.query || {};
const pw = String(b.password || '');
if (pw.length < 32) return ctx.redirect('/backup');
const rawMods = b.modules === undefined ? [] : (Array.isArray(b.modules) ? b.modules : [b.modules]);
const outPath = path.join(os.tmpdir(), `oasis-backup-${process.pid}-${Date.now()}.oasisbk`);
try {
const res = await backupModel.createBackup({ scope: b.scope, blobs: b.blobs, modules: rawMods, since: b.since }, pw, outPath);
ctx.set('Content-Type', 'application/octet-stream');
ctx.set('Content-Disposition', `attachment; filename="${res.filename}"`);
ctx.set('Content-Length', String(res.bytes));
const stream = fs.createReadStream(outPath);
stream.on('close', () => { try { fs.unlinkSync(outPath); } catch (_) {} });
ctx.body = stream;
try { onboardingModel.markStep('backup'); } catch (_) {}
} catch (error) { try { fs.unlinkSync(outPath); } catch (_) {} sendErrorPage(ctx, error.message); }
})
.post('/backup/keys/export', koaBody(), async (ctx) => { .post('/backup/keys/export', koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const pw = ctx.request.body.password; const pw = ctx.request.body.password;
@ -14648,7 +14601,6 @@ router
}) })
.post("/update", koaBody(), async (ctx) => { .post("/update", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (process.env.OASIS_MOBILE === '1') { ctx.redirect('/settings'); return; }
const exec = require("node:util").promisify(require("node:child_process").exec); const exec = require("node:util").promisify(require("node:child_process").exec);
const repoRoot = path.resolve(__dirname, '..', '..'); const repoRoot = path.resolve(__dirname, '..', '..');
const { stdout, stderr } = await exec("git reset --hard && git pull", { cwd: repoRoot }); const { stdout, stderr } = await exec("git reset --hard && git pull", { cwd: repoRoot });
@ -14669,8 +14621,6 @@ router
}) })
.post("/settings/theme", koaBody(), async (ctx) => { .post("/settings/theme", koaBody(), async (ctx) => {
const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig(); const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig();
// en el movil solo los skins de movil: un tema de escritorio deja la app sin maqueta
if (process.env.OASIS_MOBILE === '1' && !require('../views/main_views').MOBILE_THEMES.some(([value]) => value === theme)) { ctx.redirect("/settings#theme"); return; }
cfg.themes.current = theme || "Dark-SNH"; cfg.themes.current = theme || "Dark-SNH";
saveConfig(cfg); saveConfig(cfg);
ctx.cookies.set("theme", cfg.themes.current, { httpOnly: true, sameSite: 'strict', secure: ctx.secure }); ctx.cookies.set("theme", cfg.themes.current, { httpOnly: true, sameSite: 'strict', secure: ctx.secure });
@ -15177,7 +15127,6 @@ router
}) })
.post("/modules/preset", koaBody(), async (ctx) => { .post("/modules/preset", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (process.env.OASIS_MOBILE === '1') { ctx.redirect('/modules'); return; }
const ALL_MODULES = workflowsModel.ALL_MODULES; const ALL_MODULES = workflowsModel.ALL_MODULES;
const PRESETS = workflowsModel.PRESETS; const PRESETS = workflowsModel.PRESETS;
const preset = String(ctx.request.body.preset || ''); const preset = String(ctx.request.body.preset || '');
@ -15189,14 +15138,10 @@ router
ctx.redirect('/modules'); ctx.redirect('/modules');
}) })
.post("/save-modules", koaBody(), async (ctx) => { .post("/save-modules", koaBody(), async (ctx) => {
const MOBILE_UNAVAILABLE_MODS = ['ai', 'aiNav', 'dev', 'games', 'logs', 'maps', 'pixelia'];
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const modules = workflowsModel.ALL_MODULES; const modules = workflowsModel.ALL_MODULES;
const cfg = getConfig(); const cfg = getConfig();
modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off'); modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off');
if (process.env.OASIS_MOBILE === '1') {
MOBILE_UNAVAILABLE_MODS.forEach(mod => cfg.modules[`${mod}Mod`] = 'off');
}
saveConfig(cfg); saveConfig(cfg);
ctx.redirect(`/modules`); ctx.redirect(`/modules`);
}) })
@ -15347,19 +15292,6 @@ const folderBytes = async (dir) => {
return bytes; return bytes;
}; };
const refreshAll = async (now) => { const refreshAll = async (now) => {
try {
// avatar propio para el circulo de la topbar movil (lo lee renderMobileTopbar)
const meId = getViewerId();
const selfImg = meId ? await about.image(meId) : null;
const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256';
if (typeof selfImg === 'string' && selfImg[0] === '&' && selfImg !== NULL_IMG) {
global.__OASIS_SELF_AVATAR__ = `/image/256/${encodeURIComponent(selfImg)}`;
} else if (typeof selfImg === 'string' && selfImg[0] === '/') {
global.__OASIS_SELF_AVATAR__ = selfImg;
} else {
global.__OASIS_SELF_AVATAR__ = null;
}
} catch (_) {}
try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {} try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {}
try { await bankingModel.getUserEngagementScore(getViewerId()); } catch (_) {} try { await bankingModel.getUserEngagementScore(getViewerId()); } catch (_) {}
try { try {
@ -15479,14 +15411,6 @@ const kickRefresh = () => {
}; };
setTimeout(() => { if (!sharedState.getLastRefresh()) kickRefresh(); }, 5000); setTimeout(() => { if (!sharedState.getLastRefresh()) kickRefresh(); }, 5000);
const middleware = [ const middleware = [
async (ctx, next) => {
await next();
if (process.env.OASIS_MOBILE !== '1' || typeof ctx.body !== 'string' || !String(ctx.type || '').includes('html')) return;
ctx.body = ctx.body.replace(/<video\b[^>]*>/g, (tag) => tag.replace(/\bsrc="(\/blob\/[^"#]+)"/, 'src="$1#t=0.1"'));
const tab = ['/mentions', '/inbox', '/phone', '/data', '/tags', '/search', '/settings'].find((t) => ctx.path === t || ctx.path.startsWith(t + '/'));
const headerEnd = ctx.body.indexOf('<div class="main-content">');
if (tab && headerEnd > 0) ctx.body = ctx.body.slice(0, headerEnd).replace(`<a href="${tab}"`, `<a href="${tab}" aria-current="page"`) + ctx.body.slice(headerEnd);
},
async (ctx, next) => { async (ctx, next) => {
if (ctx.method === 'GET' && /^recent$/i.test(String(ctx.query.filter || '')) && ['q', 'search'].some(k => String(ctx.query[k] || '').trim())) ctx.query = { ...ctx.query, filter: ctx.query.filter === 'RECENT' ? 'ALL' : 'all' }; if (ctx.method === 'GET' && /^recent$/i.test(String(ctx.query.filter || '')) && ['q', 'search'].some(k => String(ctx.query[k] || '').trim())) ctx.query = { ...ctx.query, filter: ctx.query.filter === 'RECENT' ? 'ALL' : 'all' };
await next(); await next();
@ -15586,18 +15510,13 @@ const middleware = [
}, },
async (ctx, next) => { async (ctx, next) => {
if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/') && !ctx.path.startsWith('/qr') && !ctx.path.startsWith('/c/')) { if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/') && !ctx.path.startsWith('/qr') && !ctx.path.startsWith('/c/')) {
// filtro de la home (Personal/Community); lo leen la topbar y el lanzador
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
// el lanzador de modulos SOLO en la home; en el resto de paginas el top queda limpio
global.__OASIS_SHOW_HIVE__ = (ctx.path === '/activity' || ctx.path === '/');
if (Date.now() - sharedState.getLastRefresh() > 60000) kickRefresh(); if (Date.now() - sharedState.getLastRefresh() > 60000) kickRefresh();
} }
await next(); await next();
}, },
// rutas propias de la rama (Karvan, barra inferior, exportacion): fuera de la cadena de upstream // la rama movil: su router y su capa de backend (ver GUIA_DE_DESARROLLO.md)
require('./karvan_routes')({ cooler, pull, pmModel, checkMod, getViewerId, require('./karvan_routes')({ cooler, pull, pmModel, checkMod, getViewerId, getConfig, saveConfig,
getConfig, saveConfig, isLoopbackRequest, sendErrorPage, about, backupModel, onboardingModel }),
isLoopbackRequest, sendErrorPage }),
routes, routes,
async (ctx) => { async (ctx) => {
if (ctx.body != null || (ctx.method !== 'GET' && ctx.method !== 'HEAD')) return; if (ctx.body != null || (ctx.method !== 'GET' && ctx.method !== 'HEAD')) return;

View file

@ -60,7 +60,10 @@ const wrapUpstream = async (ctx, next) => {
}; };
module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId, module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId,
getConfig, saveConfig, isLoopbackRequest, sendErrorPage }) => { getConfig, saveConfig, isLoopbackRequest, sendErrorPage,
about, backupModel, onboardingModel }) => {
const mobileLayer = require('./mobile_layer')({ getConfig, saveConfig, getViewerId, about, backupModel, onboardingModel,
checkMod, isLoopbackRequest, sendErrorPage });
// FORK_IA_UX Fase 2 (cross-device): relay de MENSAJES de Karvan por privados SSB. Funciona vía un pub // 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). // (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. // No toca el arranque del SSB. Ingesta por stream EN VIVO (no polling) → eficiente.
@ -116,6 +119,19 @@ async function karvanInviteAuthor(roomId) {
const forkRouter = new koaRouter(); const forkRouter = new koaRouter();
forkRouter forkRouter
// tema en el movil: solo los skins de movil (uno de escritorio deja la app sin
// maqueta). Hace lo mismo que la ruta del dev, con esa comprobacion delante
.post('/settings/theme', koaBody(), async (ctx, next) => {
if (!isMobile()) return next();
const theme = String((ctx.request.body || {}).theme || '').trim();
const { MOBILE_THEMES } = require('../views/main_views');
if (!MOBILE_THEMES.some(([value]) => value === theme)) { ctx.redirect('/settings#theme'); return; }
const cfg = getConfig();
cfg.themes.current = theme;
saveConfig(cfg);
ctx.cookies.set('theme', theme, { httpOnly: true, sameSite: 'strict', secure: ctx.secure });
ctx.redirect('/settings#theme');
})
// Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream // Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream
.get('/modules', async (ctx, next) => { .get('/modules', async (ctx, next) => {
if (!isMobile()) return next(); if (!isMobile()) return next();
@ -287,5 +303,5 @@ async function karvanInviteAuthor(roomId) {
setTimeout(() => process.exit(0), 500); setTimeout(() => process.exit(0), 500);
}); });
return compose([wrapUpstream, forkRouter.routes()]); return compose([mobileLayer, wrapUpstream, forkRouter.routes()]);
}; };

133
src/backend/mobile_layer.js Normal file
View file

@ -0,0 +1,133 @@
"use strict";
// Lo que el movil necesita del backend, fuera de backend.js.
//
// Antes eran trozos metidos en backend.js (rutas, middleware y refreshAll), que es
// del dev y cambia en 34 de cada 39 versiones. Ahora va aqui y lo monta el router
// propio (karvan_routes.js) delante de las rutas del dev. Nada de esto corre en
// escritorio. Ver GUIA_DE_DESARROLLO.md.
//
// Hace cuatro cosas:
// - corta en el movil rutas que alli no tienen sentido (blockexplorer, actualizar
// con git, presets de modulos); el cambio de tema lo atiende karvan_routes.js
// - mantiene apagados los modulos que no van en el movil, al arrancar y al guardar
// - prepara lo que leen la barra de arriba y el lanzador (avatar, filtro, lanzador)
// - retoca el HTML que sale (videos con fotograma, pestaña actual marcada)
// Y sirve las dos descargas de la copia de seguridad, que en el movil van por GET.
const fs = require('fs');
const os = require('os');
const path = require('path');
const compose = require('../server/node_modules/koa-compose');
const MOBILE_UNAVAILABLE_MODS = ['ai', 'aiNav', 'dev', 'games', 'logs', 'maps', 'pixelia'];
const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256';
const TAB_PATHS = ['/mentions', '/inbox', '/phone', '/data', '/tags', '/search', '/settings'];
const isMobile = () => process.env.OASIS_MOBILE === '1';
const isPage = (p) => !/^\/(assets|image|blob|qr|c)(\/|$)/.test(p);
module.exports = ({ getConfig, saveConfig, getViewerId, about, backupModel, onboardingModel,
checkMod, isLoopbackRequest, sendErrorPage }) => {
// --- al arrancar: modulos que no van en el movil, apagados; y la cartera de
// pruebas que traia la config de serie, fuera
if (isMobile()) {
try {
const cfg = getConfig();
let dirty = false;
MOBILE_UNAVAILABLE_MODS.forEach(m => { if (cfg.modules && cfg.modules[`${m}Mod`] !== 'off') { cfg.modules[`${m}Mod`] = 'off'; dirty = true; } });
const w = cfg.wallet || {};
if (w.url === 'http://127.0.0.1:17474' && w.user === 'oasis' && w.pass === 'testpass123') {
cfg.wallet = { ...w, url: 'http://127.0.0.1:7474', user: '', pass: '' };
dirty = true;
}
if (dirty) saveConfig(cfg);
} catch (_) {}
}
// --- avatar propio para el circulo de la barra de arriba (lo lee hive_views)
let avatarAt = 0;
const refreshAvatar = async () => {
avatarAt = Date.now();
try {
const me = getViewerId();
const img = me ? await about.image(me) : null;
if (typeof img === 'string' && img[0] === '&' && img !== NULL_IMG) global.__OASIS_SELF_AVATAR__ = `/image/256/${encodeURIComponent(img)}`;
else if (typeof img === 'string' && img[0] === '/') global.__OASIS_SELF_AVATAR__ = img;
else global.__OASIS_SELF_AVATAR__ = null;
} catch (_) {}
};
// --- rutas que en el movil se cortan antes de llegar al dev
const guards = async (ctx, next) => {
if (!isMobile()) return next();
const p = ctx.path;
if (ctx.method === 'GET' && (p === '/blockexplorer' || p.startsWith('/blockexplorer/block/'))) { ctx.redirect('/'); return; }
if (ctx.method === 'POST' && p === '/update') { ctx.redirect('/settings'); return; }
if (ctx.method === 'POST' && p === '/modules/preset') { ctx.redirect('/modules'); return; }
return next();
};
// --- antes y despues de las rutas del dev
const around = async (ctx, next) => {
if (!isMobile()) return next();
if (isPage(ctx.path)) {
// filtro de la home (Personal/Social) y lanzador solo en la portada
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
global.__OASIS_SHOW_HIVE__ = (ctx.path === '/activity' || ctx.path === '/');
if (!avatarAt) await refreshAvatar();
else if (Date.now() - avatarAt > 60000) refreshAvatar();
}
await next();
// guardar modulos: los que no van en el movil siguen apagados
if (ctx.method === 'POST' && ctx.path === '/save-modules' && ctx.status >= 300 && ctx.status < 400) {
try {
const cfg = getConfig();
MOBILE_UNAVAILABLE_MODS.forEach(m => { cfg.modules[`${m}Mod`] = 'off'; });
saveConfig(cfg);
} catch (_) {}
}
if (typeof ctx.body !== 'string' || !String(ctx.type || '').includes('html')) return;
// los videos sin poster salen en negro en el WebView: se pide el fotograma 0.1
ctx.body = ctx.body.replace(/<video\b[^>]*>/g, (tag) => tag.replace(/\bsrc="(\/blob\/[^"#]+)"/, 'src="$1#t=0.1"'));
// la pestaña de la cabecera que corresponde a esta pagina
const tab = TAB_PATHS.find((t) => ctx.path === t || ctx.path.startsWith(t + '/'));
const headerEnd = ctx.body.indexOf('<div class="main-content">');
if (tab && headerEnd > 0) ctx.body = ctx.body.slice(0, headerEnd).replace(`<a href="${tab}"`, `<a href="${tab}" aria-current="page"`) + ctx.body.slice(headerEnd);
};
// --- copia de seguridad: en el movil se descarga por GET (el WebView no guarda
// la respuesta de un POST como fichero)
const backup = async (ctx, next) => {
if (ctx.method !== 'GET' || (ctx.path !== '/backup/keys/export' && ctx.path !== '/backup/export')) return next();
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!isMobile()) { ctx.redirect('/backup'); return; }
const q = ctx.query || {};
const pw = String(q.password || '');
if (!pw || pw.length < 32) { ctx.redirect('/backup'); return; }
if (ctx.path === '/backup/keys/export') {
try {
const { filename, data } = await backupModel.exportKeys({ password: pw });
ctx.set('Content-Type', 'application/octet-stream');
ctx.set('Content-Disposition', `attachment; filename="${filename}"`);
ctx.set('Content-Length', String(data.length));
ctx.body = data;
try { onboardingModel.markStep('backup'); } catch (_) {}
} catch (error) { sendErrorPage(ctx, error.message); }
return;
}
if (!checkMod(ctx, 'backupMod')) { ctx.redirect('/modules'); return; }
const rawMods = q.modules === undefined ? [] : (Array.isArray(q.modules) ? q.modules : [q.modules]);
const outPath = path.join(os.tmpdir(), `oasis-backup-${process.pid}-${Date.now()}.oasisbk`);
try {
const res = await backupModel.createBackup({ scope: q.scope, blobs: q.blobs, modules: rawMods, since: q.since }, pw, outPath);
ctx.set('Content-Type', 'application/octet-stream');
ctx.set('Content-Disposition', `attachment; filename="${res.filename}"`);
ctx.set('Content-Length', String(res.bytes));
const stream = fs.createReadStream(outPath);
stream.on('close', () => { try { fs.unlinkSync(outPath); } catch (_) {} });
ctx.body = stream;
try { onboardingModel.markStep('backup'); } catch (_) {}
} catch (error) { try { fs.unlinkSync(outPath); } catch (_) {} sendErrorPage(ctx, error.message); }
};
return compose([around, guards, backup]);
};