upstream: integrar 0.9.5 en lo que no diverge (57 ficheros) + dependencias nuevas

Primera tanda de la integracion 0.9.1 -> 0.9.5. Se actualizan los 57 ficheros que
upstream cambio y la rama no tocaba, entre ellos los 11 de traduccion: eso ya solo
es posible gracias al overlay de i18n, antes eran 11 conflictos garantizados.

Se portan ademas las dependencias que 0.9.5 introduce y de las que dependen los
ficheros actualizados: pdfDocument, content_favorites, recurrence, media_gallery,
comments_view, gallery_view y polls (modelo, limites y vista).

Quedan 48 ficheros con divergencia real por resolver a mano (backend.js,
main_views.js, OasisMobile.css y 45 vistas y modelos).

Verificado: 0 errores de sintaxis en src/, 0 requires relativos rotos, la app
arranca declarando 0.9.5 y las 47 rutas principales responden 200/302.
This commit is contained in:
SITO 2026-08-18 21:42:59 +02:00
parent b3986b12a0
commit f964e61a1c
67 changed files with 5740 additions and 10907 deletions

View file

@ -145,7 +145,6 @@ module.exports = ({ cooler }) => {
updatedAt: c.updatedAt || null,
lastVisit: c.lastVisit || null,
tags: safeArr(c.tags),
category: c.category || "",
description: c.description || "",
opinions,
opinions_inhabitants: voters,
@ -182,7 +181,7 @@ module.exports = ({ cooler }) => {
return root;
},
async createBookmark(url, tagsRaw, description, category, lastVisit) {
async createBookmark(url, tagsRaw, description, lastVisit) {
const ssbClient = await openSsb();
const now = new Date().toISOString();
@ -195,7 +194,6 @@ module.exports = ({ cooler }) => {
url: u,
tags: normalizeTags(tagsRaw),
description: description || "",
category: category || "",
createdAt: now,
updatedAt: now,
lastVisit: coerceLastVisit(lastVisit),
@ -231,7 +229,6 @@ module.exports = ({ cooler }) => {
url,
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "",
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
createdAt: oldMsg.content.createdAt,
updatedAt: now
@ -296,11 +293,10 @@ module.exports = ({ cooler }) => {
if (q) {
list = list.filter((b) => {
const url = String(b.url || "").toLowerCase();
const cat = String(b.category || "").toLowerCase();
const desc = String(b.description || "").toLowerCase();
const tags = safeArr(b.tags).join(" ").toLowerCase();
const author = String(b.author || "").toLowerCase();
return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
return url.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
});
}

View file

@ -14,25 +14,26 @@ const normalizeTags = (raw) => {
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const hasAnyInterval = (w, m, y) => !!(w || m || y)
const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
const start = new Date(firstDate)
const out = [start]
if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out
const end = new Date(deadline).getTime()
const seen = new Set([start.getTime()])
const walk = (mutate) => {
const n = new Date(start)
mutate(n)
while (n.getTime() <= end) {
const t = n.getTime()
if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) }
mutate(n)
}
}
if (weekly) walk((d) => d.setDate(d.getDate() + 7))
if (monthly) walk((d) => d.setMonth(d.getMonth() + 1))
if (yearly) walk((d) => d.setFullYear(d.getFullYear() + 1))
return out.sort((a, b) => a.getTime() - b.getTime())
const { expandRecurrence } = require('./recurrence')
const ts = (v) => {
const t = new Date(v).getTime()
return Number.isFinite(t) ? t : null
}
const assertCalendarDates = ({ deadline, date, until }) => {
const now = Date.now()
const dl = deadline ? ts(deadline) : null
const dt = date ? ts(date) : null
const un = until ? ts(until) : null
if (deadline && dl === null) throw new Error("Deadline is not a valid date")
if (date && dt === null) throw new Error("Date is not a valid date")
if (until && un === null) throw new Error("The recurrence end is not a valid date")
if (dl !== null && dl <= now) throw new Error("Deadline must be in the future")
if (dt !== null && dt <= now) throw new Error("Date must be in the future")
if (dt !== null && dl !== null && dt > dl) throw new Error("Date cannot be later than the deadline")
if (un !== null && dt !== null && un < dt) throw new Error("The recurrence cannot end before it starts")
if (un !== null && dl !== null && un > dl) throw new Error("The recurrence cannot end after the deadline")
}
module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => {
@ -108,6 +109,12 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
return tribeCrypto.encryptContent(content, [key], true)
}
const decryptScoped = async (content, rootId) => {
if (!content) return content
if (content.tribeId) return await decryptIfTribe(content)
return decryptCalendarRoot(content, rootId)
}
const decryptCalendarRoot = (content, rootId) => {
if (!content || !content.encryptedPayload) return content
if (!tribeCrypto) return content
@ -259,6 +266,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: c.status || "OPEN",
deadline: undec ? "" : (c.deadline || ""),
tags: Array.isArray(c.tags) ? c.tags : [],
mapUrl: typeof c.mapUrl === "string" ? c.mapUrl : "",
author: c.author || node.author,
participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []),
invites: Array.isArray(c.invites) ? c.invites : [],
@ -339,14 +347,18 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
return tip
},
async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) {
async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline, mapUrl, tribeId }) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const now = new Date().toISOString()
const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
if (!firstDate) throw new Error("First date must be in the future")
assertCalendarDates({
deadline,
date: firstDate,
until: hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? intervalDeadline : ""
})
let plainContent = {
type: "calendar",
@ -354,6 +366,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: validStatus,
deadline: deadline || "",
tags: normalizeTags(tags),
mapUrl: safeText(mapUrl),
author: userId,
participants: [userId],
invites: [],
@ -400,6 +413,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: validStatus,
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
mapUrl: dec.mapUrl || "",
author: userId,
participants: [userId],
invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
@ -424,7 +438,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
...(intervalWeekly ? { intervalWeekly: true } : {}),
...(intervalMonthly ? { intervalMonthly: true } : {}),
...(intervalYearly ? { intervalYearly: true } : {}),
...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}),
...(hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) && (intervalDeadline || deadline) ? { intervalDeadline: intervalDeadline || deadline } : {}),
...(tribeId ? { tribeId } : {})
}
if (tribeId) dateContent = await encryptIfTribe(dateContent)
@ -467,12 +481,15 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
: decryptCalendarRoot(item.content, rootId)
assertReadable(oldDec, "Calendar")
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
const nextDeadline = data.deadline !== undefined ? data.deadline : (oldDec.deadline || "")
if (nextDeadline !== (oldDec.deadline || "")) assertCalendarDates({ deadline: nextDeadline })
let updated = {
type: "calendar",
title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""),
status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"),
deadline: data.deadline !== undefined ? data.deadline : (oldDec.deadline || ""),
deadline: nextDeadline,
tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
mapUrl: data.mapUrl !== undefined ? safeText(data.mapUrl) : (oldDec.mapUrl || ""),
author: oldDec.author || userId,
participants: oldDec.participants || [userId],
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
@ -495,7 +512,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
const userId = ssbClient.id
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
if (!item || !item.content) throw new Error("Calendar not found")
const dec = await decryptIfTribe(item.content)
const rootId = await this.resolveRootId(id)
const dec = await decryptScoped(item.content, rootId)
assertReadable(dec, "Calendar")
const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
if (contentAuthor !== userId) throw new Error("Not the author")
@ -588,9 +606,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
const cal = await this.getCalendarById(rootId)
if (!cal) throw new Error("Calendar not found")
if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future")
if (!date) throw new Error("Date must be in the future")
const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
assertCalendarDates({
deadline: cal.deadline || "",
date,
until: hasInterval ? intervalDeadline : ""
})
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
let dateContent = {
type: "calendarDate",
@ -748,8 +771,9 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
const userId = ssbClient.id
const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it)))
if (!item || !item.content) throw new Error("Note not found")
const dec = await decryptIfTribe(item.content)
if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
const noteRoot = item.content.calendarId || null
const dec = await decryptScoped(item.content, noteRoot)
if (((dec && dec.author) || item.content.author) !== userId) throw new Error("Not the author")
return new Promise((resolve, reject) => {
ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
})
@ -777,8 +801,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
if (tombstoned.has(m.key)) continue
if (c.calendarId !== rootId || c.dateId !== dateId) continue
let dec = c
if (c.encryptedPayload && tribeCrypto && tribesModel) {
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
if (c.encryptedPayload) {
const r = await decryptScoped(c, rootId)
if (r && !r._undecryptable) dec = r
else continue
}
@ -929,6 +953,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: dec.status || "OPEN",
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
mapUrl: dec.mapUrl || "",
author: dec.author,
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
invites,
@ -975,6 +1000,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: dec.status || "OPEN",
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
mapUrl: dec.mapUrl || "",
author: dec.author,
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
invites,
@ -1009,7 +1035,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
let calKey = null
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
@ -1043,6 +1069,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: dec.status || "OPEN",
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
mapUrl: dec.mapUrl || "",
author: dec.author,
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
invites,

View file

@ -223,14 +223,27 @@ module.exports = (configPath, namespace = 'tribes') => {
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
};
const INVITE_ENCRYPT_ATTEMPTS = 3;
const encryptChainForInvite = (ancestryRootIds, code, salt) => {
const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
const k = deriveInviteKey(code, salt);
return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt));
const plain = JSON.stringify(chain);
const aad = inviteAad(code, salt);
for (let attempt = 0; attempt < INVITE_ENCRYPT_ATTEMPTS; attempt++) {
const k = deriveInviteKey(code, salt);
const payload = encryptWithKey(plain, k.toString('hex'), aad);
if (!payload) continue;
const readBack = decryptChainFromInvite(payload, code, salt);
if (readBack && JSON.stringify(readBack.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))
=== JSON.stringify(chain.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))) {
return payload;
}
}
throw new Error('Could not produce a verifiable invite on this machine');
};
const decryptChainFromInvite = (encryptedPayload, code, salt) => {
const decryptChainOnce = (encryptedPayload, code, salt) => {
const k = deriveInviteKey(code, salt);
try {
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
@ -247,6 +260,15 @@ module.exports = (configPath, namespace = 'tribes') => {
return null;
};
const decryptChainFromInvite = (encryptedPayload, code, salt, attempts = 1) => {
const tries = Math.max(1, Number(attempts) || 1);
for (let i = 0; i < tries; i++) {
const chain = decryptChainOnce(encryptedPayload, code, salt);
if (chain) return chain;
}
return null;
};
const inviteMatchesCode = (inv, code) => {
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
return inv.codeHash === hashInviteCode(code, inv.salt);

View file

@ -47,6 +47,8 @@ module.exports = ({ cooler }) => {
status: data.status || 'LOOKING FOR WORK',
preferences: data.preferences || 'REMOTE WORKING',
visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC',
aiManaged: data.aiManaged === undefined ? true : (data.aiManaged === true || data.aiManaged === '1' || data.aiManaged === 'true'),
matchThreshold: Math.min(100, Math.max(0, parseInt(data.matchThreshold, 10) || 80)),
createdAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
@ -85,7 +87,7 @@ module.exports = ({ cooler }) => {
author: userId,
name: data.name,
description: data.description,
photo: extractBlobId(photoBlobId) || null,
photo: extractBlobId(photoBlobId) || old.content.photo || null,
contact: userId,
personalSkills: parseCSV(data.personalSkills),
personalExperiences: data.personalExperiences || '',
@ -102,6 +104,12 @@ module.exports = ({ cooler }) => {
visibility: data.visibility !== undefined
? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
: (old.content.visibility || 'PUBLIC'),
aiManaged: data.aiManaged !== undefined
? (data.aiManaged === true || data.aiManaged === '1' || data.aiManaged === 'true')
: (old.content.aiManaged !== false),
matchThreshold: data.matchThreshold !== undefined
? Math.min(100, Math.max(0, parseInt(data.matchThreshold, 10) || 80))
: (Number(old.content.matchThreshold) || 80),
createdAt: old.content.createdAt,
updatedAt: new Date().toISOString()
};

View file

@ -109,12 +109,9 @@ const MODULE_ALIASES = {
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
docs: { paths: ['/documents'] },
latest: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest'] },
popular: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/popular'] },
topics: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/topics'] },
summaries: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/summaries'] },
threads: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/threads'] },
multiverse: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/extended'] },
blogs: { model: 'src/models/blog_model.js', view: 'src/views/blog_view.js', paths: ['/blogs'] },
polls: { model: 'src/models/polls_model.js', view: 'src/views/polls_view.js', paths: ['/polls'] },
data: { model: 'src/models/data_model.js', view: 'src/views/data_view.js', paths: ['/data'] },
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' }
};

View file

@ -3,7 +3,7 @@ const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const mediaFavorites = require("../backend/media-favorites");
const contentFavorites = require("../backend/content_favorites");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
@ -154,7 +154,7 @@ module.exports = ({ cooler }) => {
const favoritesSetForDocuments = async () => {
try {
return await mediaFavorites.getFavoriteSet("documents");
return await contentFavorites.getFavoriteSet("documents");
} catch {
return new Set();
}

View file

@ -1,5 +1,7 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { normalizeImages, normalizeVideo } = require('./media_gallery');
const { truthy, hasAnyInterval, nextOccurrence, upcomingOccurrences } = require('./recurrence');
const crypto = require('crypto');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { getConfig } = require('../configs/config-manager.js');
@ -72,8 +74,22 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
return m.toISOString();
};
const recurrenceOf = (c) => ({
weekly: truthy(c.intervalWeekly),
monthly: truthy(c.intervalMonthly),
yearly: truthy(c.intervalYearly),
until: c.recurrenceUntil || ''
});
const effectiveDate = (c) => {
const r = recurrenceOf(c);
if (!hasAnyInterval(r.weekly, r.monthly, r.yearly) || !r.until) return c.date;
const next = nextOccurrence(c.date, r.until, r.weekly, r.monthly, r.yearly);
return next ? next.toISOString() : c.date;
};
const deriveStatus = (c) => {
const dateM = moment(c.date);
const dateM = moment(effectiveDate(c));
let status = String(c.status || 'OPEN').toUpperCase();
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
@ -171,7 +187,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
return idx.rootOf(id);
},
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) {
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false, media = {}, recurrence = {}) {
const ssbClient = await openSsb();
const userId = await me();
@ -203,6 +219,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
isPublic: visibility,
mapUrl: String(mapUrl || "").trim(),
clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
images: normalizeImages(media && media.images),
video: normalizeVideo(media && media.video),
intervalWeekly: truthy(recurrence && recurrence.weekly),
intervalMonthly: truthy(recurrence && recurrence.monthly),
intervalYearly: truthy(recurrence && recurrence.yearly),
recurrenceUntil: String((recurrence && recurrence.until) || '').trim(),
opinions: {},
opinions_inhabitants: []
};
@ -461,6 +483,15 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
status,
isPublic: normalizePrivacy(c.isPublic),
mapUrl: c.mapUrl || "",
images: normalizeImages(c.images),
video: normalizeVideo(c.video),
intervalWeekly: truthy(c.intervalWeekly),
intervalMonthly: truthy(c.intervalMonthly),
intervalYearly: truthy(c.intervalYearly),
recurrenceUntil: c.recurrenceUntil || '',
recurring: hasAnyInterval(truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)) && !!c.recurrenceUntil,
nextDate: effectiveDate(c),
occurrences: upcomingOccurrences(c.date, c.recurrenceUntil, truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)).map(d => d.toISOString()),
clearnetPublic: !!c.clearnetPublic,
encrypted: normalizePrivacy(c.isPublic) === 'private',
opinions: agg.opinions,
@ -503,6 +534,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic,
attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
images: normalizeImages(updatedData.images !== undefined ? updatedData.images : c.images),
video: updatedData.video !== undefined ? normalizeVideo(updatedData.video) : normalizeVideo(c.video),
intervalWeekly: updatedData.intervalWeekly !== undefined ? truthy(updatedData.intervalWeekly) : truthy(c.intervalWeekly),
intervalMonthly: updatedData.intervalMonthly !== undefined ? truthy(updatedData.intervalMonthly) : truthy(c.intervalMonthly),
intervalYearly: updatedData.intervalYearly !== undefined ? truthy(updatedData.intervalYearly) : truthy(c.intervalYearly),
recurrenceUntil: updatedData.recurrenceUntil !== undefined ? String(updatedData.recurrenceUntil || '').trim() : (c.recurrenceUntil || ''),
updatedAt: new Date().toISOString(),
replaces: eventId
};
@ -610,6 +647,14 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
status,
isPublic: normalizePrivacy(c.isPublic),
mapUrl: c.mapUrl || "",
images: normalizeImages(c.images),
video: normalizeVideo(c.video),
intervalWeekly: truthy(c.intervalWeekly),
intervalMonthly: truthy(c.intervalMonthly),
intervalYearly: truthy(c.intervalYearly),
recurrenceUntil: c.recurrenceUntil || '',
recurring: hasAnyInterval(truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)) && !!c.recurrenceUntil,
nextDate: effectiveDate(c),
encrypted: normalizePrivacy(c.isPublic) === 'private',
opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants

View file

@ -1,4 +1,4 @@
const mediaFavorites = require("../backend/media-favorites");
const contentFavorites = require("../backend/content_favorites");
const safeArr = (v) => (Array.isArray(v) ? v : []);
const safeText = (v) => String(v || "").trim();
@ -15,7 +15,7 @@ const toTs = (d) => {
return Number.isFinite(t) ? t : 0;
};
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }) => {
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel, eventsModel, tasksModel, reportsModel, votesModel, jobsModel, housingModel, projectsModel, transfersModel, forumModel, blogsModel, pollsModel }) => {
const kindConfig = {
audios: {
base: "/audios/",
@ -64,10 +64,58 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
shopProducts: {
base: "/shops/product/",
getById: getFn(shopsModel, ["getProductById"])
},
events: {
base: "/events/",
getById: getFn(eventsModel, ["getEventById", "getById"])
},
tasks: {
base: "/tasks/",
getById: getFn(tasksModel, ["getTaskById", "getById"])
},
reports: {
base: "/reports/",
getById: getFn(reportsModel, ["getReportById", "getById"])
},
votes: {
base: "/votes/",
getById: getFn(votesModel, ["getVoteById", "getById"])
},
jobs: {
base: "/jobs/",
getById: getFn(jobsModel, ["getJobById", "getById"])
},
housing: {
base: "/housing/",
getById: getFn(housingModel, ["getHousingById", "getById"])
},
projects: {
base: "/projects/",
getById: getFn(projectsModel, ["getProjectById", "getById"])
},
transfers: {
base: "/transfers/",
getById: getFn(transfersModel, ["getTransferById", "getById"])
},
forum: {
base: "/forum/",
getById: getFn(forumModel, ["getForumById", "getById"])
},
blogs: {
base: "/blogs/",
getById: getFn(blogsModel, ["getBlogById", "getById"])
},
polls: {
base: "/polls/",
getById: getFn(pollsModel, ["getPollById", "getById"])
},
shops: {
base: "/shops/",
getById: getFn(shopsModel, ["getShopById", "getById"])
}
};
const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "torrents", "videos", "market", "shopProducts"];
const kindOrder = ["audios", "blogs", "bookmarks", "calendars", "chats", "documents", "events", "forum", "housing", "images", "jobs", "maps", "market", "pads", "polls", "projects", "reports", "shopProducts", "shops", "tasks", "torrents", "transfers", "videos", "votes"];
const hydrateKind = async (kind, ids) => {
const cfg = kindConfig[kind];
@ -79,9 +127,11 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
if (!id) return null;
try {
const obj = await cfg.getById(id);
if (!obj || typeof obj !== "object") return null;
const viewId = safeText(obj?.key || obj?.id || id);
return {
content: obj,
kind,
favId: id,
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
@ -104,7 +154,7 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
};
const loadAll = async () => {
const sets = await Promise.all(kindOrder.map((k) => mediaFavorites.getFavoriteSet(k)));
const sets = await Promise.all(kindOrder.map((k) => contentFavorites.getFavoriteSet(k)));
const idsByKind = {};
kindOrder.forEach((k, i) => {
idsByKind[k] = Array.from(sets[i] || []);
@ -118,21 +168,8 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
const flat = kindOrder.flatMap((k) => byKind[k]);
const counts = {
audios: byKind.audios.length,
bookmarks: byKind.bookmarks.length,
calendars: byKind.calendars.length,
chats: byKind.chats.length,
documents: byKind.documents.length,
images: byKind.images.length,
maps: byKind.maps.length,
pads: byKind.pads.length,
torrents: byKind.torrents.length,
videos: byKind.videos.length,
market: byKind.market.length,
shopProducts: byKind.shopProducts.length,
all: flat.length
};
const counts = { all: flat.length };
for (const k of kindOrder) counts[k] = (byKind[k] || []).length;
const recentFlat = flat
.slice()
@ -167,11 +204,13 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
return { items: grouped, counts };
},
kinds: kindOrder.slice(),
async removeFavorite(kind, id) {
const k = safeText(kind);
const favId = safeText(id);
if (!k || !favId) return;
await mediaFavorites.removeFavorite(k, favId);
await contentFavorites.removeFavorite(k, favId);
}
};
};

View file

@ -142,7 +142,6 @@ module.exports = ({ cooler }) => {
title: c.title || "",
description: c.description || "",
mapUrl: c.mapUrl || "",
meme: !!c.meme,
opinions: agg ? agg.opinions : (c.opinions || {}),
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false,
@ -177,7 +176,7 @@ module.exports = ({ cooler }) => {
return root;
},
async createImage(blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
async createImage(blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
@ -193,7 +192,6 @@ module.exports = ({ cooler }) => {
title: title || "",
description: description || "",
mapUrl: mapUrl || "",
meme: !!memeBool,
opinions: {},
opinions_inhabitants: []
};
@ -203,7 +201,7 @@ module.exports = ({ cooler }) => {
});
},
async updateImageById(id, blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
async updateImageById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
@ -226,7 +224,6 @@ module.exports = ({ cooler }) => {
title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "",
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme,
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
@ -280,7 +277,6 @@ module.exports = ({ cooler }) => {
if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000);
else if (filter === "meme") list = list.filter((im) => im.meme === true);
else if (filter === "top") {
list = list
.slice()

View file

@ -6,6 +6,7 @@ const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_MS = 7 * DAY_MS;
const MONTH_MS = 30 * DAY_MS;
@ -311,7 +312,16 @@ module.exports = ({ cooler }) => {
ref: c.ref || null
});
}
const parentOf = new Map();
for (const i of items) if (i.replaces) parentOf.set(i.key, i.replaces);
const rootOf = (key) => {
let cur = key;
const seen = new Set();
while (parentOf.has(cur) && !seen.has(cur)) { seen.add(cur); cur = parentOf.get(cur); }
return cur;
};
const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key));
for (const i of survivors) i.rootId = rootOf(i.key);
survivors.sort((a, b) => b.ts - a.ts);
return survivors;
}
@ -332,11 +342,14 @@ module.exports = ({ cooler }) => {
async function updateLog(id, { text, label, mode }) {
const current = await getLogById(id);
if (!current) return { status: 'not_found' };
await republishLog({
replaces: current.key,
const next = {
text: text !== undefined ? text : current.text,
label: label !== undefined ? label : current.label,
mode: mode || current.mode,
mode: mode || current.mode
};
await republishLog({
replaces: current.key,
...next,
createdAt: current.createdAt
});
return { status: 'ok' };

View file

@ -501,6 +501,31 @@ async function checkLocalBlob(blobId) {
}
models.blob = {
getCached: async ({ blobId }) => {
const local = await checkLocalBlob(blobId);
if (local) return local;
const ssb = await cooler.open();
const has = await new Promise((resolve) => ssb.blobs.has(blobId, (err, v) => resolve(!err && !!v)));
if (!has) {
try { ssb.blobs.want(blobId, () => {}); } catch (_) {}
return null;
}
return new Promise((resolve) => {
pull(
ssb.blobs.get(blobId),
pull.collect(async (err, bufs) => {
if (err || !bufs || !bufs.length) return resolve(null);
const buffer = Buffer.concat(bufs);
try {
const filePath = blobIdToHexPath(blobId);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, buffer);
} catch (e) { /* ignore */ }
resolve(buffer);
})
);
});
},
getResolved: async ({ blobId, timeout = 30000 }) => {
let buf = await checkLocalBlob(blobId);
if (buf) return buf;

View file

@ -864,7 +864,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
let mapKey = null;
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt);
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3);
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {

View file

@ -0,0 +1,42 @@
const MAX_IMAGES = 8
const MEDIA_MARKDOWN = /\[(image|video|audio):[^\]]*\]\(/
const blobOf = (value) => {
let blobId = value || null
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
return blobId || null
}
const keepMarkdown = (value, id) =>
MEDIA_MARKDOWN.test(value) || value.startsWith("![") ? value : id
const normalizeImages = (raw, max = MAX_IMAGES) => {
const list = Array.isArray(raw) ? raw : (raw ? [raw] : [])
const out = []
const seen = new Set()
for (const entry of list) {
const value = String(entry || "").trim()
const id = blobOf(value)
if (!id || seen.has(id)) continue
seen.add(id)
out.push(keepMarkdown(value, id))
if (out.length >= max) break
}
return out
}
const normalizeVideo = (raw) => {
const value = String(Array.isArray(raw) ? raw[0] || "" : raw || "").trim()
const id = blobOf(value)
if (!id) return ""
return keepMarkdown(value, id)
}
const mergeGallery = (current, uploaded, removeIndex = -1, max = MAX_IMAGES) => {
let list = normalizeImages(current, max)
if (removeIndex >= 0) list = list.filter((_, i) => i !== removeIndex)
return normalizeImages([...list, ...normalizeImages(uploaded, max)], max)
}
module.exports = { MAX_IMAGES, blobOf, normalizeImages, normalizeVideo, mergeGallery }

View file

@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const pull = require('../server/node_modules/pull-stream');
const STEPS = ['language', 'profile', 'federation', 'larp', 'greeting', 'backup'];
const STEPS = ['language', 'ux', 'profile', 'federation', 'larp', 'greeting', 'backup'];
const FLAG = 'oasis-first-contact';
const PENDING = 'welcome=pending';
const DISMISSED = 'welcome=dismissed';
@ -122,7 +122,7 @@ module.exports = ({ cooler, ssbPath } = {}) => {
markStep(step) {
if (!STEPS.includes(step)) return false;
if (step !== 'language' && step !== 'backup') return true;
if (step !== 'language' && step !== 'backup' && step !== 'ux') return true;
if (!isPending(dir())) return true;
return appendMarker(dir(), `step=${step}`);
},
@ -147,6 +147,7 @@ module.exports = ({ cooler, ssbPath } = {}) => {
profile: hasProfile(mine, messages),
federation: hasFederated(mine, messages),
larp: hasJoinedLarp(messages),
ux: flag.markers.has('step=ux'),
backup: flag.markers.has('step=backup'),
greeting: hasGreeted(messages)
};

View file

@ -12,6 +12,20 @@ const QUORUM_RATIO = 0.25;
const VOTE_METHODS = new Set(['DEMOCRACY', 'ANARCHY', 'MAJORITY', 'MINORITY']);
const METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY'];
const FEED_ID_RE = /^@.+\.ed25519$/;
const TERM_EPOCH = Date.UTC(2020, 0, 1);
const TERM_SPAN_MS = TERM_DAYS * 86400000;
const termWindowFor = (at = Date.now()) => {
const ms = at instanceof Date ? at.getTime() : Number(at);
const base = Number.isFinite(ms) ? ms : Date.now();
const index = Math.floor((base - TERM_EPOCH) / TERM_SPAN_MS);
const startMs = TERM_EPOCH + index * TERM_SPAN_MS;
return {
cycle: index,
startAt: new Date(startMs).toISOString(),
endAt: new Date(startMs + TERM_SPAN_MS).toISOString()
};
};
module.exports = ({ cooler, services = {} }) => {
let ssb;
@ -251,7 +265,13 @@ if (c.type === type) {
async function listCandidaturesOpenRaw() {
const all = await listByType('parliamentCandidature');
const filtered = all.filter(c => (c.status || 'OPEN') === 'OPEN');
const window = termWindowFor(Date.now());
const from = new Date(window.startAt).getTime();
const filtered = all.filter(c => {
if ((c.status || 'OPEN') !== 'OPEN') return false;
const created = new Date(c.createdAt).getTime();
return Number.isFinite(created) ? created >= from : true;
});
return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
@ -345,6 +365,28 @@ if (c.type === type) {
return isExpiredTerm(t) ? null : t;
}
function virtualAnarchyTerm(window = termWindowFor(Date.now())) {
return {
id: `anarchy:${window.cycle}`,
virtual: true,
type: 'parliamentTerm',
cycle: window.cycle,
method: 'ANARCHY',
powerType: 'none',
powerId: null,
powerTitle: 'ANARCHY',
winnerTribeId: null,
winnerInhabitantId: null,
winnerVotes: 0,
totalVotes: 0,
population: 0,
startAt: window.startAt,
endAt: window.endAt,
createdBy: null,
createdAt: window.startAt
};
}
function currentCycleStart(term) {
return term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString();
}
@ -352,6 +394,7 @@ if (c.type === type) {
async function archiveAllCandidatures() {
const all = await listCandidaturesOpenRaw();
for (const c of all) {
if (String(c.proposer || '') !== String(userId)) continue;
const updated = { ...stripId(c), replaces: c.id, status: 'DISCARDED', updatedAt: nowISO() };
await publishMsg(updated);
}
@ -713,10 +756,7 @@ if (c.type === type) {
async function createProposal({ title, description }) {
let term = await getCurrentTermBase();
if (!term) {
await resolveElection();
term = await getCurrentTermBase();
}
if (!term) term = await resolveElection();
if (!term) throw new Error('No active government');
const allowed = await canPropose();
if (!allowed) throw new Error('You are not in the goverment, yet.');
@ -833,6 +873,11 @@ if (c.type === type) {
}
async function getCurrentTerm() {
const published = await getCurrentTermBase();
return published || virtualAnarchyTerm();
}
async function getPublishedTerm() {
return await getCurrentTermBase();
}
@ -1135,44 +1180,20 @@ if (c.type === type) {
}
}
const startAt = now.toISOString();
const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
const window = termWindowFor(now.valueOf());
const startAt = window.startAt;
const endAt = window.endAt;
const population = await inhabitantsCount();
if (!chosen) {
const termAnarchy = {
type: 'parliamentTerm',
method: 'ANARCHY',
powerType: 'none',
powerId: null,
powerTitle: 'ANARCHY',
winnerTribeId: null,
winnerInhabitantId: null,
winnerVotes: 0,
totalVotes: 0,
startAt,
endAt,
createdBy: userId,
createdAt: nowISO()
};
const resAnarchy = await publishMsg(termAnarchy);
try {
await sleep(250);
const canonical = await getCurrentTermBase();
const myId = resAnarchy.key || resAnarchy.id;
if (canonical && canonical.id && myId && canonical.id !== myId) {
const tomb = { type: 'tombstone', target: myId, deletedAt: nowISO(), author: userId };
await publishMsg(tomb);
await archiveAllCandidatures();
return canonical;
}
} catch {}
await archiveAllCandidatures();
return resAnarchy;
return virtualAnarchyTerm(window);
}
const term = {
type: 'parliamentTerm',
cycle: window.cycle,
population,
method: chosen.method,
powerType: chosen.targetType,
powerId: chosen.targetId,
@ -1223,21 +1244,16 @@ if (c.type === type) {
const term = await getLatestTermAny();
if (!term) return null;
const card = await computeGovernmentCard({ ...term, id: term.id || term.startAt });
const now = moment();
let start = moment(term.startAt);
let end = term.endAt ? moment(term.endAt) : start.clone().add(TERM_DAYS, 'days');
let rolled = false;
while (start.isValid() && end.isValid() && end.isSameOrBefore(now)) {
start = end.clone();
end = end.clone().add(TERM_DAYS, 'days');
rolled = true;
}
const current = termWindowFor(Date.now());
const termEnd = term.endAt ? new Date(term.endAt).getTime() : NaN;
const rolled = Number.isFinite(termEnd) && termEnd <= Date.now();
if (rolled) {
return {
...card,
id: term.id || term.startAt,
since: start.toISOString(),
end: end.toISOString(),
cycle: current.cycle,
since: current.startAt,
end: current.endAt,
method: 'ANARCHY',
powerType: 'none',
powerId: null,
@ -1513,6 +1529,7 @@ if (c.type === type) {
listCandidatures,
listTerms,
getCurrentTerm,
getPublishedTerm,
getLatestTerm,
getLatestGovernmentCard,
listLeaders,
@ -1543,6 +1560,29 @@ if (c.type === type) {
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function compareTermsForWindow(a, b) {
const num = (t, field) => Number(t && t[field]) || 0;
const anarchy = (t) => (String(t && t.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0);
const populationA = num(a, 'population');
const populationB = num(b, 'population');
if (populationA !== populationB) return populationB - populationA;
const turnoutA = num(a, 'totalVotes');
const turnoutB = num(b, 'totalVotes');
if (turnoutA !== turnoutB) return turnoutB - turnoutA;
const votesA = num(a, 'winnerVotes');
const votesB = num(b, 'winnerVotes');
if (votesA !== votesB) return votesB - votesA;
const anarchyA = anarchy(a);
const anarchyB = anarchy(b);
if (anarchyA !== anarchyB) return anarchyA - anarchyB;
return String(a && a.id || '').localeCompare(String(b && b.id || ''));
}
function collapseOverlappingTerms(terms = []) {
if (!terms.length) return [];
const sorted = [...terms].sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
@ -1563,20 +1603,13 @@ function collapseOverlappingTerms(terms = []) {
if (!placed) groups.push({ items: [t], minStart: tStart, maxEnd: tEnd });
}
const winners = groups.map(g => {
g.items.sort((a, b) => {
const aAn = String(a.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
const bAn = String(b.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
if (aAn !== bAn) return aAn - bAn;
const aC = new Date(a.createdAt || a.startAt).getTime();
const bC = new Date(b.createdAt || b.startAt).getTime();
if (aC !== bC) return aC - bC;
const aS = new Date(a.startAt).getTime();
const bS = new Date(b.startAt).getTime();
if (aS !== bS) return aS - bS;
return String(a.id || '').localeCompare(String(b.id || ''));
});
g.items.sort(compareTermsForWindow);
return g.items[0];
});
return winners.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
}
module.exports.termWindowFor = termWindowFor;
module.exports.compareTermsForWindow = compareTermsForWindow;
module.exports.collapseOverlappingTerms = collapseOverlappingTerms;
module.exports.TERM_DAYS = TERM_DAYS;

443
src/models/polls_model.js Normal file
View file

@ -0,0 +1,443 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const POLL_TYPE = 'poll';
const VOTE_TYPE = 'pollVote';
const OPINION_TYPE = 'pollOpinion';
const { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH } = require('./polls_model_limits');
const clean = (v) => String(v == null ? '' : v).trim();
const normalizeOptions = (raw) => {
const list = Array.isArray(raw) ? raw : String(raw || '').split('\n');
const out = [];
const seen = new Set();
for (const entry of list) {
const value = clean(entry).slice(0, MAX_OPTION_LENGTH);
if (!value) continue;
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
if (out.length >= MAX_OPTIONS) break;
}
return out;
};
const normalizeTags = (raw) => {
const list = Array.isArray(raw) ? raw : String(raw || '').split(',');
return list.map(t => clean(t)).filter(Boolean).slice(0, 20);
};
module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = null }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const decryptField = (value, keys) => {
if (!tribeCrypto || typeof value !== 'string' || !value) return null;
for (const k of keys) {
try {
const out = tribeCrypto.decryptWithKey(value, k);
if (typeof out === 'string' && out) return out;
} catch (_) {}
}
return null;
};
const scopeKey = async (scope) => {
if (!scope || !chatsModel) return null;
if (!scope.chatId && !scope.tribeId) return null;
try { return await chatsModel.encryptionKeyFor(scope.chatId || null, scope.tribeId || null); } catch (_) { return null; }
};
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const replaced = new Map();
const votesByRoot = new Map();
const opinionsByRoot = new Map();
const closedRoots = new Set();
const commentsByRoot = new Map();
const authorByKey = new Map();
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c || typeof c !== 'object') continue;
authorByKey.set(m.key, v.author);
if (c.type === POLL_TYPE) {
nodes.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
if (typeof c.replaces === 'string') replaced.set(c.replaces, m.key);
continue;
}
if (c.type === VOTE_TYPE && typeof c.target === 'string') {
const entry = votesByRoot.get(c.target) || new Map();
const prev = entry.get(v.author);
const ts = v.timestamp || m.timestamp || 0;
if (!prev || ts >= prev.ts) {
entry.set(v.author, {
ts,
choices: Array.isArray(c.choices) ? c.choices : [],
encryptedChoices: typeof c.encryptedChoices === 'string' ? c.encryptedChoices : null
});
}
votesByRoot.set(c.target, entry);
continue;
}
if (c.type === OPINION_TYPE && typeof c.target === 'string') {
const entry = opinionsByRoot.get(c.target) || { counts: {}, voters: [] };
if (!entry.voters.includes(v.author)) {
entry.voters.push(v.author);
entry.counts[c.category] = (entry.counts[c.category] || 0) + 1;
}
opinionsByRoot.set(c.target, entry);
continue;
}
if (c.type === 'pollClose' && typeof c.target === 'string') {
closedRoots.add(`${v.author}|${c.target}`);
continue;
}
if (c.type === 'post' && typeof c.root === 'string') {
commentsByRoot.set(c.root, (commentsByRoot.get(c.root) || 0) + 1);
}
}
const rootOf = (key) => {
let cur = key;
const seen = new Set();
while (nodes.has(cur) && nodes.get(cur).c.replaces && !seen.has(cur)) {
seen.add(cur);
const next = nodes.get(cur).c.replaces;
if (!nodes.has(next)) break;
cur = next;
}
return cur;
};
const tipOf = (root) => {
let cur = root;
const seen = new Set();
while (replaced.has(cur) && !seen.has(cur)) {
seen.add(cur);
const next = replaced.get(cur);
const node = nodes.get(next);
if (!node || node.author !== nodes.get(cur).author) break;
cur = next;
}
return cur;
};
return { tomb, nodes, replaced, votesByRoot, opinionsByRoot, closedRoots, commentsByRoot, rootOf, tipOf };
};
const isExpired = (c) => {
if (!c.deadline) return false;
const t = Date.parse(c.deadline);
return Number.isFinite(t) && t <= Date.now();
};
const buildPoll = (idx, rootId, viewerId, keys = []) => {
const tipId = idx.tipOf(rootId);
const node = idx.nodes.get(tipId);
if (!node) return null;
const c = node.c || {};
let question = clean(c.question);
let rawOptions = c.options;
let undecryptable = false;
if (c.encryptedQuestion) {
const q = decryptField(c.encryptedQuestion, keys);
const o = decryptField(c.encryptedOptions, keys);
if (q === null) undecryptable = true;
question = q || '';
try { rawOptions = o ? JSON.parse(o) : []; } catch (_) { rawOptions = []; }
}
const options = normalizeOptions(rawOptions);
const voteMap = idx.votesByRoot.get(rootId) || new Map();
const counts = {};
for (const o of options) counts[o] = 0;
let totalVoters = 0;
const voters = [];
let myChoices = [];
for (const [author, entry] of voteMap.entries()) {
let choices = Array.isArray(entry.choices) ? entry.choices : [];
if (entry.encryptedChoices) {
const plain = decryptField(entry.encryptedChoices, keys);
try { choices = plain ? JSON.parse(plain) : []; } catch (_) { choices = []; }
}
const picked = (Array.isArray(choices) ? choices : []).filter(o => options.includes(o));
if (!picked.length) continue;
totalVoters += 1;
voters.push(author);
for (const o of picked) counts[o] += 1;
if (viewerId && author === viewerId) myChoices = picked;
}
const closed = idx.closedRoots.has(`${node.author}|${rootId}`) || isExpired(c);
const op = idx.opinionsByRoot.get(rootId) || { counts: {}, voters: [] };
return {
id: rootId,
rootId,
tipId,
author: node.author,
question,
options,
encrypted: !!c.encryptedQuestion,
undecryptable,
chatId: c.chatId || null,
anonymous: c.anonymous === true,
multiple: c.multiple === true,
deadline: c.deadline || '',
tags: normalizeTags(c.tags),
tribeId: c.tribeId || null,
houseKey: c.houseKey || null,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
status: closed ? 'CLOSED' : 'OPEN',
counts,
totalVoters,
voters: c.anonymous === true ? [] : voters,
votersHidden: c.anonymous === true,
myChoices,
hasVoted: myChoices.length > 0,
commentCount: idx.commentsByRoot.get(rootId) || 0,
opinions: op.counts,
opinions_inhabitants: op.voters
};
};
const collect = async (viewerId) => {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const roots = new Set();
for (const key of idx.nodes.keys()) roots.add(idx.rootOf(key));
const keyCache = new Map();
const keysFor = async (c) => {
if (!c || (!c.chatId && !c.tribeId)) return [];
const cacheKey = `${c.chatId || ''}|${c.tribeId || ''}`;
if (keyCache.has(cacheKey)) return keyCache.get(cacheKey);
const k = await scopeKey({ chatId: c.chatId || null, tribeId: c.tribeId || null });
const keys = k ? [k] : [];
keyCache.set(cacheKey, keys);
return keys;
};
const list = [];
for (const rootId of roots) {
const tipId = idx.tipOf(rootId);
if (idx.tomb.has(tipId) || idx.tomb.has(rootId)) continue;
const node = idx.nodes.get(tipId);
const keys = await keysFor(node && node.c);
const poll = buildPoll(idx, rootId, viewerId || ssbClient.id, keys);
if (!poll) continue;
if (poll.undecryptable) { list.push(poll); continue; }
if (!poll.question || poll.options.length < MIN_OPTIONS) continue;
list.push(poll);
}
return { list, idx, viewerId: viewerId || ssbClient.id };
};
return {
POLL_TYPE,
VOTE_TYPE,
MAX_OPTIONS,
MIN_OPTIONS,
async listAll(filter = 'ALL', opts = {}) {
const { list, viewerId } = await collect();
const f = String(filter || 'ALL').toUpperCase();
const scopeChat = opts.chatId || null;
const scopeTribe = opts.tribeId || null;
const scopeHouse = opts.houseKey || null;
let out = list.filter(p =>
scopeChat ? p.chatId === scopeChat
: scopeTribe ? (p.tribeId === scopeTribe && !p.chatId)
: scopeHouse ? p.houseKey === scopeHouse
: (!p.tribeId && !p.chatId && !p.houseKey));
if (f === 'MINE') out = out.filter(p => String(p.author) === String(viewerId));
else if (f === 'OPEN') out = out.filter(p => p.status === 'OPEN');
else if (f === 'CLOSED') out = out.filter(p => p.status === 'CLOSED');
else if (f === 'VOTED') out = out.filter(p => p.hasVoted);
else if (f === 'FAVORITES') {
const fav = new Set((opts.favorites || []).map(String));
out = out.filter(p => fav.has(String(p.id)));
}
const q = clean(opts.q).toLowerCase();
if (q) out = out.filter(p =>
p.question.toLowerCase().includes(q) ||
p.options.some(o => o.toLowerCase().includes(q)) ||
p.tags.some(t => t.toLowerCase().includes(q)));
if (f === 'TOP') out.sort((a, b) => b.totalVoters - a.totalVoters || new Date(b.createdAt) - new Date(a.createdAt));
else out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return out;
},
async getPollById(id, viewerId = null) {
const { list, idx } = await collect(viewerId);
const rootId = idx.rootOf(id);
const poll = list.find(p => p.id === rootId || p.id === id || p.tipId === id);
if (!poll) throw new Error('Poll not found');
return poll;
},
async resolveRootId(id) {
const { idx } = await collect();
return idx.rootOf(id);
},
async createPoll(data = {}) {
if (isPublic) throw new Error('Not available in public mode');
const question = clean(data.question);
if (!question) throw new Error('Question is required');
const options = normalizeOptions(data.options);
if (options.length < MIN_OPTIONS) throw new Error(`A poll needs at least ${MIN_OPTIONS} options`);
const deadline = clean(data.deadline);
if (deadline) {
const t = Date.parse(deadline);
if (!Number.isFinite(t)) throw new Error('Invalid deadline');
if (t <= Date.now()) throw new Error('Deadline must be in the future');
}
const ssbClient = await openSsb();
const content = {
type: POLL_TYPE,
anonymous: data.anonymous === true || data.anonymous === 'true' || data.anonymous === '1',
multiple: data.multiple === true || data.multiple === 'true' || data.multiple === '1',
deadline,
tags: normalizeTags(data.tags),
createdAt: new Date().toISOString(),
...(data.tribeId ? { tribeId: String(data.tribeId) } : {}),
...(data.chatId ? { chatId: String(data.chatId) } : {}),
...(data.houseKey ? { houseKey: String(data.houseKey) } : {})
};
const key = await scopeKey({ chatId: data.chatId, tribeId: data.tribeId });
if (key && tribeCrypto) {
content.encryptedQuestion = tribeCrypto.encryptWithKey(question, key);
content.encryptedOptions = tribeCrypto.encryptWithKey(JSON.stringify(options), key);
} else {
content.question = question;
content.options = options;
}
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async updatePoll(id, data = {}) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const poll = await this.getPollById(id, userId);
if (poll.author !== userId) throw new Error('Not the author');
if (poll.status === 'CLOSED') throw new Error('Cannot edit a closed poll');
if (poll.totalVoters > 0) throw new Error('Cannot edit a poll that already has votes');
const options = data.options !== undefined ? normalizeOptions(data.options) : poll.options;
if (options.length < MIN_OPTIONS) throw new Error(`A poll needs at least ${MIN_OPTIONS} options`);
const deadline = data.deadline !== undefined ? clean(data.deadline) : poll.deadline;
if (deadline) {
const t = Date.parse(deadline);
if (!Number.isFinite(t)) throw new Error('Invalid deadline');
}
const question = data.question !== undefined ? clean(data.question) : poll.question;
const content = {
type: POLL_TYPE,
replaces: poll.tipId,
anonymous: data.anonymous !== undefined
? (data.anonymous === true || data.anonymous === 'true' || data.anonymous === '1')
: poll.anonymous,
multiple: data.multiple !== undefined
? (data.multiple === true || data.multiple === 'true' || data.multiple === '1')
: poll.multiple,
deadline,
tags: data.tags !== undefined ? normalizeTags(data.tags) : poll.tags,
createdAt: poll.createdAt,
updatedAt: new Date().toISOString(),
...(poll.tribeId ? { tribeId: poll.tribeId } : {}),
...(poll.chatId ? { chatId: poll.chatId } : {}),
...(poll.houseKey ? { houseKey: poll.houseKey } : {})
};
const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId });
if (key && tribeCrypto) {
content.encryptedQuestion = tribeCrypto.encryptWithKey(question, key);
content.encryptedOptions = tribeCrypto.encryptWithKey(JSON.stringify(options), key);
} else {
content.question = question;
content.options = options;
}
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async vote(id, choicesRaw) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const poll = await this.getPollById(id, userId);
if (poll.status === 'CLOSED') throw new Error('This poll is closed');
const wanted = (Array.isArray(choicesRaw) ? choicesRaw : [choicesRaw])
.map(c => clean(c))
.filter(c => poll.options.includes(c));
const unique = [...new Set(wanted)];
if (!unique.length) throw new Error('Pick at least one option');
if (!poll.multiple && unique.length > 1) throw new Error('This poll only accepts one option');
const content = {
type: VOTE_TYPE,
target: poll.rootId,
createdAt: new Date().toISOString(),
...(poll.chatId ? { chatId: poll.chatId } : {})
};
const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId });
if (key && tribeCrypto) content.encryptedChoices = tribeCrypto.encryptWithKey(JSON.stringify(unique), key);
else content.choices = unique;
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async closePoll(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const poll = await this.getPollById(id, userId);
if (poll.author !== userId) throw new Error('Not the author');
if (poll.status === 'CLOSED') return { status: 'already_closed' };
const content = { type: 'pollClose', target: poll.rootId, createdAt: new Date().toISOString() };
await new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
return { status: 'ok' };
},
async deletePoll(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const poll = await this.getPollById(id, userId);
if (poll.author !== userId) throw new Error('Not the author');
const content = { type: 'tombstone', target: poll.tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories');
if (!categories.includes(category)) throw new Error('Invalid opinion category');
const ssbClient = await openSsb();
const userId = ssbClient.id;
const poll = await this.getPollById(id, userId);
if (poll.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
const content = { type: OPINION_TYPE, target: poll.rootId, category, createdAt: new Date().toISOString() };
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
}
};
};

View file

@ -0,0 +1,5 @@
const MAX_OPTIONS = 10;
const MIN_OPTIONS = 2;
const MAX_OPTION_LENGTH = 120;
module.exports = { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH };

40
src/models/recurrence.js Normal file
View file

@ -0,0 +1,40 @@
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';
const hasAnyInterval = (weekly, monthly, yearly) => truthy(weekly) || truthy(monthly) || truthy(yearly);
const expandRecurrence = (firstDate, until, weekly, monthly, yearly) => {
const start = new Date(firstDate);
if (isNaN(start.getTime())) return [];
const out = [start];
if (!until || !hasAnyInterval(weekly, monthly, yearly)) return out;
const end = new Date(until).getTime();
if (!Number.isFinite(end)) return out;
const seen = new Set([start.getTime()]);
const walk = (mutate) => {
const n = new Date(start);
mutate(n);
let guard = 0;
while (n.getTime() <= end && guard++ < 5000) {
const t = n.getTime();
if (!seen.has(t)) { seen.add(t); out.push(new Date(n)); }
mutate(n);
}
};
if (truthy(weekly)) walk((d) => d.setUTCDate(d.getUTCDate() + 7));
if (truthy(monthly)) walk((d) => d.setUTCMonth(d.getUTCMonth() + 1));
if (truthy(yearly)) walk((d) => d.setUTCFullYear(d.getUTCFullYear() + 1));
return out.sort((a, b) => a.getTime() - b.getTime());
};
const nextOccurrence = (firstDate, until, weekly, monthly, yearly, from = Date.now()) => {
const list = expandRecurrence(firstDate, until, weekly, monthly, yearly);
for (const d of list) if (d.getTime() >= from) return d;
return list.length ? list[list.length - 1] : null;
};
const upcomingOccurrences = (firstDate, until, weekly, monthly, yearly, limit = 12, from = Date.now()) =>
expandRecurrence(firstDate, until, weekly, monthly, yearly)
.filter(d => d.getTime() >= from)
.slice(0, limit);
module.exports = { truthy, hasAnyInterval, expandRecurrence, nextOccurrence, upcomingOccurrences };

View file

@ -1,4 +1,5 @@
const pull = require('../server/node_modules/pull-stream');
const { normalizeImages, normalizeVideo } = require('./media_gallery');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
@ -39,7 +40,7 @@ const normalizeTemplate = (category, tpl) => {
}
if (cat === 'CONTENT') {
return pick(['contentLocation', 'whyInappropriate', 'requestedAction', 'evidenceLinks']);
return pick(['contentLocation', 'whyInappropriate', 'evidenceLinks']);
}
return {};
@ -164,6 +165,8 @@ module.exports = ({ cooler }) => {
...c,
category: cat,
status: normalizeStatus(c.status || 'OPEN'),
images: normalizeImages(c.images && c.images.length ? c.images : c.image),
video: normalizeVideo(c.video),
severity: normalizeSeverity(c.severity) || 'low',
confirmations,
tags: ensureArray(c.tags),
@ -174,14 +177,13 @@ module.exports = ({ cooler }) => {
};
return {
async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}) {
async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}, media = {}) {
const ssb = await openSsb();
const userId = ssb.id;
let blobId = null;
if (image) {
blobId = String(image).trim() || null;
}
const images = normalizeImages((media && media.images && media.images.length) ? media.images : image);
const blobId = images[0] || null;
const clip = normalizeVideo(media && media.video);
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
@ -196,6 +198,8 @@ module.exports = ({ cooler }) => {
createdAt: new Date().toISOString(),
author: userId,
image: blobId,
images,
video: clip,
tags,
confirmations: [],
severity: normalizeSeverity(severity) || 'low',
@ -227,10 +231,15 @@ module.exports = ({ cooler }) => {
? String(updatedContent.tags || '').split(',').map(t => t.trim()).filter(Boolean)
: ensureArray(report.content.tags);
let blobId = report.content.image || null;
if (updatedContent.image) {
blobId = String(updatedContent.image).trim() || null;
}
const nextImages = normalizeImages(
Object.prototype.hasOwnProperty.call(updatedContent, 'images')
? updatedContent.images
: (report.content.images && report.content.images.length ? report.content.images : (updatedContent.image || report.content.image))
);
const blobId = nextImages[0] || null;
const nextVideo = Object.prototype.hasOwnProperty.call(updatedContent, 'video')
? normalizeVideo(updatedContent.video)
: normalizeVideo(report.content.video);
const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status')
? normalizeStatus(updatedContent.status)
@ -258,6 +267,8 @@ module.exports = ({ cooler }) => {
type: 'report',
replaces: tipId,
image: blobId,
images: nextImages,
video: nextVideo,
tags,
confirmations,
opinions: agg.opinions,

View file

@ -1,5 +1,6 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { normalizeImages, normalizeVideo } = require('./media_gallery');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
@ -131,6 +132,8 @@ module.exports = ({ cooler, pmModel }) => {
id: node.key,
rootId,
...c,
images: normalizeImages(c.images),
video: normalizeVideo(c.video),
assignees,
opinions,
opinions_inhabitants: voters,
@ -139,7 +142,7 @@ module.exports = ({ cooler, pmModel }) => {
};
return {
async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic) {
async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic, media = {}) {
const ssb = await openSsb();
const userId = ssb.id;
@ -166,6 +169,8 @@ module.exports = ({ cooler, pmModel }) => {
location,
tags,
isPublic: visibility,
images: normalizeImages(media && media.images),
video: normalizeVideo(media && media.video),
assignees: [userId],
createdAt: new Date().toISOString(),
status: 'OPEN',
@ -253,6 +258,8 @@ module.exports = ({ cooler, pmModel }) => {
tags: newTags,
isPublic: newVisibility,
status: newStatus,
images: normalizeImages(updatedData.images !== undefined ? updatedData.images : c.images),
video: updatedData.video !== undefined ? normalizeVideo(updatedData.video) : normalizeVideo(c.video),
assignees: nextAssignees,
updatedAt: new Date().toISOString(),
replaces: taskId

View file

@ -671,7 +671,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (typeof c.ch !== 'string' || typeof c.s !== 'string' || typeof c.ek !== 'string') continue;
if (inviteTombstoned.has(m.key)) continue;
if (tribeCrypto.hashInviteCode(code, c.s) !== c.ch) continue;
const chain = tribeCrypto.decryptChainFromInvite(c.ek, code, c.s);
const chain = tribeCrypto.decryptChainFromInvite(c.ek, code, c.s, 3);
if (Array.isArray(chain) && chain.length) {
matched = { msgKey: m.key, codeHash: c.ch, chain, multi: c.multi === 1 || c.multi === true };
break;