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.
297 lines
15 KiB
JavaScript
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;
|
|
}
|
|
};
|
|
};
|