OASIS_MOBILE/src/models/wiki_model.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

485 lines
20 KiB
JavaScript

const pull = require('../server/node_modules/pull-stream');
const { readTyped, memoIndex } = require('./typed_log');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const WIKILINK_RE = /\[\[([^\]|\n]{1,120})(?:\|([^\]\n]{1,120}))?\]\]/g;
const slugify = (value) => String(value == null ? '' : value)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
const linkTarget = (raw) => {
const s = String(raw || '').trim();
const isUrl = /^[a-z]+:\/\//i.test(s);
const wikiPath = (isUrl || s.startsWith('/')) ? s.match(/\/wiki\/([^?#]+)/) : null;
if (wikiPath) { try { return decodeURIComponent(wikiPath[1]).trim(); } catch (_) { return wikiPath[1].trim(); } }
if (isUrl) {
const seg = s.replace(/[?#].*$/, '').split('/').filter(Boolean).pop() || '';
try { return decodeURIComponent(seg).trim(); } catch (_) { return seg.trim(); }
}
const namespaced = s.match(/^[a-z]+:(?=\S)(.+)$/i);
return namespaced ? namespaced[1].trim() : s;
};
const extractWikiLinks = (text) => {
const out = [];
const seen = new Set();
for (const m of String(text || '').matchAll(WIKILINK_RE)) {
const slug = slugify(linkTarget(m[1]));
if (!slug || seen.has(slug)) continue;
seen.add(slug);
out.push(slug);
}
return out;
};
const WIKI_TYPE = 'wikiPage';
const ENVELOPE_TYPE = 'tribe-msg';
const EDIT_POLICIES = ['open', 'author', 'tribe'];
const RECENT_MS = 24 * 60 * 60 * 1000;
const MAX_CHANGES = 60;
const MAX_TITLE = 100;
const IMAGE_RE = /!\[image:[^\]\[\n]{0,160}\]\((&[^)\s]{1,120}\.sha256)\)/;
const BODY_INLINE_MAX = 6000;
const bodyCache = new Map();
const addBlob = (ssbClient, buf) => new Promise((resolve, reject) => pull(pull.values([buf]), ssbClient.blobs.add((err, ref) => err ? reject(err) : resolve(ref))));
const getBlob = (ssbClient, ref) => new Promise((resolve) => {
try {
pull(ssbClient.blobs.get(ref), pull.collect((err, chunks) => resolve(err || !chunks || !chunks.length ? null : Buffer.concat(chunks.map(c => Buffer.isBuffer(c) ? c : Buffer.from(c))))));
} catch (_) { resolve(null); }
});
const safeText = (v) => String(v == null ? '' : v).trim();
const normalizeList = (raw) => {
if (!raw) return [];
const arr = Array.isArray(raw) ? raw : String(raw).split(',');
return Array.from(new Set(arr.map(t => String(t || '').trim()).filter(Boolean)));
};
const normalizePolicy = (v) => {
const s = String(v || '').toLowerCase();
if (s === 'closed') return 'author';
return EDIT_POLICIES.includes(s) ? s : 'open';
};
module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const tribeRootId = async (tribeId) => {
if (!tribesModel || !tribeId) return tribeId;
try { return await tribesModel.getRootId(tribeId); } catch (_) { return tribeId; }
};
const tribeKeyFor = async (tribeId) => {
if (!tribeCrypto || !tribeId) return null;
const rootId = await tribeRootId(tribeId);
return tribeCrypto.getKey(rootId) || null;
};
const storeBody = async (ssbClient, body, tribeId) => {
const text = safeText(body);
if (Buffer.byteLength(text, 'utf8') <= BODY_INLINE_MAX) return { body: text };
let payload = text;
if (tribeId) {
const key = await tribeKeyFor(tribeId);
if (!key) throw new Error('Missing tribe key — cannot publish encrypted wiki page');
payload = JSON.stringify(tribeCrypto.encryptContent({ body: text }, [key], true));
}
const ref = await addBlob(ssbClient, Buffer.from(payload, 'utf8'));
bodyCache.set(ref, text);
return { body: '', bodyBlob: ref };
};
const resolveBody = async (ssbClient, c) => {
if (!c || typeof c.bodyBlob !== 'string' || !c.bodyBlob) return c;
if (bodyCache.has(c.bodyBlob)) return { ...c, body: bodyCache.get(c.bodyBlob) };
const buf = await getBlob(ssbClient, c.bodyBlob);
if (!buf) return { ...c, body: safeText(c.body) };
let text = buf.toString('utf8');
if (c.tribeId) {
try {
const key = await tribeKeyFor(c.tribeId);
const r = tribeCrypto.decryptContent(JSON.parse(text), key ? [[key]] : []);
text = r && !r._undecryptable && typeof r.body === 'string' ? r.body : '';
} catch (_) { text = ''; }
}
bodyCache.set(c.bodyBlob, text);
return { ...c, body: text };
};
const readEntries = async (ssbClient) => {
const msgs = await readTyped(ssbClient, [WIKI_TYPE, 'tombstone', ENVELOPE_TYPE], { limit: logLimit });
const fpIdx = tribeCrypto && typeof tribeCrypto.buildFingerprintIndex === 'function' ? tribeCrypto.buildFingerprintIndex() : null;
const out = [];
for (const m of msgs) {
const v = m && m.value;
const c = v && v.content;
if (!c || typeof c !== 'object') continue;
if (c.type === WIKI_TYPE || c.type === 'tombstone') { out.push(m); continue; }
if (!fpIdx || !tribeCrypto.isTribeMsg(c)) continue;
const r = tribeCrypto.unwrapMsg(c, fpIdx);
if (!r || !r.body || typeof r.body !== 'object') continue;
const body = r.body;
if (body.k === WIKI_TYPE) {
const flat = { ...body, type: WIKI_TYPE };
delete flat.k;
out.push({ ...m, value: { ...v, content: flat } });
} else if (body.k === 'tombstone' && typeof body.target === 'string') {
out.push({ ...m, value: { ...v, content: { type: 'tombstone', target: body.target, deletedAt: body.deletedAt, author: body.author || v.author } } });
}
}
for (let i = 0; i < out.length; i++) {
const c = out[i].value && out[i].value.content;
if (c && c.type === WIKI_TYPE && c.bodyBlob) out[i] = { ...out[i], value: { ...out[i].value, content: await resolveBody(ssbClient, c) } };
}
return out;
};
const tribeMembersFor = async (entries) => {
const out = new Map();
if (!tribesModel) return out;
for (const m of entries) {
const c = m.value.content;
if (c.type !== WIKI_TYPE || !c.tribeId || out.has(String(c.tribeId))) continue;
let members = [];
try { const t = await tribesModel.getTribeById(c.tribeId); members = Array.isArray(t && t.members) ? t.members : []; } catch (_) {}
out.set(String(c.tribeId), members);
}
return out;
};
const buildIndex = (entries, membersOf = new Map()) => {
const tomb = buildValidatedTombstoneSet(entries);
const nodes = new Map();
for (const m of entries) {
const c = m.value.content;
if (c.type !== WIKI_TYPE) continue;
nodes.set(m.key, { key: m.key, author: m.value.author, ts: m.value.timestamp || m.timestamp || 0, c });
}
const mayEdit = (state, author) => {
if (author === state.owner) return true;
if (state.policy === 'author') return false;
if (state.tribeId) return (membersOf.get(state.tribeId) || []).includes(author);
return state.policy !== 'tribe';
};
const states = new Map();
const stateOf = (key) => {
const path = [];
const seen = new Set();
let cur = key;
while (!states.has(cur)) {
const node = nodes.get(cur);
if (!node) { states.set(cur, null); break; }
if (typeof node.c.replaces !== 'string') {
states.set(cur, { root: cur, owner: node.author, tribeId: String(node.c.tribeId || ''), policy: normalizePolicy(node.c.editPolicy), license: safeText(node.c.license) });
break;
}
if (seen.has(cur)) { states.set(cur, null); break; }
seen.add(cur);
path.push(cur);
cur = node.c.replaces;
}
for (let i = path.length - 1; i >= 0; i--) {
const k = path[i];
if (states.has(k)) continue;
const node = nodes.get(k);
const parent = states.get(node.c.replaces);
if (!parent || String(node.c.tribeId || '') !== parent.tribeId || !mayEdit(parent, node.author)) { states.set(k, null); continue; }
states.set(k, node.author === parent.owner ? { ...parent, policy: normalizePolicy(node.c.editPolicy), license: safeText(node.c.license) } : parent);
}
return states.get(key);
};
const rootOf = (key) => { const s = stateOf(key); return s ? s.root : key; };
const versionsByRoot = new Map();
for (const node of nodes.values()) {
if (!stateOf(node.key)) continue;
const root = rootOf(node.key);
if (tomb.has(root)) continue;
if (!versionsByRoot.has(root)) versionsByRoot.set(root, []);
versionsByRoot.get(root).push(node);
}
const pages = [];
for (const [root, versions] of versionsByRoot) {
versions.sort((a, b) => a.ts - b.ts);
const first = versions[0];
const tip = versions[versions.length - 1];
const state = stateOf(tip.key);
const c = tip.c;
const body = safeText(c.body);
pages.push({
id: root,
rootId: root,
tipId: tip.key,
slug: slugify(c.title) || safeText(c.slug),
title: safeText(c.title).slice(0, MAX_TITLE),
body,
image: (body.match(IMAGE_RE) || [])[1] || null,
tags: normalizeList(c.tags),
aliases: [...new Set([...normalizeList(c.aliases).map(slugify), safeText(c.slug)].filter(a => a && a !== slugify(c.title)))],
editPolicy: state.policy,
license: state.license,
tribeId: c.tribeId || null,
encrypted: !!c.tribeId,
author: state.owner,
lastAuthor: tip.author,
createdAt: first.c.createdAt || new Date(first.ts).toISOString(),
updatedAt: c.updatedAt || new Date(tip.ts).toISOString(),
ts: tip.ts,
versionCount: versions.length,
links: extractWikiLinks(body),
versions: versions.map(vn => ({
key: vn.key,
author: vn.author,
ts: vn.ts,
createdAt: vn.c.updatedAt || vn.c.createdAt || new Date(vn.ts).toISOString(),
title: safeText(vn.c.title),
body: safeText(vn.c.body),
tags: normalizeList(vn.c.tags),
summary: safeText(vn.c.summary)
}))
});
}
pages.sort((a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0);
return { pages, rootOf, nodes };
};
const sameScope = (page, tribeId) => String(page.tribeId || '') === String(tribeId || '');
const scopePages = async (pages, tribeId) => {
if (tribeId === 'any') return pages;
if (!tribeId) return pages.filter(p => !p.tribeId);
const chain = tribesModel && typeof tribesModel.getChainIds === 'function'
? await tribesModel.getChainIds(tribeId).catch(() => [tribeId])
: [tribeId];
const set = new Set(chain.map(String));
return pages.filter(p => p.tribeId && set.has(String(p.tribeId)));
};
const decorate = (pages, scoped) => {
const bySlug = new Map();
for (const p of scoped) {
const keys = [p.slug, ...p.aliases];
for (const s of keys) if (s && !bySlug.has(s)) bySlug.set(s, p);
}
const inbound = new Map();
for (const p of scoped) {
for (const s of p.links) {
const target = bySlug.get(s);
if (!target || target.id === p.id) continue;
if (!inbound.has(target.id)) inbound.set(target.id, new Set());
inbound.get(target.id).add(p.id);
}
}
return scoped.map(p => ({
...p,
backlinkCount: (inbound.get(p.id) || new Set()).size,
missingLinks: p.links.filter(s => !bySlug.has(s)),
isLinked: (inbound.get(p.id) || new Set()).size > 0
}));
};
const findInScope = (scoped, idOrSlug) => {
const needle = String(idOrSlug || '').trim();
if (!needle) return null;
if (needle.startsWith('%')) return scoped.find(p => p.id === needle || p.tipId === needle || p.versions.some(v => v.key === needle)) || null;
const slug = slugify(needle);
return scoped.find(p => p.slug === slug) || scoped.find(p => p.aliases.includes(slug)) || null;
};
const publishContent = async (ssbClient, content) => {
let msg = content;
if (content.tribeId) {
const key = await tribeKeyFor(content.tribeId);
if (!key) throw new Error('Missing tribe key — cannot publish encrypted wiki page');
msg = tribeCrypto.wrapMsg({ k: WIKI_TYPE, ...content }, key);
}
return new Promise((resolve, reject) => ssbClient.publish(msg, (err, res) => err ? reject(err) : resolve(res)));
};
const load = async () => {
const ssbClient = await openSsb();
const entries = await readEntries(ssbClient);
const members = await tribeMembersFor(entries);
const idx = memoIndex('wiki', entries, (e) => buildIndex(e, members));
return { ssbClient, idx };
};
const canEdit = async (page, viewer) => {
if (!page) return false;
const owner = String(page.author) === String(viewer);
if (page.editPolicy === 'author') return owner;
if (page.tribeId) {
if (!tribesModel) return owner;
try {
const t = await tribesModel.getTribeById(page.tribeId);
return owner || (Array.isArray(t.members) && t.members.includes(viewer));
} catch (_) { return owner; }
}
if (page.editPolicy === 'tribe') return owner;
return true;
};
return {
WIKI_TYPE,
EDIT_POLICIES,
slugify,
async createPage({ title, body, tags, aliases, editPolicy, license, tribeId = null }) {
const { ssbClient, idx } = await load();
const cleanTitle = safeText(title).slice(0, MAX_TITLE);
if (!cleanTitle) throw new Error('Title required');
const slug = slugify(cleanTitle);
if (!slug) throw new Error('Title required');
const scoped = await scopePages(idx.pages, tribeId);
const existing = scoped.find(p => p.slug === slug);
if (existing) return { key: existing.id, existing: true };
const now = new Date().toISOString();
const content = {
type: WIKI_TYPE,
title: cleanTitle,
slug,
...(await storeBody(ssbClient, body, tribeId)),
tags: normalizeList(tags),
aliases: normalizeList(aliases).map(slugify).filter(Boolean),
editPolicy: normalizePolicy(editPolicy),
...(license ? { license } : {}),
author: ssbClient.id,
createdAt: now,
updatedAt: now,
...(tribeId ? { tribeId } : {})
};
const res = await publishContent(ssbClient, content);
return { key: res.key, existing: false };
},
async updatePage(id, data = {}) {
const { ssbClient, idx } = await load();
const page = findInScope(idx.pages, id);
if (!page) throw new Error('Page not found');
if (!(await canEdit(page, ssbClient.id))) throw new Error('Not allowed to edit');
const now = new Date().toISOString();
const nextTitle = data.title !== undefined ? safeText(data.title).slice(0, MAX_TITLE) : page.title;
const nextLicense = data.license !== undefined && String(page.author) === String(ssbClient.id) ? safeText(data.license) : page.license;
const content = {
type: WIKI_TYPE,
title: nextTitle || page.title,
slug: page.slug,
...(await storeBody(ssbClient, data.body !== undefined ? data.body : page.body, page.tribeId || null)),
tags: data.tags !== undefined ? normalizeList(data.tags) : page.tags,
aliases: data.aliases !== undefined ? normalizeList(data.aliases).map(slugify).filter(Boolean) : page.aliases,
editPolicy: data.editPolicy !== undefined ? normalizePolicy(data.editPolicy) : page.editPolicy,
...(nextLicense ? { license: nextLicense } : {}),
summary: safeText(data.summary).slice(0, 200),
author: ssbClient.id,
createdAt: page.createdAt,
updatedAt: now,
replaces: page.tipId,
...(page.tribeId ? { tribeId: page.tribeId } : {})
};
const res = await publishContent(ssbClient, content);
return { key: res.key, rootId: page.id, author: page.author };
},
async restoreVersion(id, versionKey) {
const { idx } = await load();
const page = findInScope(idx.pages, id);
if (!page) throw new Error('Page not found');
const version = page.versions.find(v => v.key === versionKey);
if (!version) throw new Error('Version not found');
return this.updatePage(page.id, { title: version.title, body: version.body, tags: version.tags, summary: `restore:${versionKey}` });
},
async deletePage(id) {
const { ssbClient, idx } = await load();
const page = findInScope(idx.pages, id);
if (!page) throw new Error('Page not found');
if (String(page.author) !== String(ssbClient.id)) throw new Error('Only the creator can delete');
const deletedAt = new Date().toISOString();
const tomb = { type: 'tombstone', target: page.id, deletedAt, author: ssbClient.id };
let msg = tomb;
if (page.tribeId) {
const key = await tribeKeyFor(page.tribeId);
if (!key) throw new Error('Missing tribe key — cannot delete encrypted wiki page');
msg = tribeCrypto.wrapMsg({ k: 'tombstone', target: page.id, deletedAt, author: ssbClient.id }, key);
}
return new Promise((resolve, reject) => ssbClient.publish(msg, (err, res) => err ? reject(err) : resolve(res)));
},
async getPage(idOrSlug, { tribeId = null } = {}) {
const { ssbClient, idx } = await load();
let scoped = await scopePages(idx.pages, tribeId);
let page = findInScope(scoped, idOrSlug);
if (!page && String(idOrSlug || '').startsWith('%')) {
page = findInScope(idx.pages, idOrSlug);
if (page) scoped = await scopePages(idx.pages, page.tribeId || null);
}
if (!page) return null;
const decorated = decorate(idx.pages, scoped);
const full = decorated.find(p => p.id === page.id) || page;
const bySlug = new Map();
for (const p of scoped) for (const s of [p.slug, ...p.aliases]) if (s && !bySlug.has(s)) bySlug.set(s, p);
const backlinks = decorated.filter(p => p.id !== full.id && p.links.some(s => bySlug.get(s) && bySlug.get(s).id === full.id));
const outbound = [];
const seen = new Set([full.id]);
for (const slug of full.links) {
const target = bySlug.get(slug);
if (!target || seen.has(target.id)) continue;
seen.add(target.id);
outbound.push(decorated.find(p => p.id === target.id) || target);
}
const linkedPages = [...backlinks];
const inList = new Set(backlinks.map(p => p.id));
for (const p of outbound) if (!inList.has(p.id)) linkedPages.push(p);
return { ...full, backlinks, outbound, linkedPages, canEdit: await canEdit(full, ssbClient.id), isOwner: String(full.author) === String(ssbClient.id) };
},
async listPages({ tribeId = null, filter = 'all', q = '', viewerId } = {}) {
const { ssbClient, idx } = await load();
const me = viewerId || ssbClient.id;
const scoped = decorate(idx.pages, await scopePages(idx.pages, tribeId));
let list = scoped;
const f = String(filter || 'all').toLowerCase();
if (f === 'mine') list = list.filter(p => String(p.author) === String(me) || p.versions.some(v => String(v.author) === String(me)));
else if (f === 'recent') list = list.filter(p => Date.now() - p.ts <= RECENT_MS);
else if (f === 'linked') list = list.filter(p => p.isLinked);
const needle = safeText(q).toLowerCase();
if (needle) list = list.filter(p => p.title.toLowerCase().includes(needle) || p.body.toLowerCase().includes(needle) || p.tags.some(t => t.toLowerCase().includes(needle)));
return list.slice().sort((a, b) => b.ts - a.ts);
},
async recentChanges({ tribeId = null } = {}) {
const { idx } = await load();
const scoped = await scopePages(idx.pages, tribeId);
const changes = [];
for (const p of scoped) {
p.versions.forEach((v, i) => changes.push({
pageId: p.id, slug: p.slug, title: v.title || p.title, tribeId: p.tribeId || null,
versionKey: v.key, author: v.author, ts: v.ts, createdAt: v.createdAt, summary: v.summary, index: i + 1, isCreation: i === 0
}));
}
return changes.sort((a, b) => b.ts - a.ts || b.index - a.index).slice(0, MAX_CHANGES);
},
async resolveRootId(id) {
const { idx } = await load();
const page = findInScope(idx.pages, id);
if (!page) throw new Error('Not found');
return page.id;
},
async canEditPage(id, viewer) {
const { ssbClient, idx } = await load();
const page = findInScope(idx.pages, id);
return canEdit(page, viewer || ssbClient.id);
}
};
};
Object.assign(module.exports, { WIKILINK_RE, slugify, linkTarget, extractWikiLinks });