From a22e4a49d4188a3ba8ff5b5324072ab52cb70797 Mon Sep 17 00:00:00 2001 From: s1to Date: Wed, 19 Aug 2026 11:08:55 +0200 Subject: [PATCH] identidades: selector en los ajustes, con reinicio en movil La base de multicuenta ya estaba en el arranque (OASIS_ACCOUNT), pero solo se podia elegir por linea de comandos. Ahora hay una seccion en los ajustes que lista las identidades, marca cual esta en uso y permite crear una. Como funciona: - accounts.js encuentra las identidades buscando directorios ~/. con un secret dentro, y lee de el su @id. En Android ese HOME es el almacenamiento privado de la app, asi que quedan aisladas del resto del sistema. - El selector anota la elegida en src/configs/active-account; ssb_config.js la usa al arrancar si no hay OASIS_ACCOUNT (que sigue mandando). - Crear una identidad solo anota el nombre: el secret lo genera ssb-config al arrancar con esa cuenta. En movil hay ademas un boton de reinicio, porque aqui cerrar la app no basta: el backend vive en un servicio en primer plano que sobrevive. La ruta responde la pagina y luego termina el proceso; el servicio se recrea y node arranca con la identidad nueva. No se puede levantar el runtime dos veces en el mismo proceso, de ahi salir en vez de reiniciar por dentro. Comprobado en el emulador: al reiniciar, el feed pasa a ser el de la otra identidad y la anterior sigue intacta. Dos carpetas pueden tener el mismo secret copiado, y publicar desde las dos parte el registro sin arreglo posible. La lista lo detecta comparando los @id y lo avisa en rojo en las dos filas. --- src/backend/accounts.js | 74 +++++++++++++++++++ src/backend/fork_routes.js | 40 ++++++++++ src/client/assets/styles/karvan.css | 12 +++ .../assets/translations/fork/i18n_fork.js | 22 ++++++ src/server/ssb_config.js | 12 ++- src/views/fork/identities.js | 69 +++++++++++++++++ src/views/settings_view.js | 4 + 7 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 src/backend/accounts.js create mode 100644 src/views/fork/identities.js diff --git a/src/backend/accounts.js b/src/backend/accounts.js new file mode 100644 index 0000000..29330f9 --- /dev/null +++ b/src/backend/accounts.js @@ -0,0 +1,74 @@ +/** + * Identidades locales. + * + * Cada identidad vive en su propio directorio ~/., con su secret, su base + * de datos y sus blobs. Cual esta activa lo decide ssb_config.js al arrancar: + * primero OASIS_ACCOUNT, y si no, el fichero que escribe setActive(). + * + * Aqui no se cambia de identidad en caliente: medio Oasis captura la referencia al + * sbot al cargarse, asi que cambiarla con el proceso vivo dejaria modulos hablando + * con la identidad anterior. El cambio se aplica al reiniciar. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// mismo criterio que ssb_config.js: este nombre acaba en rutas de fichero +const ACCOUNT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/; +const ACTIVE_FILE = path.join(__dirname, '..', 'configs', 'active-account'); + +const isValid = (name) => typeof name === 'string' && ACCOUNT_RE.test(name); + +/** Cuenta anotada como activa, o null si no hay ninguna anotada. */ +const readActive = () => { + try { + const raw = fs.readFileSync(ACTIVE_FILE, 'utf8').trim(); + return isValid(raw) ? raw : null; + } catch (_) { return null; } +}; + +/** Anota cual sera la cuenta activa en el proximo arranque. */ +const setActive = (name) => { + if (!isValid(name)) throw new Error('nombre de cuenta no valido'); + fs.mkdirSync(path.dirname(ACTIVE_FILE), { recursive: true }); + fs.writeFileSync(ACTIVE_FILE, name, { mode: 0o600 }); + return name; +}; + +/** Directorio de una cuenta. */ +const dirOf = (name) => path.join(os.homedir(), `.${name}`); + +/** + * Identidades que existen en el disco: directorios ~/. con un secret dentro. + * El secret es lo que hace que un directorio sea una identidad y no otra cosa. + */ +const list = () => { + let entries = []; + try { + entries = fs.readdirSync(os.homedir(), { withFileTypes: true }); + } catch (_) { return []; } + + return entries + .filter((e) => e.isDirectory() && e.name.startsWith('.')) + .map((e) => e.name.slice(1)) + .filter(isValid) + .filter((name) => { + try { return fs.statSync(path.join(dirOf(name), 'secret')).isFile(); } + catch (_) { return false; } + }) + .sort(); +}; + +/** Identidad publica (@...) de una cuenta, leyendo su secret. Null si no se puede. */ +const feedIdOf = (name) => { + if (!isValid(name)) return null; + try { + const raw = fs.readFileSync(path.join(dirOf(name), 'secret'), 'utf8'); + const clean = raw.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n'); + const id = JSON.parse(clean).id; + return typeof id === 'string' ? id : null; + } catch (_) { return null; } +}; + +module.exports = { isValid, list, readActive, setActive, feedIdOf, dirOf, ACTIVE_FILE }; diff --git a/src/backend/fork_routes.js b/src/backend/fork_routes.js index 0f60566..c849cbe 100644 --- a/src/backend/fork_routes.js +++ b/src/backend/fork_routes.js @@ -190,6 +190,46 @@ async function karvanInviteAuthor(roomId) { 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); + }) .get('/legacy/export', async (ctx) => { if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } const pw = ctx.query && ctx.query.password; diff --git a/src/client/assets/styles/karvan.css b/src/client/assets/styles/karvan.css index 36fc1f5..2f33974 100644 --- a/src/client/assets/styles/karvan.css +++ b/src/client/assets/styles/karvan.css @@ -89,3 +89,15 @@ .kc-btn-start { font-weight: 600; } .kc-btn-stop { background: #241414; border-color: #5a2a2a; } .kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; } + +/* ========================================================================== + Selector de identidades (ajustes) + ========================================================================== */ +.identity-list { list-style: none; margin: 10px 0; padding: 0; } +.identity-row { display: flex; align-items: center; justify-content: space-between; + gap: 12px; flex-wrap: wrap; padding: 8px 0; border-bottom: 1px solid #2a2a2a; } +.identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.identity-name { color: #FFD700; font-weight: 700; } +.identity-feed { color: #FFDD44; font-size: 12px; font-family: monospace; word-break: break-all; } +.identity-warn { color: #ff8a8a; font-size: 12px; } +.identity-active { color: #35d07f; font-size: 13px; font-weight: 600; white-space: nowrap; } diff --git a/src/client/assets/translations/fork/i18n_fork.js b/src/client/assets/translations/fork/i18n_fork.js index ddaf8a1..75f2c3d 100644 --- a/src/client/assets/translations/fork/i18n_fork.js +++ b/src/client/assets/translations/fork/i18n_fork.js @@ -14,6 +14,17 @@ module.exports = { menuNetwork: "Network", menuMedia: "Media", fediverse: "Fediverse", + identitiesRestart: "Restart now", + identitiesRestarting: "Restarting. Open Oasis again in a few seconds.", + identitiesTitle: "Identities", + identitiesDescription: "Each identity lives in its own folder, with its own key, database and blobs. The change applies on restart.", + identitiesActive: "In use", + identitiesUse: "Use this one", + identitiesCreate: "Create identity", + identitiesNewName: "Name for the new identity", + identitiesRestartNeeded: "Selected. Restart Oasis to enter with this identity.", + identitiesSameFeed: "Shares key with another identity: never publish from both.", + identitiesInvalidName: "Invalid name. Letters, numbers, hyphen and underscore, up to 32.", modulesKarvanLabel: "Karvan", modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.", chatReply: "Reply", @@ -63,6 +74,17 @@ module.exports = { menuNetwork: "Red", menuMedia: "Medios", fediverse: "Fediverso", + identitiesRestart: "Reiniciar ahora", + identitiesRestarting: "Reiniciando. Vuelve a abrir Oasis en unos segundos.", + identitiesTitle: "Identidades", + identitiesDescription: "Cada identidad vive en su propia carpeta, con su clave, su base de datos y sus blobs. El cambio se aplica al reiniciar.", + identitiesActive: "En uso", + identitiesUse: "Usar esta", + identitiesCreate: "Crear identidad", + identitiesNewName: "Nombre de la nueva identidad", + identitiesRestartNeeded: "Seleccionada. Reinicia Oasis para entrar con esta identidad.", + identitiesSameFeed: "Comparte clave con otra identidad: no publiques nunca desde las dos.", + identitiesInvalidName: "Nombre no valido. Letras, numeros, guion y guion bajo, hasta 32.", modulesKarvanLabel: "Karvan", modulesKarvanDescription: "Salas efimeras: los mensajes viven solo en memoria y se autodestruyen.", chatReply: "Responder", diff --git a/src/server/ssb_config.js b/src/server/ssb_config.js index b3aca81..551da1a 100644 --- a/src/server/ssb_config.js +++ b/src/server/ssb_config.js @@ -18,11 +18,19 @@ const cliArgs = ~i ? argv.slice(0, i) : argv; // Se valida a proposito: este nombre acaba en rutas de fichero y hay operaciones // destructivas (el modo panico borra el directorio de la cuenta) que no deben poder // apuntar fuera de sitio. +// +// Manda la variable de entorno; si no esta, la cuenta que dejo anotada el selector +// de identidades de los ajustes; y si tampoco, 'ssb'. const ACCOUNT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/; -const rawAccount = process.env.OASIS_ACCOUNT || 'ssb'; +let annotated = null; +try { + const f = path.resolve(__dirname, '../configs', 'active-account'); + annotated = fs.readFileSync(f, 'utf8').trim() || null; +} catch (_) {} +const rawAccount = process.env.OASIS_ACCOUNT || annotated || 'ssb'; const account = ACCOUNT_RE.test(rawAccount) ? rawAccount : 'ssb'; if (account !== rawAccount) { - console.error(`[oasis] OASIS_ACCOUNT no valido (${rawAccount}); se usa 'ssb'`); + console.error(`[oasis] cuenta no valida (${rawAccount}); se usa 'ssb'`); } let config = Config(account, minimist(conf)); diff --git a/src/views/fork/identities.js b/src/views/fork/identities.js new file mode 100644 index 0000000..6860c09 --- /dev/null +++ b/src/views/fork/identities.js @@ -0,0 +1,69 @@ +/** + * Selector de identidades para los ajustes. + * + * Vive aparte de settings_view.js para que las reescrituras de upstream sobre ese + * fichero no arrastren esto: alli solo hay una linea que llama a renderIdentities(). + * + * No cambia de identidad en caliente. Anota cual sera la del proximo arranque y lo + * dice; el porque esta en src/backend/accounts.js. + */ + +module.exports = ({ i18n, html }) => { + const { form, button, div, h2, p, section, input, span, ul, li } = html; + const accounts = require('../../backend/accounts.js'); + + const renderIdentities = (activeAccount) => { + const names = accounts.list(); + const feeds = new Map(names.map((n) => [n, accounts.feedIdOf(n)])); + + // dos carpetas con el mismo feed son la misma identidad copiada: publicar desde + // las dos parte el registro y no tiene arreglo, asi que se avisa en la lista + const seen = new Map(); + for (const [n, id] of feeds) { + if (!id) continue; + seen.set(id, (seen.get(id) || 0) + 1); + } + + const row = (name) => { + const id = feeds.get(name); + const isActive = name === activeAccount; + const duplicated = id && seen.get(id) > 1; + return li({ class: "identity-row" }, + div({ class: "identity-info" }, + span({ class: "identity-name" }, name), + span({ class: "identity-feed" }, id ? `${id.slice(0, 18)}…` : "—"), + duplicated ? span({ class: "identity-warn" }, i18n.identitiesSameFeed) : null + ), + isActive + ? span({ class: "identity-active" }, i18n.identitiesActive) + : form({ method: "POST", action: "/settings/identity/use" }, + input({ type: "hidden", name: "account", value: name }), + button({ type: "submit" }, i18n.identitiesUse) + ) + ); + }; + + // en movil el backend lo mantiene vivo un servicio en primer plano, asi que + // cerrar la app no basta para reiniciarlo: hace falta pedirlo + const restart = process.env.OASIS_MOBILE === '1' + ? form({ method: "POST", action: "/settings/identity/restart" }, + button({ type: "submit" }, i18n.identitiesRestart)) + : null; + + return section( + div({ class: "tags-header" }, + h2(i18n.identitiesTitle), + p(i18n.identitiesDescription), + ul({ class: "identity-list" }, names.map(row)), + form({ method: "POST", action: "/settings/identity/create" }, + input({ type: "text", name: "account", placeholder: i18n.identitiesNewName, + maxlength: "32", autocomplete: "off" }), + button({ type: "submit" }, i18n.identitiesCreate) + ), + restart + ) + ); + }; + + return { renderIdentities }; +}; diff --git a/src/views/settings_view.js b/src/views/settings_view.js index d3099bb..6208e40 100644 --- a/src/views/settings_view.js +++ b/src/views/settings_view.js @@ -5,6 +5,9 @@ const { getConfig } = require('../configs/config-manager.js'); const { template, selectedLanguage, i18n, setLanguage } = require('./main_views'); const i18nBase = require("../client/assets/translations/i18n"); const { WORKFLOWS, currentWorkflow } = require('../models/workflows_model'); +const html = require("../server/node_modules/hyperaxe"); +// selector de identidades: la implementacion vive en ./fork/identities.js +const { renderIdentities } = require('./fork/identities')({ i18n, html }); const snhUrl = "https://wiki.solarnethub.com/socialnet/overview"; @@ -89,6 +92,7 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = updateButton ) ), + renderIdentities(require('../server/ssb_config').account), section( div({ class: "tags-header" }, h2(i18n.language),