documents_model.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. const pull = require("../server/node_modules/pull-stream");
  2. const { getConfig } = require("../configs/config-manager.js");
  3. const categories = require("../backend/opinion_categories");
  4. const { buildValidatedTombstoneSet } = require('./tombstone_validator');
  5. const mediaFavorites = require("../backend/media-favorites");
  6. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  7. const safeArr = (v) => (Array.isArray(v) ? v : []);
  8. const safeText = (v) => String(v || "").trim();
  9. const parseBlobId = (blobMarkdown) => {
  10. if (!blobMarkdown) return null;
  11. const s = String(blobMarkdown);
  12. const match = s.match(/\(([^)]+)\)/);
  13. return match ? match[1] : s.trim();
  14. };
  15. const parseCSV = (str) =>
  16. str === undefined || str === null ? undefined : String(str).split(",").map((s) => s.trim()).filter(Boolean);
  17. const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
  18. module.exports = ({ cooler }) => {
  19. let ssb;
  20. let userId;
  21. const openSsb = async () => {
  22. if (!ssb) {
  23. ssb = await cooler.open();
  24. userId = ssb.id;
  25. }
  26. return ssb;
  27. };
  28. const getAllMessages = async (ssbClient) =>
  29. new Promise((resolve, reject) => {
  30. pull(
  31. ssbClient.createLogStream({ limit: logLimit }),
  32. pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
  33. );
  34. });
  35. const buildIndex = (messages) => {
  36. const tomb = buildValidatedTombstoneSet(messages);
  37. const nodes = new Map();
  38. const parent = new Map();
  39. const child = new Map();
  40. for (const m of messages) {
  41. const k = m.key;
  42. const v = m.value || {};
  43. const c = v.content;
  44. if (!c) continue;
  45. if (c.type === "tombstone") continue;
  46. if (c.type !== "document") continue;
  47. const ts = v.timestamp || m.timestamp || 0;
  48. let sizeBytes = 0;
  49. try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
  50. nodes.set(k, { key: k, ts, c, sizeBytes });
  51. if (c.replaces) {
  52. parent.set(k, c.replaces);
  53. child.set(c.replaces, k);
  54. }
  55. }
  56. const rootOf = (id) => {
  57. let cur = id;
  58. while (parent.has(cur)) cur = parent.get(cur);
  59. return cur;
  60. };
  61. const tipOf = (id) => {
  62. let cur = id;
  63. while (child.has(cur)) cur = child.get(cur);
  64. return cur;
  65. };
  66. const roots = new Set();
  67. for (const id of nodes.keys()) roots.add(rootOf(id));
  68. const tipByRoot = new Map();
  69. for (const r of roots) tipByRoot.set(r, tipOf(r));
  70. const forward = new Map();
  71. for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
  72. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
  73. };
  74. const pickDoc = (node, rootId) => {
  75. const c = node.c || {};
  76. return {
  77. key: node.key,
  78. rootId,
  79. url: c.url,
  80. createdAt: c.createdAt,
  81. updatedAt: c.updatedAt || null,
  82. tags: safeArr(c.tags),
  83. author: c.author,
  84. title: c.title || "",
  85. description: c.description || "",
  86. opinions: c.opinions || {},
  87. opinions_inhabitants: safeArr(c.opinions_inhabitants),
  88. sizeBytes: node.sizeBytes || 0
  89. };
  90. };
  91. const hasBlob = (ssbClient, blobId) =>
  92. new Promise((resolve) => {
  93. if (!blobId) return resolve(false);
  94. ssbClient.blobs.has(blobId, (err, has) => resolve(!err && !!has));
  95. });
  96. const favoritesSetForDocuments = async () => {
  97. try {
  98. return await mediaFavorites.getFavoriteSet("documents");
  99. } catch {
  100. return new Set();
  101. }
  102. };
  103. return {
  104. type: "document",
  105. async resolveCurrentId(id) {
  106. const ssbClient = await openSsb();
  107. const messages = await getAllMessages(ssbClient);
  108. const idx = buildIndex(messages);
  109. let tip = id;
  110. while (idx.forward.has(tip)) tip = idx.forward.get(tip);
  111. if (idx.tomb.has(tip)) throw new Error("Document not found");
  112. return tip;
  113. },
  114. async resolveRootId(id) {
  115. const ssbClient = await openSsb();
  116. const messages = await getAllMessages(ssbClient);
  117. const idx = buildIndex(messages);
  118. let tip = id;
  119. while (idx.forward.has(tip)) tip = idx.forward.get(tip);
  120. if (idx.tomb.has(tip)) throw new Error("Document not found");
  121. let root = tip;
  122. while (idx.parent.has(root)) root = idx.parent.get(root);
  123. return root;
  124. },
  125. async createDocument(blobMarkdown, tagsRaw, title, description) {
  126. const ssbClient = await openSsb();
  127. const blobId = parseBlobId(blobMarkdown);
  128. if (!blobId) throw new Error("Missing document blob");
  129. const tags = parseCSV(tagsRaw) || [];
  130. const content = {
  131. type: "document",
  132. url: blobId,
  133. createdAt: new Date().toISOString(),
  134. author: userId,
  135. tags,
  136. title: title || "",
  137. description: description || "",
  138. opinions: {},
  139. opinions_inhabitants: []
  140. };
  141. return new Promise((resolve, reject) => {
  142. ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
  143. });
  144. },
  145. async updateDocumentById(id, blobMarkdown, tagsRaw, title, description) {
  146. const ssbClient = await openSsb();
  147. const tipId = await this.resolveCurrentId(id);
  148. const oldMsg = await new Promise((res, rej) =>
  149. ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error("Document not found")) : res(msg)))
  150. );
  151. if (oldMsg.content?.type !== "document") throw new Error("Document not found");
  152. if (Object.keys(oldMsg.content.opinions || {}).length > 0) {
  153. throw new Error("Cannot edit document after it has received opinions.");
  154. }
  155. if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
  156. const parsedTags = parseCSV(tagsRaw);
  157. const tags = parsedTags !== undefined ? parsedTags : safeArr(oldMsg.content.tags);
  158. const blobId = parseBlobId(blobMarkdown);
  159. const updatedAt = new Date().toISOString();
  160. const updated = {
  161. ...oldMsg.content,
  162. replaces: tipId,
  163. url: blobId || oldMsg.content.url,
  164. tags,
  165. title: title !== undefined ? (title || "") : oldMsg.content.title || "",
  166. description: description !== undefined ? (description || "") : oldMsg.content.description || "",
  167. updatedAt
  168. };
  169. const tombstone = { type: "tombstone", target: tipId, deletedAt: updatedAt, author: userId };
  170. await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
  171. return new Promise((resolve, reject) => {
  172. ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
  173. });
  174. },
  175. async deleteDocumentById(id) {
  176. const ssbClient = await openSsb();
  177. const tipId = await this.resolveCurrentId(id);
  178. const msg = await new Promise((res, rej) =>
  179. ssbClient.get(tipId, (err, m) => (err || !m ? rej(new Error("Document not found")) : res(m)))
  180. );
  181. if (msg.content?.type !== "document") throw new Error("Document not found");
  182. if (String(msg.content.author) !== String(userId)) throw new Error("Not the author");
  183. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
  184. return new Promise((resolve, reject) => {
  185. ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
  186. });
  187. },
  188. async listAll(arg1 = "all") {
  189. const ssbClient = await openSsb();
  190. const opts = typeof arg1 === "object" && arg1 !== null ? arg1 : { filter: arg1 };
  191. const filter = safeText(opts.filter || "all");
  192. const q = safeText(opts.q || "").toLowerCase();
  193. const sort = safeText(opts.sort || "recent");
  194. const favorites = await favoritesSetForDocuments();
  195. const messages = await getAllMessages(ssbClient);
  196. const idx = buildIndex(messages);
  197. const items = [];
  198. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  199. if (idx.tomb.has(tipId)) continue;
  200. const node = idx.nodes.get(tipId);
  201. if (!node) continue;
  202. items.push(pickDoc(node, rootId));
  203. }
  204. let out = items;
  205. const now = Date.now();
  206. if (filter === "mine") out = out.filter((d) => String(d.author) === String(userId));
  207. else if (filter === "recent") out = out.filter((d) => new Date(d.createdAt).getTime() >= now - 86400000);
  208. else if (filter === "favorites") out = out.filter((d) => favorites.has(d.rootId || d.key));
  209. if (q) {
  210. out = out.filter((d) => {
  211. const t = String(d.title || "").toLowerCase();
  212. const desc = String(d.description || "").toLowerCase();
  213. const u = String(d.url || "").toLowerCase();
  214. const a = String(d.author || "").toLowerCase();
  215. const tags = safeArr(d.tags).join(" ").toLowerCase();
  216. return t.includes(q) || desc.includes(q) || u.includes(q) || a.includes(q) || tags.includes(q);
  217. });
  218. }
  219. const effectiveSort = filter === "top" ? "top" : sort;
  220. if (effectiveSort === "top") {
  221. out = out
  222. .slice()
  223. .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
  224. } else if (effectiveSort === "oldest") {
  225. out = out.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
  226. } else {
  227. out = out.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  228. }
  229. const checked = await Promise.all(out.map(async (d) => ((await hasBlob(ssbClient, d.url)) ? d : null)));
  230. return checked
  231. .filter(Boolean)
  232. .map((d) => ({ ...d, isFavorite: favorites.has(d.rootId || d.key) }));
  233. },
  234. async getDocumentById(id) {
  235. const ssbClient = await openSsb();
  236. const tipId = await this.resolveCurrentId(id);
  237. const rootId = await this.resolveRootId(id);
  238. const favorites = await favoritesSetForDocuments();
  239. return new Promise((resolve, reject) => {
  240. ssbClient.get(tipId, (err, msg) => {
  241. if (err || !msg || msg.content?.type !== "document") return reject(new Error("Document not found"));
  242. const c = msg.content;
  243. resolve({
  244. key: tipId,
  245. rootId,
  246. url: c.url,
  247. createdAt: c.createdAt,
  248. updatedAt: c.updatedAt || null,
  249. tags: c.tags || [],
  250. author: c.author,
  251. title: c.title || "",
  252. description: c.description || "",
  253. opinions: c.opinions || {},
  254. opinions_inhabitants: c.opinions_inhabitants || [],
  255. isFavorite: favorites.has(rootId || tipId)
  256. });
  257. });
  258. });
  259. },
  260. async createOpinion(id, category) {
  261. if (!categories.includes(category)) return Promise.reject(new Error("Invalid voting category"));
  262. const ssbClient = await openSsb();
  263. const tipId = await this.resolveCurrentId(id);
  264. return new Promise((resolve, reject) => {
  265. ssbClient.get(tipId, async (err, msg) => {
  266. if (err || !msg || msg.content?.type !== "document") return reject(new Error("Document not found"));
  267. if (safeArr(msg.content.opinions_inhabitants).includes(userId)) return reject(new Error("Already voted"));
  268. const now = new Date().toISOString();
  269. const updated = {
  270. ...msg.content,
  271. replaces: tipId,
  272. opinions: { ...msg.content.opinions, [category]: (msg.content.opinions?.[category] || 0) + 1 },
  273. opinions_inhabitants: safeArr(msg.content.opinions_inhabitants).concat(userId),
  274. updatedAt: now
  275. };
  276. const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
  277. await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
  278. ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
  279. });
  280. });
  281. }
  282. };
  283. };