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.
This commit is contained in:
oasis-bot 2026-10-18 02:52:17 +02:00
parent 8d838dd8b1
commit c332afd6ea
265 changed files with 80970 additions and 29167 deletions

View file

@ -1,8 +1,9 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const { readTyped, memoIndex } = require("./typed_log");
const { dedupeBy, norm } = require('../backend/dedupe');
const { dedupeBy, norm, versionGroups } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
@ -13,6 +14,10 @@ const normalizeTags = (raw) => {
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const cipherField = (cipher) => (cipher && typeof cipher === "object" && cipher.key && cipher.manifestBlobId)
? { cipher: { v: Number(cipher.v) || 1, key: String(cipher.key), manifestBlobId: String(cipher.manifestBlobId), chunkCount: Number(cipher.chunkCount) || 0 } }
: {};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\((&[^)]+\.sha256)\)/);
@ -40,10 +45,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, 'torrent') : msgs;
const tombFor = async (target, tribeId, author) => tribeHelpers ? tribeHelpers.encryptTombstone(target, tribeId, author) : { type: 'tombstone', target, deletedAt: new Date().toISOString(), author };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
});
const TORRENT_TYPES = ["torrent", "torrentOpinion", "tombstone"];
const getAllMessages = async (ssbClient) => readTyped(ssbClient, TORRENT_TYPES, { limit: logLimit, withWindow: true });
const getMsg = async (ssbClient, key) =>
new Promise((resolve) => {
@ -53,9 +57,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const buildIndex = (messages) => {
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const authorByKey = new Map();
const tombRequests = [];
const opinionMsgs = [];
@ -78,49 +79,24 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
nodes.set(k, { key: k, ts, seq: v.sequence || 0, c, sizeBytes, author: v.author });
authorByKey.set(k, v.author);
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const vg = versionGroups([...nodes.values()].map(n => ({ key: n.key, author: n.author, seq: n.seq, ts: n.ts, replaces: n.c.replaces || null, claimedAuthor: typeof n.c.author === "string" ? n.c.author : null })));
const rootOf = vg.rootOf;
const contentTipOf = vg.tipOf;
for (const t of tombRequests) {
const targetAuthor = authorByKey.get(t.target);
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target);
}
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
for (const r of vg.roots) tipByRoot.set(r, contentTipOf(r));
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
@ -137,7 +113,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
return { contentTip, contentNode, opinions, voters };
};
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
return { tomb, nodes, rootOf, tipByRoot, resolveGroup };
};
const buildTorrent = (node, rootId, viewerId, agg) => {
@ -156,6 +132,10 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
title: undec ? "" : (c.title || ""),
description: undec ? "" : (c.description || ""),
size: c.size || 0,
source: undec ? "" : (typeof c.source === "string" && c.source.startsWith("&") ? c.source : ""),
sourceName: undec ? "" : (c.sourceName || ""),
cipher: undec ? null : (c.cipher && typeof c.cipher === "object" && typeof c.cipher.key === "string" && typeof c.cipher.manifestBlobId === "string" ? { v: Number(c.cipher.v) || 1, key: c.cipher.key, manifestBlobId: c.cipher.manifestBlobId, chunkCount: Number(c.cipher.chunkCount) || 0 } : null),
sourceMime: undec ? "" : (c.sourceMime || ""),
opinions,
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false,
@ -184,16 +164,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Torrent not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
const root = idx.rootOf(id);
if (idx.tomb.has(idx.tipByRoot.get(root) || id)) throw new Error("Torrent not found");
return root;
},
async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId) {
async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId, source = null) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
@ -211,7 +187,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
size: Number(size) || 0,
opinions: {},
opinions_inhabitants: [],
...(tribeId ? { tribeId } : {})
...(tribeId ? { tribeId } : {}),
...(source && typeof source.source === "string" && source.source.startsWith("&")
? { source: source.source, sourceName: String(source.sourceName || ""), sourceMime: String(source.sourceMime || "") }
: {}),
...cipherField(source && source.cipher)
};
content = await encryptIfTribe(content);
@ -221,7 +201,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
});
},
async updateTorrentById(id, blobMarkdown, tagsRaw, title, description) {
async updateTorrentById(id, blobMarkdown, tagsRaw, title, description, extra = null) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
@ -231,7 +211,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const oldDec = await decryptIfTribe(oldMsg.content);
if (!oldDec || oldDec.type !== "torrent") throw new Error("Torrent not found");
assertReadable(oldDec, "Torrent");
if ((oldDec.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
if (oldMsg.author !== userId) throw new Error("Not the author");
const aggTorrent = await this.getTorrentById(id, userId);
if (aggTorrent && Object.keys(aggTorrent.opinions || {}).some(k => (aggTorrent.opinions[k] || 0) > 0)) throw new Error("Cannot edit torrent after it has received opinions.");
@ -243,6 +223,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
type: "torrent",
replaces: tipId,
url: blobId || oldDec.url,
...(blobId ? cipherField(extra && extra.cipher) : cipherField(oldDec.cipher)),
tags,
title: title !== undefined ? title || "" : oldDec.title || "",
description: description !== undefined ? description || "" : oldDec.description || "",
@ -250,7 +231,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
opinions: oldDec.opinions || {},
opinions_inhabitants: oldDec.opinions_inhabitants || [],
author: oldDec.author || userId,
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
...((oldDec.tribeId || oldMsg.content.tribeId) ? { tribeId: oldDec.tribeId || oldMsg.content.tribeId } : {}),
createdAt: oldDec.createdAt,
updatedAt: now
};
@ -274,7 +255,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
if (!msg) throw new Error("Torrent not found");
const dec = await decryptIfTribe(msg.content);
if (!dec || dec.type !== "torrent") throw new Error("Torrent not found");
if ((dec.author || msg.content.author) !== userId) throw new Error("Not the author");
if (msg.author !== userId) throw new Error("Not the author");
const tombstone = await tombFor(tipId, dec.tribeId || msg.content.tribeId, userId);
@ -338,10 +319,10 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
await decryptIndexNodes(idx);
const idx = await memoIndex('torrents', messages, async (m) => { const built = buildIndex(unwrapForIndex(m)); await decryptIndexNodes(built); return built; });
const root = idx.rootOf(id);
if (idx.nodes.has(root) && !idx.tipByRoot.has(root)) throw new Error("Torrent not found");
const agg = idx.resolveGroup(root);
if (agg.contentNode && !idx.tomb.has(agg.contentTip)) return buildTorrent(agg.contentNode, root, viewer, agg);