search_model.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. const pull = require('../server/node_modules/pull-stream');
  2. const moment = require('../server/node_modules/moment');
  3. const { getConfig } = require('../configs/config-manager.js');
  4. const { buildValidatedTombstoneSet } = require('./tombstone_validator');
  5. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  6. module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
  7. let ssb;
  8. const openSsb = async () => {
  9. if (!ssb) ssb = await cooler.open();
  10. return ssb;
  11. };
  12. const STANDALONE_ENCRYPTED_TYPES = new Set(['chat', 'pad', 'map', 'calendar']);
  13. const tryDecryptStandalone = (content) => {
  14. if (!tribeCrypto || !content || !content.encryptedPayload) return null;
  15. const rootCandidates = [content.calendarId, content.chatId, content.padId, content.mapId, content.roomId, content.parentId, content.dateId, content.rootId].filter(Boolean);
  16. for (const cand of rootCandidates) {
  17. const keys = tribeCrypto.getKeys(cand);
  18. if (!keys || !keys.length) continue;
  19. try {
  20. const dec = tribeCrypto.decryptContent(content, keys.map(k => [k]));
  21. if (dec && !dec._undecryptable) return dec;
  22. } catch (_) {}
  23. }
  24. return null;
  25. };
  26. const tryDecryptTribe = async (content) => {
  27. if (!tribeCrypto || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null;
  28. try {
  29. const dec = await tribeCrypto.decryptFromTribe(content, tribesModel);
  30. if (dec && !dec._undecryptable) return dec;
  31. } catch (_) {}
  32. return null;
  33. };
  34. const searchableTypes = [
  35. 'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
  36. 'votes', 'report', 'task', 'event', 'bookmark', 'document',
  37. 'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim',
  38. 'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
  39. ];
  40. const getRelevantFields = (type, content) => {
  41. switch (type) {
  42. case 'post':
  43. return [content?.text, content?.contentWarning, ...(content?.tags || [])];
  44. case 'about':
  45. return [content?.about, content?.name, content?.description];
  46. case 'feed':
  47. return [content?.text, content?.author, content?.createdAt, ...(content?.tags || []), content?.refeeds];
  48. case 'event':
  49. return [content?.title, content?.description, content?.date, content?.location, content?.price, content?.eventUrl, ...(content?.tags || []), content?.attendees, content?.organizer, content?.status, content?.isPublic];
  50. case 'votes':
  51. return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes];
  52. case 'tribe':
  53. return [content?.title, content?.description, content?.image, content?.location, ...(content?.tags || []), content?.isAnonymous, content?.members?.length, content?.createdAt, content?.author];
  54. case 'audio':
  55. return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
  56. case 'image':
  57. return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.meme];
  58. case 'video':
  59. return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
  60. case 'document':
  61. return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.key];
  62. case 'torrent':
  63. return [content?.title, content?.description, ...(content?.tags || []), content?.url];
  64. case 'market':
  65. 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];
  66. case 'bookmark':
  67. return [content?.author, content?.url, ...(content?.tags || []), content?.description, content?.category, content?.lastVisit];
  68. case 'task':
  69. return [content?.title, content?.description, content?.startTime, content?.endTime, content?.priority, content?.location, ...(content?.tags || []), content?.isPublic, content?.assignees?.length, content?.status, content?.author];
  70. case 'report':
  71. return [content?.title, content?.description, content?.category, content?.createdAt, content?.author, content?.image, ...(content?.tags || []), content?.confirmations, content?.severity, content?.status, content?.isAnonymous];
  72. case 'transfer':
  73. return [content?.from, content?.to, content?.concept, content?.amount, content?.deadline, content?.status, ...(content?.tags || []), content?.confirmedBy?.length];
  74. case 'curriculum':
  75. 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];
  76. case 'bankWallet':
  77. return [content?.address];
  78. case 'bankClaim':
  79. return [content?.amount, content?.epochId, content?.allocationId, content?.txid];
  80. case 'project':
  81. 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];
  82. case 'job':
  83. return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length];
  84. case 'forum':
  85. return [content?.root, content?.category, content?.title, content?.text, content?.key];
  86. case 'vote':
  87. return [content?.vote?.link];
  88. case 'contact':
  89. return [content?.contact];
  90. case 'pub':
  91. return [content?.address?.host, content?.address?.key];
  92. case 'map':
  93. return [content?.title, content?.description, content?.mapType, ...(content?.tags || []), content?.lat, content?.lng];
  94. case 'shop':
  95. return [content?.title, content?.shortDescription, content?.description, content?.location, ...(content?.tags || []), content?.visibility, content?.url];
  96. case 'shopProduct':
  97. return [content?.title, content?.description, content?.price, ...(content?.tags || []), content?.shopId];
  98. case 'chat':
  99. return [content?.title, content?.description, content?.category, ...(content?.tags || []), content?.status, content?.author];
  100. case 'pad':
  101. return [content?.title, content?.status, content?.deadline, ...(content?.tags || []), content?.author];
  102. case 'gameScore':
  103. return [content?.game, content?.player];
  104. default:
  105. return [];
  106. }
  107. };
  108. const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
  109. const getDedupeKey = (msg) => {
  110. const c = msg?.value?.content || {};
  111. const t = c?.type || 'unknown';
  112. const author = c.author || msg?.value?.author || '';
  113. if (t === 'post') return `post:${msg.key}`;
  114. if (t === 'about') return `about:${c.about || author || msg.key}`;
  115. if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
  116. if (t === 'contact') return `contact:${c.contact || msg.key}`;
  117. if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
  118. if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
  119. if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
  120. if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
  121. if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`;
  122. if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
  123. if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
  124. if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
  125. if (t === 'torrent') return `torrent:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
  126. if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
  127. if (t === 'tribe') {
  128. return [
  129. 'tribe',
  130. author,
  131. norm(c.title),
  132. norm(c.location),
  133. norm(c.image)
  134. ].join('|');
  135. }
  136. if (t === 'event') {
  137. return [
  138. 'event',
  139. c.organizer || author,
  140. norm(c.title),
  141. norm(c.date),
  142. norm(c.location)
  143. ].join('|');
  144. }
  145. if (t === 'task') {
  146. return [
  147. 'task',
  148. c.author || author,
  149. norm(c.title),
  150. norm(c.startTime),
  151. norm(c.endTime),
  152. norm(c.location)
  153. ].join('|');
  154. }
  155. if (t === 'report') {
  156. return [
  157. 'report',
  158. c.author || author,
  159. norm(c.title),
  160. norm(c.category),
  161. norm(c.severity)
  162. ].join('|');
  163. }
  164. if (t === 'votes') {
  165. return [
  166. 'votes',
  167. c.createdBy || author,
  168. norm(c.question),
  169. norm(c.deadline)
  170. ].join('|');
  171. }
  172. if (t === 'market') {
  173. return [
  174. 'market',
  175. c.seller || author,
  176. norm(c.title),
  177. norm(c.deadline),
  178. norm(c.item_type),
  179. norm(c.image)
  180. ].join('|');
  181. }
  182. if (t === 'transfer') {
  183. const txid = c.txid || c.transactionId || c.id;
  184. if (txid) return `transfer:${txid}`;
  185. return [
  186. 'transfer',
  187. norm(c.from),
  188. norm(c.to),
  189. norm(c.amount),
  190. norm(c.concept),
  191. norm(c.deadline)
  192. ].join('|');
  193. }
  194. if (t === 'feed') {
  195. return [
  196. 'feed',
  197. c.author || author,
  198. norm(c.text)
  199. ].join('|');
  200. }
  201. if (t === 'project') {
  202. return [
  203. 'project',
  204. c.activityActor || author,
  205. norm(c.title),
  206. norm(c.deadline),
  207. norm(c.goal)
  208. ].join('|');
  209. }
  210. if (t === 'job') {
  211. return [
  212. 'job',
  213. author,
  214. norm(c.title),
  215. norm(c.location),
  216. norm(c.salary),
  217. norm(c.job_type)
  218. ].join('|');
  219. }
  220. if (t === 'forum') {
  221. return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
  222. }
  223. if (t === 'map') {
  224. return ['map', author, norm(c.title), norm(c.description), norm(c.lat), norm(c.lng)].join('|');
  225. }
  226. if (t === 'shop') {
  227. return ['shop', author, norm(c.title), norm(c.location)].join('|');
  228. }
  229. if (t === 'shopProduct') {
  230. return ['shopProduct', author, norm(c.title), norm(c.shopId)].join('|');
  231. }
  232. if (t === 'pad') {
  233. return ['pad', author, norm(c.title), norm(c.deadline)].join('|');
  234. }
  235. return `${t}:${msg.key}`;
  236. };
  237. const dedupeKeepLatest = (msgs) => {
  238. const map = new Map();
  239. for (const msg of msgs) {
  240. const k = getDedupeKey(msg);
  241. const prev = map.get(k);
  242. const ts = msg?.value?.timestamp || 0;
  243. const pts = prev?.value?.timestamp || 0;
  244. if (!prev || ts > pts) map.set(k, msg);
  245. }
  246. return Array.from(map.values());
  247. };
  248. const search = async ({ query, types = [], resultsPerPage = "10" }) => {
  249. const ssbClient = await openSsb();
  250. const viewerId = ssbClient.id;
  251. const queryLower = String(query || '').toLowerCase();
  252. const messages = await new Promise((res, rej) => {
  253. pull(
  254. ssbClient.createLogStream({ limit: logLimit }),
  255. pull.collect((err, msgs) => err ? rej(err) : res(msgs))
  256. );
  257. });
  258. const tombstoned = buildValidatedTombstoneSet(messages);
  259. const replacesMap = new Map();
  260. const latestByKey = new Map();
  261. for (const msg of messages) {
  262. const k = msg.key;
  263. const c = msg?.value?.content;
  264. const t = c?.type;
  265. if (!t || !searchableTypes.includes(t)) continue;
  266. if (tombstoned.has(k)) continue;
  267. if (c.replaces) replacesMap.set(c.replaces, k);
  268. latestByKey.set(k, msg);
  269. }
  270. for (const oldId of replacesMap.keys()) {
  271. latestByKey.delete(oldId);
  272. }
  273. const viewerTribeIds = new Set();
  274. if (tribesModel && typeof tribesModel.listTribesForViewer === 'function') {
  275. try {
  276. const myTribes = await tribesModel.listTribesForViewer(viewerId);
  277. for (const tr of (myTribes || [])) viewerTribeIds.add(String(tr.rootId || tr.id || tr.key));
  278. } catch (_) {}
  279. }
  280. for (const [k, msg] of Array.from(latestByKey.entries())) {
  281. const c = msg?.value?.content;
  282. if (!c) { latestByKey.delete(k); continue; }
  283. if (c.tribeId && !viewerTribeIds.has(String(c.tribeId))) {
  284. latestByKey.delete(k);
  285. continue;
  286. }
  287. if (c.encryptedPayload) {
  288. let dec = null;
  289. if (c.tribeId) dec = await tryDecryptTribe(c);
  290. if (!dec && STANDALONE_ENCRYPTED_TYPES.has(c.type)) {
  291. const keys = tribeCrypto && tribeCrypto.getKeys ? tribeCrypto.getKeys(k) : [];
  292. if (keys && keys.length) {
  293. try {
  294. const out = tribeCrypto.decryptContent(c, keys.map(kk => [kk]));
  295. if (out && !out._undecryptable) dec = out;
  296. } catch (_) {}
  297. }
  298. }
  299. if (!dec) dec = tryDecryptStandalone(c);
  300. if (!dec) { latestByKey.delete(k); continue; }
  301. msg.value.content = { ...c, ...dec, encryptedPayload: undefined };
  302. }
  303. }
  304. if (padsModel) {
  305. for (const msg of latestByKey.values()) {
  306. const c = msg?.value?.content;
  307. if (c?.type === 'pad') {
  308. const rootId = c.replaces ? msg.key : msg.key;
  309. try {
  310. const decrypted = await padsModel.decryptContent(c, rootId);
  311. if (decrypted && typeof decrypted === 'object') {
  312. if (decrypted.title) c.title = decrypted.title;
  313. if (decrypted.deadline) c.deadline = decrypted.deadline;
  314. if (Array.isArray(decrypted.tags) && decrypted.tags.length) c.tags = decrypted.tags;
  315. }
  316. } catch (_) {}
  317. }
  318. }
  319. }
  320. let filtered = Array.from(latestByKey.values()).filter(msg => {
  321. const c = msg?.value?.content;
  322. const t = c?.type;
  323. if (!t || (types.length > 0 && !types.includes(t))) return false;
  324. if (t === 'market') {
  325. if (c.stock === 0 && c.status !== 'SOLD') return false;
  326. }
  327. if (!queryLower) return true;
  328. if (queryLower.startsWith('@') && queryLower.length > 1) return (t === 'about' && c?.about === query);
  329. const fields = getRelevantFields(t, c);
  330. if (queryLower.startsWith('#') && queryLower.length > 1) {
  331. const tag = queryLower.substring(1);
  332. const tagArr = Array.isArray(c?.tags) ? c.tags : (typeof c?.tags === 'string' ? c.tags.split(',').map(s => s.trim()).filter(Boolean) : []);
  333. return tagArr.some(x => String(x).toLowerCase() === tag);
  334. }
  335. return fields.filter(Boolean).map(String).some(field => field.toLowerCase().includes(queryLower));
  336. });
  337. filtered = dedupeKeepLatest(filtered);
  338. filtered.sort((a, b) => (b?.value?.timestamp || 0) - (a?.value?.timestamp || 0));
  339. const grouped = filtered.reduce((acc, msg) => {
  340. const t = msg?.value?.content?.type || 'unknown';
  341. if (!acc[t]) acc[t] = [];
  342. acc[t].push(msg);
  343. return acc;
  344. }, {});
  345. if (resultsPerPage !== "all") {
  346. const limit = parseInt(resultsPerPage, 10);
  347. for (const key in grouped) grouped[key] = grouped[key].slice(0, limit);
  348. }
  349. return grouped;
  350. };
  351. return { search };
  352. };