OASIS_MOBILE/src/models/emergencies_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

297 lines
15 KiB
JavaScript

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 EMERGENCY_TYPES = ['emergency', 'emergencyConfirm', 'emergencyUpdate', 'tombstone'];
const CATEGORIES = ['WEATHER', 'INFRASTRUCTURE', 'HEALTH', 'SECURITY', 'LOST', 'NEIGHBORHOOD'];
const SEVERITY_THRESHOLDS = { LOW: 2, MEDIUM: 5, HIGH: 10 };
const EXPIRES_OPTIONS = { '1d': 1, '3d': 3, '7d': 7, '30d': 30 };
const MEDIA_RE = /!?\[(image|video):[^\]]*\]\((&[^)]+)\)/;
const RECENT_MS = 24 * 60 * 60 * 1000;
const normU = (v) => String(v == null ? '' : v).trim().toUpperCase();
const safeText = (v) => String(v == null ? '' : v).trim();
const normalizeTags = (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 normalizeCategory = (v) => (CATEGORIES.includes(normU(v)) ? normU(v) : 'NEIGHBORHOOD');
const severityOf = (confirmations) => {
const n = Number(confirmations) || 0;
if (n >= SEVERITY_THRESHOLDS.HIGH) return 'HIGH';
if (n >= SEVERITY_THRESHOLDS.MEDIUM) return 'MEDIUM';
if (n >= SEVERITY_THRESHOLDS.LOW) return 'LOW';
return 'UNVERIFIED';
};
const expiresAtFrom = (expiresIn, from = Date.now()) => {
const days = EXPIRES_OPTIONS[String(expiresIn || '').toLowerCase()];
return days ? new Date(from + days * 24 * 60 * 60 * 1000).toISOString() : null;
};
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const readAll = async (ssbClient) => readTyped(ssbClient, EMERGENCY_TYPES, { limit: logLimit });
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parentOf = new Map();
const confirms = [];
const updates = [];
for (const m of messages) {
const v = m.value || {};
const c = v.content;
if (!c || typeof c !== 'object') continue;
if (c.type === 'emergency') {
nodes.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
if (typeof c.replaces === 'string') parentOf.set(m.key, c.replaces);
} else if (c.type === 'emergencyConfirm' && typeof c.target === 'string') {
confirms.push({ key: m.key, target: c.target, author: v.author, ts: v.timestamp || 0 });
} else if (c.type === 'emergencyUpdate' && typeof c.target === 'string') {
updates.push({ key: m.key, target: c.target, author: v.author, ts: v.timestamp || 0, text: safeText(c.text), createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString(), replaces: typeof c.replaces === 'string' ? c.replaces : null });
}
}
const rootOf = (key) => {
const seen = new Set();
let cur = key;
while (parentOf.has(cur) && !seen.has(cur)) { seen.add(cur); cur = parentOf.get(cur); }
return cur;
};
const updateParent = new Map(updates.filter(u => u.replaces).map(u => [u.key, u.replaces]));
const updateRootOf = (key) => {
const seen = new Set();
let cur = key;
while (updateParent.has(cur) && !seen.has(cur)) { seen.add(cur); cur = updateParent.get(cur); }
return cur;
};
const updateByKey = new Map(updates.map(u => [u.key, u]));
const updateVersions = new Map();
for (const u of updates) {
const uroot = updateRootOf(u.key);
if (tomb.has(uroot)) continue;
if (!updateVersions.has(uroot)) updateVersions.set(uroot, []);
updateVersions.get(uroot).push(u);
}
const updateItems = [];
for (const [uroot, versions] of updateVersions) {
versions.sort((a, b) => a.ts - b.ts);
const first = versions[0];
const own = versions.filter(v => v.author === first.author);
const tip = own[own.length - 1];
const confirmers = Array.from(new Set(confirms.filter(cf => updateByKey.has(cf.target) && updateRootOf(cf.target) === uroot && cf.author !== first.author).map(cf => cf.author)));
updateItems.push({
id: uroot, key: uroot, tipId: tip.key, target: first.target, author: first.author,
ts: first.ts, editedTs: tip.ts, text: tip.text, createdAt: first.createdAt, updatedAt: tip.createdAt, edited: tip.key !== uroot,
confirmations: confirmers, confirmationCount: confirmers.length,
lastActivityTs: Math.max(tip.ts, ...confirms.filter(cf => updateByKey.has(cf.target) && updateRootOf(cf.target) === uroot).map(cf => cf.ts))
});
}
const versionsByRoot = new Map();
for (const node of nodes.values()) {
const root = rootOf(node.key);
if (tomb.has(root)) continue;
if (!versionsByRoot.has(root)) versionsByRoot.set(root, []);
versionsByRoot.get(root).push(node);
}
const emergencies = new Map();
for (const [root, versions] of versionsByRoot) {
versions.sort((a, b) => a.ts - b.ts);
const first = versions[0];
const own = versions.filter(v => v.author === first.author);
const tip = own[own.length - 1];
const c = tip.c;
const thread = updateItems.filter(u => nodes.has(u.target) && rootOf(u.target) === root && u.author === first.author).sort((a, b) => a.ts - b.ts);
const confirmers = Array.from(new Set([
...confirms.filter(cf => nodes.has(cf.target) && rootOf(cf.target) === root && cf.author !== first.author).map(cf => cf.author),
...thread.flatMap(u => u.confirmations)
]));
const expiresAt = c.expiresAt || null;
const expired = !!(expiresAt && Date.parse(expiresAt) < Date.now());
const status = normU(c.status) === 'RESOLVED' ? 'RESOLVED' : (expired ? 'EXPIRED' : 'ACTIVE');
emergencies.set(root, {
id: root, rootId: root, tipId: tip.key,
title: safeText(c.title), text: safeText(c.text),
media: (() => { const m = safeText(c.text).match(MEDIA_RE); return m ? { kind: m[1], blobId: m[2] } : null; })(),
category: normalizeCategory(c.category), mapUrl: safeText(c.mapUrl), tags: normalizeTags(c.tags),
author: first.author, createdAt: first.c.createdAt || new Date(first.ts).toISOString(),
updatedAt: c.updatedAt || new Date(tip.ts).toISOString(), ts: tip.ts, lastActivityTs: Math.max(tip.ts, ...thread.map(u => u.lastActivityTs), ...confirms.filter(cf => nodes.has(cf.target) && rootOf(cf.target) === root).map(cf => cf.ts)),
expiresAt, expired, status, resolved: status === 'RESOLVED',
confirmations: confirmers, confirmationCount: confirmers.length, severity: severityOf(confirmers.length),
updates: thread
});
}
const updatesById = new Map(updateItems.map(u => [u.id, u]));
return { emergencies, rootOf, nodes, updatesById, updateRootOf };
};
const load = async () => {
const ssbClient = await openSsb();
const idx = memoIndex('emergencies', await readAll(ssbClient), buildIndex);
return { ssbClient, idx };
};
const find = (idx, id) => {
const key = String(id || '');
if (idx.emergencies.has(key)) return idx.emergencies.get(key);
if (idx.nodes.has(key)) return idx.emergencies.get(idx.rootOf(key)) || null;
return null;
};
const publish = (ssbClient, content) => new Promise((resolve, reject) => ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res)));
return {
CATEGORIES,
SEVERITY_THRESHOLDS,
EXPIRES_OPTIONS,
severityOf,
async createEmergency({ title, text, category, mapUrl, tags, expiresIn }) {
const ssbClient = await openSsb();
const cleanTitle = safeText(title);
if (!cleanTitle) throw new Error('Title required');
const now = new Date().toISOString();
const content = {
type: 'emergency',
title: cleanTitle,
text: safeText(text),
category: normalizeCategory(category),
mapUrl: safeText(mapUrl),
tags: normalizeTags(tags),
status: 'ACTIVE',
expiresAt: expiresAtFrom(expiresIn),
author: ssbClient.id,
createdAt: now,
updatedAt: now
};
return publish(ssbClient, content);
},
async updateEmergency(id, data = {}) {
const { ssbClient, idx } = await load();
const emergency = find(idx, id);
if (!emergency) throw new Error('Emergency not found');
if (String(emergency.author) !== String(ssbClient.id)) throw new Error('Only the author can update');
const editsContent = ['title', 'text', 'category', 'mapUrl', 'tags', 'expiresIn'].some(k => data[k] !== undefined);
if (editsContent && (emergency.confirmationCount || 0) > 0) throw new Error('Cannot edit an emergency after it has been confirmed');
const content = {
type: 'emergency',
title: data.title !== undefined ? (safeText(data.title) || emergency.title) : emergency.title,
text: data.text !== undefined ? safeText(data.text) : emergency.text,
category: data.category !== undefined ? normalizeCategory(data.category) : emergency.category,
mapUrl: data.mapUrl !== undefined ? safeText(data.mapUrl) : emergency.mapUrl,
tags: data.tags !== undefined ? normalizeTags(data.tags) : emergency.tags,
status: data.status !== undefined ? (normU(data.status) === 'RESOLVED' ? 'RESOLVED' : 'ACTIVE') : (emergency.resolved ? 'RESOLVED' : 'ACTIVE'),
expiresAt: data.expiresIn !== undefined && data.expiresIn !== 'keep' ? expiresAtFrom(data.expiresIn) : emergency.expiresAt,
author: ssbClient.id,
createdAt: emergency.createdAt,
updatedAt: new Date().toISOString(),
replaces: emergency.tipId
};
const res = await publish(ssbClient, content);
return { key: res.key, rootId: emergency.id };
},
async resolveEmergency(id) {
return this.updateEmergency(id, { status: 'RESOLVED' });
},
async confirmEmergency(id) {
const { ssbClient, idx } = await load();
const emergency = find(idx, id);
if (!emergency) throw new Error('Emergency not found');
if (String(emergency.author) === String(ssbClient.id)) throw new Error('Cannot confirm own emergency');
if (emergency.confirmations.includes(ssbClient.id)) throw new Error('Already confirmed');
return publish(ssbClient, { type: 'emergencyConfirm', target: emergency.id, createdAt: new Date().toISOString() });
},
async confirmUpdate(updateId) {
const { ssbClient, idx } = await load();
const update = idx.updatesById.get(idx.updateRootOf(String(updateId || '')));
if (!update) throw new Error('Update not found');
if (String(update.author) === String(ssbClient.id)) throw new Error('Cannot confirm own update');
if (update.confirmations.includes(ssbClient.id)) throw new Error('Already confirmed');
return publish(ssbClient, { type: 'emergencyConfirm', target: update.id, createdAt: new Date().toISOString() });
},
async editUpdate(updateId, text) {
const { ssbClient, idx } = await load();
const update = idx.updatesById.get(idx.updateRootOf(String(updateId || '')));
if (!update) throw new Error('Update not found');
if (String(update.author) !== String(ssbClient.id)) throw new Error('Only the author can edit an update');
if (update.confirmationCount > 0) throw new Error('Cannot edit an update after it has been confirmed');
const clean = safeText(text);
if (!clean) throw new Error('Text required');
const res = await publish(ssbClient, { type: 'emergencyUpdate', target: update.target, text: clean, createdAt: new Date().toISOString(), replaces: update.tipId });
return { key: res.key, rootId: update.id, emergencyId: update.target };
},
async deleteUpdate(updateId) {
const { ssbClient, idx } = await load();
const update = idx.updatesById.get(idx.updateRootOf(String(updateId || '')));
if (!update) throw new Error('Update not found');
if (String(update.author) !== String(ssbClient.id)) throw new Error('Only the author can delete an update');
await publish(ssbClient, { type: 'tombstone', target: update.id, deletedAt: new Date().toISOString(), author: ssbClient.id });
return { key: update.id, emergencyId: update.target };
},
async addUpdate(id, text) {
const { ssbClient, idx } = await load();
const emergency = find(idx, id);
if (!emergency) throw new Error('Emergency not found');
if (String(emergency.author) !== String(ssbClient.id)) throw new Error('Only the author can post updates');
const clean = safeText(text);
if (!clean) throw new Error('Text required');
return publish(ssbClient, { type: 'emergencyUpdate', target: emergency.id, text: clean, createdAt: new Date().toISOString() });
},
async deleteEmergency(id) {
const { ssbClient, idx } = await load();
const emergency = find(idx, id);
if (!emergency) throw new Error('Emergency not found');
if (String(emergency.author) !== String(ssbClient.id)) throw new Error('Only the author can delete');
return publish(ssbClient, { type: 'tombstone', target: emergency.id, deletedAt: new Date().toISOString(), author: ssbClient.id });
},
async getEmergencyById(id) {
const { idx } = await load();
return find(idx, id);
},
async listAll({ filter = 'all', q = '', viewerId } = {}) {
const { ssbClient, idx } = await load();
const me = viewerId || ssbClient.id;
let list = Array.from(idx.emergencies.values());
const f = normU(filter || 'all');
if (f === 'MINE') list = list.filter(a => String(a.author) === String(me));
else if (f === 'RECENT') list = list.filter(a => a.lastActivityTs >= Date.now() - RECENT_MS);
else if (f === 'ACTIVE') list = list.filter(a => a.status === 'ACTIVE');
else if (f === 'RESOLVED') list = list.filter(a => a.status === 'RESOLVED');
else if (f === 'EXPIRED') list = list.filter(a => a.status === 'EXPIRED');
else if (['UNVERIFIED', 'LOW', 'MEDIUM', 'HIGH'].includes(f)) list = list.filter(a => a.severity === f);
else if (CATEGORIES.includes(f)) list = list.filter(a => a.category === f);
const needle = safeText(q).toLowerCase();
if (needle) list = list.filter(a => a.title.toLowerCase().includes(needle) || a.text.toLowerCase().includes(needle) || a.tags.some(t => t.toLowerCase().includes(needle)));
return list.sort((a, b) => b.lastActivityTs - a.lastActivityTs);
},
async featured() {
const { idx } = await load();
const rank = { HIGH: 3, MEDIUM: 2, LOW: 1, UNVERIFIED: 0 };
const candidates = Array.from(idx.emergencies.values()).filter(a => a.status === 'ACTIVE' && a.severity === 'HIGH');
candidates.sort((a, b) => (rank[b.severity] - rank[a.severity]) || (b.confirmationCount - a.confirmationCount) || (b.lastActivityTs - a.lastActivityTs));
const top = candidates[0];
return top ? { id: top.id, title: top.title, severity: top.severity, category: top.category, confirmationCount: top.confirmationCount, href: `/emergencies/${encodeURIComponent(top.id)}` } : null;
},
async resolveRootId(id) {
const { idx } = await load();
const emergency = find(idx, id);
if (!emergency) throw new Error('Not found');
return emergency.id;
}
};
};