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..d6304a1 100644 --- a/src/backend/fork_routes.js +++ b/src/backend/fork_routes.js @@ -190,6 +190,30 @@ 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'); + }) .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 ad4a727..31ce8f1 100644 --- a/src/client/assets/translations/fork/i18n_fork.js +++ b/src/client/assets/translations/fork/i18n_fork.js @@ -11,6 +11,17 @@ module.exports = { en: { + 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", @@ -57,6 +68,17 @@ module.exports = { spreadMore: "more" }, es: { + 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..5f9ef3b --- /dev/null +++ b/src/views/fork/identities.js @@ -0,0 +1,61 @@ +/** + * 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) + ) + ); + }; + + 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) + ) + ) + ); + }; + + 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),