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.
471 lines
20 KiB
JavaScript
471 lines
20 KiB
JavaScript
const pull = require('../server/node_modules/pull-stream');
|
|
const moment = require('../server/node_modules/moment');
|
|
const { getConfig } = require('../configs/config-manager.js');
|
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
const { readTyped, CONTENT_TYPES } = require('./typed_log');
|
|
const longText = require('../backend/long_text');
|
|
const { versionGroups, versionEntry } = require('../backend/dedupe');
|
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
|
|
module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|
let ssb;
|
|
const openSsb = async () => {
|
|
if (!ssb) ssb = await cooler.open();
|
|
return ssb;
|
|
};
|
|
|
|
const STANDALONE_ENCRYPTED_TYPES = new Set(['chat', 'pad', 'map', 'calendar']);
|
|
|
|
const tryDecryptStandalone = (content) => {
|
|
if (!tribeCrypto || !content || !content.encryptedPayload) return null;
|
|
const rootCandidates = [content.calendarId, content.chatId, content.padId, content.mapId, content.roomId, content.parentId, content.dateId, content.rootId].filter(Boolean);
|
|
for (const cand of rootCandidates) {
|
|
const keys = tribeCrypto.getKeys(cand);
|
|
if (!keys || !keys.length) continue;
|
|
try {
|
|
const dec = tribeCrypto.decryptContent(content, keys.map(k => [k]));
|
|
if (dec && !dec._undecryptable) return dec;
|
|
} catch (_) {}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const tryDecryptTribe = async (content) => {
|
|
if (!tribeCrypto || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null;
|
|
try {
|
|
const dec = await tribeCrypto.decryptFromTribe(content, tribesModel);
|
|
if (dec && !dec._undecryptable) return dec;
|
|
} catch (_) {}
|
|
return null;
|
|
};
|
|
|
|
const searchableTypes = [
|
|
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
|
|
'votes', 'report', 'task', 'event', 'bookmark', 'document',
|
|
'image', 'audio', 'video', 'torrent', 'file', 'market', 'bankWallet', 'bankClaim',
|
|
'project', 'job', 'housing', 'industry', 'industryBlueprint', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad', 'room', 'poll', 'schoolCourse', 'wikiPage', 'emergency', 'mailingList', 'logisticsRoute', 'podcast', 'podcastEpisode', 'campaign'
|
|
];
|
|
|
|
const getRelevantFields = (type, content) => {
|
|
switch (type) {
|
|
case 'post':
|
|
return [content?.text, content?.contentWarning, ...(content?.tags || [])];
|
|
case 'about':
|
|
return [content?.about, content?.name, content?.description];
|
|
case 'feed':
|
|
return [content?.text, content?.author, content?.createdAt, ...(content?.tags || []), content?.refeeds];
|
|
case 'event':
|
|
return [content?.title, content?.description, content?.date, content?.location, content?.price, content?.eventUrl, ...(content?.tags || []), content?.attendees, content?.organizer, content?.status, content?.isPublic];
|
|
case 'votes':
|
|
return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes];
|
|
case 'tribe':
|
|
return [content?.title, content?.description, content?.image, content?.location, ...(content?.tags || []), content?.isAnonymous, content?.members?.length, content?.createdAt, content?.author];
|
|
case 'audio':
|
|
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
|
|
case 'image':
|
|
return [content?.url, content?.title, content?.description, ...(content?.tags || [])];
|
|
case 'video':
|
|
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
|
|
case 'document':
|
|
return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.key];
|
|
case 'torrent':
|
|
case 'file':
|
|
return [content?.title, content?.description, ...(content?.tags || []), content?.url];
|
|
case 'market':
|
|
return [content?.item_type, content?.title, content?.description, content?.price, ...(content?.tags || []), content?.status, content?.item_status, content?.deadline, content?.includesShipping, content?.seller, content?.image, content?.auctions_poll, content?.stock];
|
|
case 'bookmark':
|
|
return [content?.author, content?.url, ...(content?.tags || []), content?.description, content?.category, content?.lastVisit];
|
|
case 'task':
|
|
return [content?.title, content?.description, content?.startTime, content?.endTime, content?.priority, content?.location, ...(content?.tags || []), content?.isPublic, content?.assignees?.length, content?.status, content?.author];
|
|
case 'report':
|
|
return [content?.title, content?.description, content?.category, content?.createdAt, (content?.isAnonymous ? null : content?.author), content?.image, ...(content?.tags || []), content?.confirmations, content?.severity, content?.status];
|
|
case 'transfer':
|
|
return [content?.from, content?.to, content?.concept, content?.amount, content?.deadline, content?.status, ...(content?.tags || []), content?.confirmedBy?.length];
|
|
case 'curriculum':
|
|
return [content?.author, content?.name, content?.description, content?.photo, ...(content?.personalSkills || []), ...(content?.personalExperiences || []), ...(content?.oasisExperiences || []), ...(content?.oasisSkills || []), ...(content?.educationExperiences || []), ...(content?.educationalSkills || []), ...(content?.languages || []), ...(content?.professionalExperiences || []), ...(content?.professionalSkills || []), content?.location, content?.status, content?.preferences, content?.createdAt];
|
|
case 'bankWallet':
|
|
return [content?.address];
|
|
case 'bankClaim':
|
|
return [content?.amount, content?.epochId, content?.allocationId, content?.txid];
|
|
case 'schoolCourse':
|
|
return [content?.title, content?.description, ...(content?.tags || []), content?.price, content?.status, content?.visibility];
|
|
case 'project':
|
|
return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor];
|
|
case 'poll':
|
|
return content?.encryptedQuestion
|
|
? []
|
|
: [content?.question, ...(content?.options || []), ...(content?.tags || [])];
|
|
case 'housing':
|
|
return [content?.title, content?.housing_type, content?.property_type, content?.place, content?.price, content?.rooms, content?.size, content?.capacity, content?.status];
|
|
case 'job':
|
|
return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length];
|
|
case 'industry':
|
|
return [content?.name, content?.sector, content?.description, content?.membershipPolicy, content?.status, content?.license, ...(content?.tags || [])];
|
|
case 'industryBlueprint':
|
|
return [content?.name, content?.description, content?.sector, content?.outItem, content?.outKind, content?.license, ...(content?.skills || []), ...((content?.materials || []).map(m => m && m.item))];
|
|
case 'forum':
|
|
return [content?.root, content?.category, content?.title, content?.text, content?.key];
|
|
case 'vote':
|
|
return [content?.vote?.link];
|
|
case 'contact':
|
|
return [content?.contact];
|
|
case 'pub':
|
|
return [content?.address?.host, content?.address?.key];
|
|
case 'map':
|
|
return [content?.title, content?.description, content?.mapType, ...(content?.tags || []), content?.lat, content?.lng];
|
|
case 'shop':
|
|
return [content?.title, content?.shortDescription, content?.description, content?.location, ...(content?.tags || []), content?.visibility, content?.url];
|
|
case 'shopProduct':
|
|
return [content?.title, content?.description, content?.price, ...(content?.tags || []), content?.shopId];
|
|
case 'chat':
|
|
return [content?.title, content?.description, content?.category, ...(content?.tags || []), content?.status, content?.author];
|
|
case 'pad':
|
|
return [content?.title, content?.status, content?.deadline, ...(content?.tags || []), content?.author];
|
|
case 'room':
|
|
return content?.encrypted === true ? [] : [content?.title, content?.description, content?.status, ...(content?.tags || []), content?.author];
|
|
case 'wikiPage':
|
|
return [content?.title, content?.body, content?.slug, ...(content?.tags || []), ...(content?.aliases || []), content?.author];
|
|
case 'emergency':
|
|
return [content?.title, content?.text, content?.category, content?.status, ...(content?.tags || []), content?.author];
|
|
case 'mailingList':
|
|
return [content?.title, content?.description, content?.listType, content?.status, ...(content?.tags || []), content?.author];
|
|
case 'logisticsRoute':
|
|
return [content?.title, content?.description, content?.origin, content?.destination, content?.kind, content?.mode, ...(content?.tags || []), content?.author];
|
|
case 'podcast':
|
|
return [content?.title, content?.description, content?.category, ...(content?.tags || []), content?.author];
|
|
case 'podcastEpisode':
|
|
return [content?.title, content?.description, ...(content?.tags || []), content?.author];
|
|
case 'campaign':
|
|
return [content?.title, content?.text, content?.category, content?.status, ...(content?.tags || []), content?.author];
|
|
case 'gameScore':
|
|
return [content?.game, content?.player];
|
|
default:
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
|
|
|
|
const getDedupeKey = (msg) => {
|
|
const c = msg?.value?.content || {};
|
|
const t = c?.type || 'unknown';
|
|
const author = c.author || msg?.value?.author || '';
|
|
|
|
if (t === 'post') return `post:${msg.key}`;
|
|
|
|
if (t === 'about') return `about:${c.about || author || msg.key}`;
|
|
if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
|
|
if (t === 'contact') return `contact:${c.contact || msg.key}`;
|
|
if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
|
|
if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
|
|
if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
|
|
if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
|
|
|
|
if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`;
|
|
if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
|
|
if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
|
|
if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
|
|
if (t === 'torrent') return `torrent:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
|
|
if (t === 'file') return `file:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
|
|
if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
|
|
|
|
if (t === 'tribe') {
|
|
return [
|
|
'tribe',
|
|
author,
|
|
norm(c.title),
|
|
norm(c.location),
|
|
norm(c.image)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'event') {
|
|
return [
|
|
'event',
|
|
c.organizer || author,
|
|
norm(c.title),
|
|
norm(c.date),
|
|
norm(c.location)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'task') {
|
|
return [
|
|
'task',
|
|
c.author || author,
|
|
norm(c.title),
|
|
norm(c.startTime),
|
|
norm(c.endTime),
|
|
norm(c.location)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'report') {
|
|
return [
|
|
'report',
|
|
c.author || author,
|
|
norm(c.title),
|
|
norm(c.category),
|
|
norm(c.severity)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'votes') {
|
|
return [
|
|
'votes',
|
|
c.createdBy || author,
|
|
norm(c.question),
|
|
norm(c.deadline)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'market') {
|
|
return [
|
|
'market',
|
|
c.seller || author,
|
|
norm(c.title),
|
|
norm(c.deadline),
|
|
norm(c.item_type),
|
|
norm(c.image)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'transfer') {
|
|
const txid = c.txid || c.transactionId || c.id;
|
|
if (txid) return `transfer:${txid}`;
|
|
return [
|
|
'transfer',
|
|
norm(c.from),
|
|
norm(c.to),
|
|
norm(c.amount),
|
|
norm(c.concept),
|
|
norm(c.deadline)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'feed') {
|
|
return [
|
|
'feed',
|
|
c.author || author,
|
|
norm(c.text)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'schoolCourse') {
|
|
return ['schoolCourse', author, norm(c.title), norm(c.createdAt)].join('|');
|
|
}
|
|
if (t === 'project') {
|
|
return [
|
|
'project',
|
|
c.activityActor || author,
|
|
norm(c.title),
|
|
norm(c.deadline),
|
|
norm(c.goal)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'housing') {
|
|
return [
|
|
'housing',
|
|
author,
|
|
norm(c.title),
|
|
norm(c.place),
|
|
norm(c.price),
|
|
norm(c.housing_type)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'job') {
|
|
return [
|
|
'job',
|
|
author,
|
|
norm(c.title),
|
|
norm(c.location),
|
|
norm(c.salary),
|
|
norm(c.job_type)
|
|
].join('|');
|
|
}
|
|
|
|
if (t === 'industry') {
|
|
return ['industry', author, norm(c.name), norm(c.sector)].join('|');
|
|
}
|
|
|
|
if (t === 'industryBlueprint') {
|
|
return ['industryBlueprint', author, norm(c.facility), norm(c.name)].join('|');
|
|
}
|
|
|
|
if (t === 'forum') {
|
|
return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
|
|
}
|
|
|
|
if (t === 'map') {
|
|
return ['map', author, norm(c.title), norm(c.description), norm(c.lat), norm(c.lng)].join('|');
|
|
}
|
|
|
|
if (t === 'shop') {
|
|
return ['shop', author, norm(c.title), norm(c.location)].join('|');
|
|
}
|
|
|
|
if (t === 'shopProduct') {
|
|
return ['shopProduct', author, norm(c.title), norm(c.shopId)].join('|');
|
|
}
|
|
|
|
if (t === 'pad') {
|
|
return ['pad', author, norm(c.title), norm(c.deadline)].join('|');
|
|
}
|
|
|
|
if (t === 'room') {
|
|
return ['room', author, norm(c.rid || c.title), norm(c.createdAt)].join('|');
|
|
}
|
|
|
|
if (t === 'wikiPage') {
|
|
return ['wikiPage', norm(c.tribeId), norm(c.slug)].join('|');
|
|
}
|
|
|
|
if (t === 'emergency') {
|
|
return ['emergency', author, norm(c.title), norm(c.createdAt)].join('|');
|
|
}
|
|
|
|
if (t === 'mailingList') {
|
|
return ['mailingList', author, norm(c.createdAt)].join('|');
|
|
}
|
|
|
|
if (t === 'logisticsRoute') {
|
|
return ['logisticsRoute', author, norm(c.createdAt)].join('|');
|
|
}
|
|
|
|
if (t === 'podcast' || t === 'podcastEpisode' || t === 'campaign') {
|
|
return [t, author, norm(c.createdAt)].join('|');
|
|
}
|
|
|
|
return `${t}:${msg.key}`;
|
|
};
|
|
|
|
const dedupeKeepLatest = (msgs) => {
|
|
const map = new Map();
|
|
for (const msg of msgs) {
|
|
const k = getDedupeKey(msg);
|
|
const prev = map.get(k);
|
|
const ts = msg?.value?.timestamp || 0;
|
|
const pts = prev?.value?.timestamp || 0;
|
|
if (!prev || ts > pts) map.set(k, msg);
|
|
}
|
|
return Array.from(map.values());
|
|
};
|
|
|
|
const search = async ({ query, types = [] }) => {
|
|
const ssbClient = await openSsb();
|
|
const viewerId = ssbClient.id;
|
|
const queryLower = String(query || '').toLowerCase();
|
|
|
|
const messages = await readTyped(ssbClient, CONTENT_TYPES, { limit: logLimit, withWindow: true });
|
|
|
|
const tombstoned = buildValidatedTombstoneSet(messages);
|
|
const latestByKey = new Map();
|
|
|
|
for (const msg of messages) {
|
|
const k = msg.key;
|
|
const c = msg?.value?.content;
|
|
const t = c?.type;
|
|
if (!t || !searchableTypes.includes(t)) continue;
|
|
latestByKey.set(k, msg);
|
|
}
|
|
|
|
const vg = versionGroups(Array.from(latestByKey.values()).map(versionEntry));
|
|
for (const k of Array.from(latestByKey.keys())) {
|
|
if (!vg.roots.has(vg.rootOf(k)) || vg.tipOf(k) !== k || tombstoned.has(k)) latestByKey.delete(k);
|
|
}
|
|
|
|
const viewerTribeIds = new Set();
|
|
if (tribesModel && typeof tribesModel.listTribesForViewer === 'function') {
|
|
try {
|
|
const myTribes = await tribesModel.listTribesForViewer(viewerId);
|
|
for (const tr of (myTribes || [])) viewerTribeIds.add(String(tr.rootId || tr.id || tr.key));
|
|
} catch (_) {}
|
|
}
|
|
|
|
for (const [k, msg] of Array.from(latestByKey.entries())) {
|
|
const c = msg?.value?.content;
|
|
if (!c) { latestByKey.delete(k); continue; }
|
|
if (c.tribeId && !viewerTribeIds.has(String(c.tribeId))) {
|
|
latestByKey.delete(k);
|
|
continue;
|
|
}
|
|
if (c.encryptedPayload) {
|
|
let dec = null;
|
|
if (c.tribeId) dec = await tryDecryptTribe(c);
|
|
if (!dec && STANDALONE_ENCRYPTED_TYPES.has(c.type)) {
|
|
const keys = tribeCrypto && tribeCrypto.getKeys ? tribeCrypto.getKeys(k) : [];
|
|
if (keys && keys.length) {
|
|
try {
|
|
const out = tribeCrypto.decryptContent(c, keys.map(kk => [kk]));
|
|
if (out && !out._undecryptable) dec = out;
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
if (!dec) dec = tryDecryptStandalone(c);
|
|
if (!dec) { latestByKey.delete(k); continue; }
|
|
msg.value.content = { ...c, ...dec, encryptedPayload: undefined };
|
|
}
|
|
}
|
|
|
|
const chunkLookup = longText.lookupIn(longText.indexChunks(messages));
|
|
for (const [k, msg] of Array.from(latestByKey.entries())) {
|
|
const c = msg?.value?.content;
|
|
if ((c?.type === 'post' || c?.type === 'forum') && longText.hasChunks(c)) {
|
|
latestByKey.set(k, { ...msg, value: { ...msg.value, content: longText.resolveField(c, 'text', msg.value.author, chunkLookup) } });
|
|
}
|
|
}
|
|
|
|
if (padsModel) {
|
|
for (const msg of latestByKey.values()) {
|
|
const c = msg?.value?.content;
|
|
if (c?.type === 'pad') {
|
|
const rootId = c.replaces ? msg.key : msg.key;
|
|
try {
|
|
const decrypted = await padsModel.decryptContent(c, rootId);
|
|
if (decrypted && typeof decrypted === 'object') {
|
|
if (decrypted.title) c.title = decrypted.title;
|
|
if (decrypted.deadline) c.deadline = decrypted.deadline;
|
|
if (Array.isArray(decrypted.tags) && decrypted.tags.length) c.tags = decrypted.tags;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
let filtered = Array.from(latestByKey.values()).filter(msg => {
|
|
const c = msg?.value?.content;
|
|
const t = c?.type;
|
|
if (!t || (types.length > 0 && !types.includes(t))) return false;
|
|
if (t === 'market') {
|
|
if (c.stock === 0 && c.status !== 'SOLD') return false;
|
|
}
|
|
if (!queryLower) return true;
|
|
if (queryLower.startsWith('@') && queryLower.length > 1) return (t === 'about' && c?.about === query);
|
|
const fields = getRelevantFields(t, c);
|
|
if (queryLower.startsWith('#') && queryLower.length > 1) {
|
|
const tag = queryLower.substring(1);
|
|
const tagArr = Array.isArray(c?.tags) ? c.tags : (typeof c?.tags === 'string' ? c.tags.split(',').map(s => s.trim()).filter(Boolean) : []);
|
|
return tagArr.some(x => String(x).toLowerCase() === tag);
|
|
}
|
|
return fields.filter(Boolean).map(String).some(field => field.toLowerCase().includes(queryLower));
|
|
});
|
|
|
|
filtered = dedupeKeepLatest(filtered);
|
|
|
|
filtered.sort((a, b) => (b?.value?.timestamp || 0) - (a?.value?.timestamp || 0));
|
|
|
|
const grouped = filtered.reduce((acc, msg) => {
|
|
const t = msg?.value?.content?.type || 'unknown';
|
|
if (!acc[t]) acc[t] = [];
|
|
acc[t].push(msg);
|
|
return acc;
|
|
}, {});
|
|
|
|
return grouped;
|
|
};
|
|
|
|
return { search };
|
|
};
|
|
|