images_model.js 11 KB

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