feat: merge oasis 0.7.5 upstream — tribes ACL, 4 new languages, model/view updates
Manual merge of epsylon/oasis 0.7.5 preserving our mobile-specific work: - tribes_model: upstream ACL security (validMembershipDelta, structuralFieldsEqual) + our inviteLog feature re-applied in generateInvite/joinByInvite - tribes_view: isSubEdit hides isAnonymous/isLARP when editing subtribes - blockchain_view: new block types (calendarDate, calendarNote, padEntry, chatMessage), bd-type-* CSS classes replacing inline styles, encrypted payload filter - style.css: removed old tribe-parent classes, added bd-type-* and stats utility classes (mobile.css/OasisMobile.css untouched — our mobile work) - backend.js: calendarsModel/torrentsModel/mapsModel now receive tribeCrypto+tribesModel, moved after tribesModel init; ensureFollowTribeMembers call in /tribe/:tribeId route - translations: updated de/en/es/eu/fr/it/pt + 4 new languages ar/hi/ru/zh - models (safe copy): calendars, chats, maps, pads, parliament, torrents, tribe_crypto, tribes_content - views (safe copy): chats, pads, torrents, stats - package.json: bumped to 0.7.5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bb2d7acfd9
commit
9764a0c162
34 changed files with 14235 additions and 373 deletions
|
|
@ -345,13 +345,11 @@ const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.p
|
|||
const votesModel = require('../models/votes_model')({ cooler, isPublic: config.public });
|
||||
const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public });
|
||||
const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
|
||||
const calendarsModel = require('../models/calendars_model')({ cooler, pmModel });
|
||||
const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
|
||||
const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
|
||||
const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
|
||||
const imagesModel = require("../models/images_model")({ cooler, isPublic: config.public });
|
||||
const audiosModel = require("../models/audios_model")({ cooler, isPublic: config.public });
|
||||
const torrentsModel = require("../models/torrents_model")({ cooler, isPublic: config.public });
|
||||
const videosModel = require("../models/videos_model")({ cooler, isPublic: config.public });
|
||||
const documentsModel = require("../models/documents_model")({ cooler, isPublic: config.public });
|
||||
const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public });
|
||||
|
|
@ -370,8 +368,10 @@ const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic
|
|||
const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto });
|
||||
const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto });
|
||||
const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, tribesModel });
|
||||
const calendarsModel = require('../models/calendars_model')({ cooler, pmModel, tribeCrypto, tribesModel });
|
||||
const torrentsModel = require("../models/torrents_model")({ cooler, tribeCrypto, tribesModel });
|
||||
const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
|
||||
const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
|
||||
const mapsModel = require("../models/maps_model")({ cooler, tribeCrypto, tribesModel });
|
||||
const gamesModel = require('../models/games_model')({ cooler });
|
||||
const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
|
||||
const logsModel = require("../models/logs_model")({ cooler });
|
||||
|
|
@ -1379,6 +1379,7 @@ router
|
|||
return results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
};
|
||||
const tribe = await tribesModel.getTribeById(ctx.params.tribeId);
|
||||
tribesModel.ensureFollowTribeMembers(ctx.params.tribeId).catch(() => {});
|
||||
const uid = getViewerId();
|
||||
const query = { feedFilter: 'TOP', ...ctx.query };
|
||||
if (!tribe.members.includes(uid)) {
|
||||
|
|
|
|||
|
|
@ -3642,17 +3642,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.tribe-card-subtribes {
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin: 6px 0;
|
||||
background: #1e1f23;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tribe-card-members {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
|
@ -3737,26 +3726,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.tribe-parent-box {
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tribe-parent-box h2 {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.tribe-parent-link {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tribe-parent-image {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #444;
|
||||
}
|
||||
|
||||
.tribe-card-meta-row {
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
|
|
@ -4154,42 +4123,49 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.tribe-parent-box {
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.no-border{border:none}
|
||||
.tribe-card-padded{padding:12px 16px}
|
||||
.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px}
|
||||
.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center}
|
||||
.bold{font-weight:bold}
|
||||
.inline-form{display:inline}
|
||||
|
||||
.tribe-parent-box h2 {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.tribe-parent-link {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tribe-parent-image {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #444;
|
||||
}
|
||||
|
||||
.tribe-card-parent {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tribe-parent-card-link {
|
||||
color: #FFA500;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tribe-parent-card-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db}
|
||||
.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6}
|
||||
.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6}
|
||||
.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c}
|
||||
.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c}
|
||||
.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085}
|
||||
.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71}
|
||||
.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71}
|
||||
.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22}
|
||||
.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22}
|
||||
.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c}
|
||||
.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c}
|
||||
.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12}
|
||||
.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12}
|
||||
.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b}
|
||||
.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b}
|
||||
.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db}
|
||||
.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db}
|
||||
.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad}
|
||||
.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400}
|
||||
.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60}
|
||||
.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f}
|
||||
.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6}
|
||||
.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6}
|
||||
.stats-link{color:#007bff;text-decoration:none}
|
||||
.stats-link-break{color:#007bff;text-decoration:none;word-break:break-all}
|
||||
.stats-muted-555{color:#555}
|
||||
.stats-muted-888{color:#888}
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px}
|
||||
.stats-section-h{font-size:18px;color:#555;margin:8px 0;font-weight:600}
|
||||
.stats-list-reset{list-style-type:none;padding:0;margin:0}
|
||||
.stats-mb-16{margin-bottom:16px}
|
||||
.stats-w-100{width:100%}
|
||||
.stats-table{width:100%;border-collapse:collapse}
|
||||
.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px}
|
||||
.stats-h-row{font-size:18px;color:#555;margin:8px 0}
|
||||
|
||||
.comment-submit-btn {
|
||||
width: auto;
|
||||
|
|
|
|||
|
|
@ -428,7 +428,6 @@ a.user-link:focus {
|
|||
.tribe-info-label { color: #2D2D2D !important; background: #FFFFFF !important; }
|
||||
.tribe-info-value { color: #007BFF !important; background: #FFFFFF !important; }
|
||||
.tribe-info-empty { color: #999 !important; }
|
||||
.tribe-card-subtribes { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
|
||||
.tribe-card-members { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
|
||||
.tribe-members-count { color: #FF6F00 !important; }
|
||||
.tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
|
||||
|
|
@ -440,5 +439,3 @@ a.user-link:focus {
|
|||
.tribe-subtribe-link:hover { background: #E0E0E0 !important; }
|
||||
.tribe-parent-image { border-color: #E0E0E0 !important; }
|
||||
.tribe-parent-box { background: #FFFFFF !important; }
|
||||
.tribe-card-parent { background: #F8F8F8 !important; border-color: #E0E0E0 !important; }
|
||||
.tribe-parent-card-link { color: #FF6F00 !important; }
|
||||
|
|
|
|||
|
|
@ -343,7 +343,6 @@ a.user-link:focus {
|
|||
.tribe-info-label { color: #ffa300 !important; background: #1e1f23 !important; }
|
||||
.tribe-info-value { color: #FFD700 !important; background: #1e1f23 !important; }
|
||||
.tribe-info-empty { color: #9aa3b2 !important; }
|
||||
.tribe-card-subtribes { border-color: #444 !important; background: #1e1f23 !important; }
|
||||
.tribe-card-members { border-color: #444 !important; background: #1e1f23 !important; }
|
||||
.tribe-members-count { color: #ffa300 !important; }
|
||||
.tribe-card-actions { border-color: #444 !important; background: #1e1f23 !important; }
|
||||
|
|
@ -355,5 +354,3 @@ a.user-link:focus {
|
|||
.tribe-subtribe-link:hover { background: #333 !important; }
|
||||
.tribe-parent-image { border-color: #ffa300 !important; }
|
||||
.tribe-parent-box { background: #1e1f23 !important; }
|
||||
.tribe-card-parent { background: #1e1f23 !important; border-color: #444 !important; }
|
||||
.tribe-parent-card-link { color: #ffa300 !important; }
|
||||
|
|
|
|||
|
|
@ -439,7 +439,6 @@ a.user-link:focus {
|
|||
.tribe-info-label { color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.tribe-info-value { color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.tribe-info-empty { color: #006600 !important; }
|
||||
.tribe-card-subtribes { border-color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.tribe-card-members { border-color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.tribe-members-count { color: #00FF00 !important; }
|
||||
.tribe-card-actions { border-color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
|
|
@ -451,5 +450,3 @@ a.user-link:focus {
|
|||
.tribe-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
|
||||
.tribe-parent-image { border-color: #00FF00 !important; }
|
||||
.tribe-parent-box { background: #1A1A1A !important; }
|
||||
.tribe-card-parent { background: #1A1A1A !important; border-color: #00FF00 !important; }
|
||||
.tribe-parent-card-link { color: #00FF00 !important; }
|
||||
|
|
|
|||
|
|
@ -474,7 +474,6 @@ a.user-link:focus {
|
|||
.tribe-info-label { color: #B86ADE !important; background: #3C1360 !important; }
|
||||
.tribe-info-value { color: #FFEEDB !important; background: #3C1360 !important; }
|
||||
.tribe-info-empty { color: #8844aa !important; }
|
||||
.tribe-card-subtribes { border-color: #B86ADE !important; background: #2D0B47 !important; }
|
||||
.tribe-card-members { border-color: #B86ADE !important; background: #2D0B47 !important; }
|
||||
.tribe-members-count { color: #FFD600 !important; }
|
||||
.tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !important; }
|
||||
|
|
@ -486,5 +485,3 @@ a.user-link:focus {
|
|||
.tribe-subtribe-link:hover { background: #5A1A85 !important; }
|
||||
.tribe-parent-image { border-color: #B86ADE !important; }
|
||||
.tribe-parent-box { background: #3C1360 !important; }
|
||||
.tribe-card-parent { background: #4B1A72 !important; border-color: #B86ADE !important; }
|
||||
.tribe-parent-card-link { color: #FFD600 !important; }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1657,6 +1657,8 @@ module.exports = {
|
|||
tribeRecentSectionTitle: "Neueste Stämme",
|
||||
tribeTopSectionTitle: "Beliebte Stämme",
|
||||
tribeviewTribeButton: "Stamm besuchen",
|
||||
tribeviewSubTribeButton: "Unter-Stamm besuchen",
|
||||
tribeRootLabel: "WURZEL",
|
||||
tribeDescription: "Stämme in deinem Netzwerk erkunden oder erstellen.",
|
||||
tribeFilterAll: "ALLE",
|
||||
tribeFilterMine: "MEINE",
|
||||
|
|
@ -1851,6 +1853,7 @@ module.exports = {
|
|||
tribeStatusLabel: "Status",
|
||||
tribeSubTribes: "UNTER-STÄMME",
|
||||
tribeSubTribesCreate: "Unter-Stamm Erstellen",
|
||||
tribeSubTribesStrictDenied: "Der strikte Modus des Stammes erlaubt Ihnen nicht, neue Unter-Stämme zu erstellen. Bitte wenden Sie sich an den Administrator.",
|
||||
tribeSubTribesEmpty: "Noch keine Unter-Stämme erstellt.",
|
||||
tribeLarpCreateForbidden: "L.A.R.P.-Stämme können nicht erstellt werden.",
|
||||
tribeLarpUpdateForbidden: "L.A.R.P.-Stämme können nicht aktualisiert werden.",
|
||||
|
|
@ -2867,6 +2870,7 @@ module.exports = {
|
|||
padNoEntries: "Noch keine Einträge.",
|
||||
padAllSectionTitle: "Alle Pads",
|
||||
padMineSectionTitle: "Meine Pads",
|
||||
padsDescription: "Verwalte kollaborative verschlüsselte Texteditoren in deinem Netzwerk.",
|
||||
padRecentSectionTitle: "Aktuelle Pads",
|
||||
padOpenSectionTitle: "Offene Pads",
|
||||
padClosedSectionTitle: "Geschlossene Pads",
|
||||
|
|
@ -3125,6 +3129,26 @@ module.exports = {
|
|||
tribeGovernanceDesc: "Interne Governance dieses Stammes.",
|
||||
tribeGovernanceNoGov: "Keine aktive Regierung",
|
||||
tribeGovernanceNoGovDesc: "Dieser Stamm hat noch keine Regierung gewählt.",
|
||||
tribeGovCardTitle: "Aktuelle Regierung",
|
||||
tribeGovCycleSince: "ZYKLUS SEIT",
|
||||
tribeGovCycleEnd: "ZYKLUS ENDE",
|
||||
tribeGovTimeRemaining: "VERBLEIBENDE ZEIT",
|
||||
tribeGovPopulation: "BEVÖLKERUNG",
|
||||
tribeGovMethod: "METHODE",
|
||||
tribeGovVotesReceived: "ERHALTENE STIMMEN",
|
||||
tribeGovLeader: "ANFÜHRER",
|
||||
tribeGovFilterGovernment: "REGIERUNG",
|
||||
tribeGovFilterCandidatures: "KANDIDATUREN",
|
||||
tribeGovFilterLaws: "GESETZE",
|
||||
tribeGovCandidatureId: "Kandidatur",
|
||||
tribeGovCandidatureMethod: "Methode",
|
||||
tribeGovCandidatureProposeBtn: "Kandidatur veröffentlichen",
|
||||
tribeGovRuleTitle: "Regeltitel",
|
||||
tribeGovRuleBody: "Regelinhalt",
|
||||
tribeGovProposals: "VORSCHLÄGE",
|
||||
tribeGovRevocations: "WIDERRUFE",
|
||||
tribeGovHistorical: "VERLAUF",
|
||||
tribeGovRules: "REGELN",
|
||||
tribeGovernanceAlreadyPublished: "Dieser Stamm hat bereits eine offene Kandidatur.",
|
||||
tribeGovernanceProposeInternal: "Interne Kandidatur vorschlagen",
|
||||
tribeGovernanceInternalCandidatures: "Interne Kandidaturen",
|
||||
|
|
|
|||
|
|
@ -1663,6 +1663,8 @@ module.exports = {
|
|||
tribeRecentSectionTitle: "Recent Tribes",
|
||||
tribeTopSectionTitle: "Popular Tribes",
|
||||
tribeviewTribeButton: "Visit Tribe",
|
||||
tribeviewSubTribeButton: "Visit Sub-Tribe",
|
||||
tribeRootLabel: "ROOT",
|
||||
tribeDescription: "Explore or create tribes on your network.",
|
||||
tribeFilterAll: "ALL",
|
||||
tribeFilterMine: "MINE",
|
||||
|
|
@ -1857,6 +1859,7 @@ module.exports = {
|
|||
tribeStatusLabel: "Status",
|
||||
tribeSubTribes: "SUB-TRIBES",
|
||||
tribeSubTribesCreate: "Create Sub-Tribe",
|
||||
tribeSubTribesStrictDenied: "Tribe strict mode does not allow you to create new sub-tribes. Please contact the administrator.",
|
||||
tribeSubTribesEmpty: "No sub-tribes created, yet.",
|
||||
tribeLarpCreateForbidden: "L.A.R.P. tribes cannot be created.",
|
||||
tribeLarpUpdateForbidden: "L.A.R.P. tribes cannot be updated.",
|
||||
|
|
@ -3148,6 +3151,26 @@ module.exports = {
|
|||
tribeGovernanceDesc: "Internal governance for this tribe. Propose candidatures, debate rules, elect leaders.",
|
||||
tribeGovernanceNoGov: "No active government",
|
||||
tribeGovernanceNoGovDesc: "This tribe has not yet elected a government. Propose candidatures to start the process.",
|
||||
tribeGovCardTitle: "Current Government",
|
||||
tribeGovCycleSince: "CYCLE SINCE",
|
||||
tribeGovCycleEnd: "CYCLE END",
|
||||
tribeGovTimeRemaining: "TIME REMAINING",
|
||||
tribeGovPopulation: "POPULATION",
|
||||
tribeGovMethod: "METHOD",
|
||||
tribeGovVotesReceived: "VOTES RECEIVED",
|
||||
tribeGovLeader: "LEADER",
|
||||
tribeGovFilterGovernment: "GOVERNMENT",
|
||||
tribeGovFilterCandidatures: "CANDIDATURES",
|
||||
tribeGovFilterLaws: "LAWS",
|
||||
tribeGovCandidatureId: "Candidature",
|
||||
tribeGovCandidatureMethod: "Method",
|
||||
tribeGovCandidatureProposeBtn: "Publish Candidature",
|
||||
tribeGovRuleTitle: "Rule Title",
|
||||
tribeGovRuleBody: "Rule Body",
|
||||
tribeGovProposals: "PROPOSALS",
|
||||
tribeGovRevocations: "REVOCATIONS",
|
||||
tribeGovHistorical: "HISTORICAL",
|
||||
tribeGovRules: "RULES",
|
||||
tribeGovernanceAlreadyPublished: "This tribe already has an open candidature in the current global parliament cycle.",
|
||||
tribeGovernanceProposeInternal: "Propose internal candidature",
|
||||
tribeGovernanceInternalCandidatures: "Internal candidatures",
|
||||
|
|
|
|||
|
|
@ -1653,6 +1653,8 @@ module.exports = {
|
|||
tribeRecentSectionTitle: "Tribus Recientes",
|
||||
tribeTopSectionTitle: "Tribus Populares",
|
||||
tribeviewTribeButton: "Visitar Tribu",
|
||||
tribeviewSubTribeButton: "Visitar Sub-Tribu",
|
||||
tribeRootLabel: "RAÍZ",
|
||||
tribeDescription: "Explora o crea tribus en tu red.",
|
||||
tribeFilterAll: "TODOS",
|
||||
tribeFilterMine: "MÍAS",
|
||||
|
|
@ -1847,6 +1849,7 @@ module.exports = {
|
|||
tribeStatusLabel: "Estado",
|
||||
tribeSubTribes: "SUB-TRIBUS",
|
||||
tribeSubTribesCreate: "Crear Sub-Tribu",
|
||||
tribeSubTribesStrictDenied: "El modo estricto de la tribu no te permite generar nuevas sub-tribus. Ponte en contacto con el administrador.",
|
||||
tribeSubTribesEmpty: "No se han creado sub-tribus, aún.",
|
||||
tribeLarpCreateForbidden: "No se pueden crear tribus L.A.R.P.",
|
||||
tribeLarpUpdateForbidden: "No se pueden actualizar tribus L.A.R.P.",
|
||||
|
|
@ -2876,6 +2879,7 @@ module.exports = {
|
|||
padNoEntries: "Sin entradas aún.",
|
||||
padAllSectionTitle: "Todos los Pads",
|
||||
padMineSectionTitle: "Mis Pads",
|
||||
padsDescription: "Gestiona editores de texto cifrados colaborativos en tu red.",
|
||||
padRecentSectionTitle: "Pads Recientes",
|
||||
padOpenSectionTitle: "Pads Abiertos",
|
||||
padClosedSectionTitle: "Pads Cerrados",
|
||||
|
|
@ -3139,6 +3143,26 @@ module.exports = {
|
|||
tribeGovernanceDesc: "Gobierno interno de esta tribu.",
|
||||
tribeGovernanceNoGov: "Sin gobierno activo",
|
||||
tribeGovernanceNoGovDesc: "Esta tribu aún no tiene gobierno. Propón candidaturas para iniciar el proceso.",
|
||||
tribeGovCardTitle: "Gobierno Actual",
|
||||
tribeGovCycleSince: "CICLO INICIO",
|
||||
tribeGovCycleEnd: "CICLO FIN",
|
||||
tribeGovTimeRemaining: "TIEMPO RESTANTE",
|
||||
tribeGovPopulation: "POBLACIÓN",
|
||||
tribeGovMethod: "MÉTODO",
|
||||
tribeGovVotesReceived: "VOTOS RECIBIDOS",
|
||||
tribeGovLeader: "LÍDER",
|
||||
tribeGovFilterGovernment: "GOBIERNO",
|
||||
tribeGovFilterCandidatures: "CANDIDATURAS",
|
||||
tribeGovFilterLaws: "LEYES",
|
||||
tribeGovCandidatureId: "Candidatura",
|
||||
tribeGovCandidatureMethod: "Método",
|
||||
tribeGovCandidatureProposeBtn: "Publicar Candidatura",
|
||||
tribeGovRuleTitle: "Título de Regla",
|
||||
tribeGovRuleBody: "Cuerpo de Regla",
|
||||
tribeGovProposals: "PROPUESTAS",
|
||||
tribeGovRevocations: "REVOCACIONES",
|
||||
tribeGovHistorical: "HISTÓRICO",
|
||||
tribeGovRules: "REGLAS",
|
||||
tribeGovernanceAlreadyPublished: "Esta tribu ya tiene una candidatura abierta en el ciclo actual del parlamento global.",
|
||||
tribeGovernanceProposeInternal: "Proponer candidatura interna",
|
||||
tribeGovernanceInternalCandidatures: "Candidaturas internas",
|
||||
|
|
|
|||
|
|
@ -1620,6 +1620,8 @@ module.exports = {
|
|||
tribeRecentSectionTitle: "Tribu Berriak",
|
||||
tribeTopSectionTitle: "Tribu Gorenak",
|
||||
tribeviewTribeButton: "Tribua Bisitatu",
|
||||
tribeviewSubTribeButton: "Azpi-Tribua Bisitatu",
|
||||
tribeRootLabel: "ERROA",
|
||||
tribeDescription: "Aurkitu edo sortu tribuak zure sarean.",
|
||||
tribeFilterAll: "GUZTIAK",
|
||||
tribeFilterMine: "NEUREAK",
|
||||
|
|
@ -1814,6 +1816,7 @@ module.exports = {
|
|||
tribeStatusLabel: "Egoera",
|
||||
tribeSubTribes: "AZPI-TRIBUAK",
|
||||
tribeSubTribesCreate: "Azpi-Tribua Sortu",
|
||||
tribeSubTribesStrictDenied: "Tribuaren modu zorrotzak ez dizu azpi-tribu berriak sortzen uzten. Jarri harremanetan administratzailearekin.",
|
||||
tribeSubTribesEmpty: "Ez da azpi-triburik sortu, oraindik.",
|
||||
tribeLarpCreateForbidden: "L.A.R.P. tribuak ezin dira sortu.",
|
||||
tribeLarpUpdateForbidden: "L.A.R.P. tribuak ezin dira eguneratu.",
|
||||
|
|
@ -2837,6 +2840,7 @@ module.exports = {
|
|||
padNoEntries: "Oraindik sarrerarik ez.",
|
||||
padAllSectionTitle: "Pad Guztiak",
|
||||
padMineSectionTitle: "Nire Padak",
|
||||
padsDescription: "Kudeatu zure sareko enkriptatutako testu-editore kolaboratiboak.",
|
||||
padRecentSectionTitle: "Azken Padak",
|
||||
padOpenSectionTitle: "Pad Irekiak",
|
||||
padClosedSectionTitle: "Pad Itxiak",
|
||||
|
|
@ -3099,6 +3103,26 @@ module.exports = {
|
|||
tribeGovernanceDesc: "Tribu honen barne gobernantza.",
|
||||
tribeGovernanceNoGov: "Ez dago gobernu aktiborik",
|
||||
tribeGovernanceNoGovDesc: "Tribu honek ez du oraindik gobernurik aukeratu.",
|
||||
tribeGovCardTitle: "Egungo Gobernua",
|
||||
tribeGovCycleSince: "ZIKLOAREN HASIERA",
|
||||
tribeGovCycleEnd: "ZIKLOAREN AMAIERA",
|
||||
tribeGovTimeRemaining: "GERATZEN DEN DENBORA",
|
||||
tribeGovPopulation: "BIZTANLERIA",
|
||||
tribeGovMethod: "METODOA",
|
||||
tribeGovVotesReceived: "JASOTAKO BOTOAK",
|
||||
tribeGovLeader: "BURUZAGIA",
|
||||
tribeGovFilterGovernment: "GOBERNUA",
|
||||
tribeGovFilterCandidatures: "HAUTAGAITZAK",
|
||||
tribeGovFilterLaws: "LEGEAK",
|
||||
tribeGovCandidatureId: "Hautagaitza",
|
||||
tribeGovCandidatureMethod: "Metodoa",
|
||||
tribeGovCandidatureProposeBtn: "Hautagaitza argitaratu",
|
||||
tribeGovRuleTitle: "Arauaren izenburua",
|
||||
tribeGovRuleBody: "Arauaren edukia",
|
||||
tribeGovProposals: "PROPOSAMENAK",
|
||||
tribeGovRevocations: "EZEZTAPENAK",
|
||||
tribeGovHistorical: "HISTORIKOA",
|
||||
tribeGovRules: "ARAUAK",
|
||||
tribeGovernanceAlreadyPublished: "Tribu honek jada hautagai ireki bat du.",
|
||||
tribeGovernanceProposeInternal: "Proposatu barne hautagaia",
|
||||
tribeGovernanceInternalCandidatures: "Barne hautagaiak",
|
||||
|
|
|
|||
|
|
@ -1645,6 +1645,8 @@ module.exports = {
|
|||
tribeRecentSectionTitle: "Tribus récentes",
|
||||
tribeTopSectionTitle: "Tribus populaires",
|
||||
tribeviewTribeButton: "Visiter la tribu",
|
||||
tribeviewSubTribeButton: "Visiter la sous-tribu",
|
||||
tribeRootLabel: "RACINE",
|
||||
tribeDescription: "Explorez ou créez des tribus dans votre réseau.",
|
||||
tribeFilterAll: "TOUS",
|
||||
tribeFilterMine: "MIENS",
|
||||
|
|
@ -1839,6 +1841,7 @@ module.exports = {
|
|||
tribeStatusLabel: "Statut",
|
||||
tribeSubTribes: "SOUS-TRIBUS",
|
||||
tribeSubTribesCreate: "Créer Sous-Tribu",
|
||||
tribeSubTribesStrictDenied: "Le mode strict de la tribu ne vous permet pas de créer de nouvelles sous-tribus. Veuillez contacter l'administrateur.",
|
||||
tribeSubTribesEmpty: "Aucune sous-tribu créée, pour l'instant.",
|
||||
tribeLarpCreateForbidden: "Les tribus L.A.R.P. ne peuvent pas être créées.",
|
||||
tribeLarpUpdateForbidden: "Les tribus L.A.R.P. ne peuvent pas être mises à jour.",
|
||||
|
|
@ -2865,6 +2868,7 @@ module.exports = {
|
|||
padNoEntries: "Aucune entrée pour l'instant.",
|
||||
padAllSectionTitle: "Tous les Pads",
|
||||
padMineSectionTitle: "Mes Pads",
|
||||
padsDescription: "Gérez des éditeurs de texte chiffrés collaboratifs dans votre réseau.",
|
||||
padRecentSectionTitle: "Pads Récents",
|
||||
padOpenSectionTitle: "Pads Ouverts",
|
||||
padClosedSectionTitle: "Pads Fermés",
|
||||
|
|
@ -3127,6 +3131,26 @@ module.exports = {
|
|||
tribeGovernanceDesc: "Gouvernance interne de cette tribu.",
|
||||
tribeGovernanceNoGov: "Pas de gouvernement actif",
|
||||
tribeGovernanceNoGovDesc: "Cette tribu n'a pas encore de gouvernement.",
|
||||
tribeGovCardTitle: "Gouvernement Actuel",
|
||||
tribeGovCycleSince: "DÉBUT DU CYCLE",
|
||||
tribeGovCycleEnd: "FIN DU CYCLE",
|
||||
tribeGovTimeRemaining: "TEMPS RESTANT",
|
||||
tribeGovPopulation: "POPULATION",
|
||||
tribeGovMethod: "MÉTHODE",
|
||||
tribeGovVotesReceived: "VOTES REÇUS",
|
||||
tribeGovLeader: "DIRIGEANT",
|
||||
tribeGovFilterGovernment: "GOUVERNEMENT",
|
||||
tribeGovFilterCandidatures: "CANDIDATURES",
|
||||
tribeGovFilterLaws: "LOIS",
|
||||
tribeGovCandidatureId: "Candidature",
|
||||
tribeGovCandidatureMethod: "Méthode",
|
||||
tribeGovCandidatureProposeBtn: "Publier la candidature",
|
||||
tribeGovRuleTitle: "Titre de la règle",
|
||||
tribeGovRuleBody: "Corps de la règle",
|
||||
tribeGovProposals: "PROPOSITIONS",
|
||||
tribeGovRevocations: "RÉVOCATIONS",
|
||||
tribeGovHistorical: "HISTORIQUE",
|
||||
tribeGovRules: "RÈGLES",
|
||||
tribeGovernanceAlreadyPublished: "Cette tribu a déjà une candidature ouverte dans le cycle actuel.",
|
||||
tribeGovernanceProposeInternal: "Proposer une candidature interne",
|
||||
tribeGovernanceInternalCandidatures: "Candidatures internes",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1658,6 +1658,8 @@ module.exports = {
|
|||
tribeRecentSectionTitle: "Tribù recenti",
|
||||
tribeTopSectionTitle: "Tribù popolari",
|
||||
tribeviewTribeButton: "Visita tribù",
|
||||
tribeviewSubTribeButton: "Visita Sotto-Tribù",
|
||||
tribeRootLabel: "RADICE",
|
||||
tribeDescription: "Esplora o crea tribù nella tua rete.",
|
||||
tribeFilterAll: "TUTTE",
|
||||
tribeFilterMine: "MIE",
|
||||
|
|
@ -1852,6 +1854,7 @@ module.exports = {
|
|||
tribeStatusLabel: "Stato",
|
||||
tribeSubTribes: "SOTTO-TRIBÙ",
|
||||
tribeSubTribesCreate: "Crea Sotto-Tribù",
|
||||
tribeSubTribesStrictDenied: "La modalità rigida della tribù non ti consente di creare nuove sotto-tribù. Si prega di contattare l'amministratore.",
|
||||
tribeSubTribesEmpty: "Nessuna sotto-tribù creata.",
|
||||
tribeLarpCreateForbidden: "Le tribù L.A.R.P. non possono essere create.",
|
||||
tribeLarpUpdateForbidden: "Le tribù L.A.R.P. non possono essere aggiornate.",
|
||||
|
|
@ -2868,6 +2871,7 @@ module.exports = {
|
|||
padNoEntries: "Nessuna voce ancora.",
|
||||
padAllSectionTitle: "Tutti i Pad",
|
||||
padMineSectionTitle: "I Miei Pad",
|
||||
padsDescription: "Gestisci editor di testo collaborativi cifrati nella tua rete.",
|
||||
padRecentSectionTitle: "Pad Recenti",
|
||||
padOpenSectionTitle: "Pad Aperti",
|
||||
padClosedSectionTitle: "Pad Chiusi",
|
||||
|
|
@ -3130,6 +3134,26 @@ module.exports = {
|
|||
tribeGovernanceDesc: "Governance interna di questa tribù.",
|
||||
tribeGovernanceNoGov: "Nessun governo attivo",
|
||||
tribeGovernanceNoGovDesc: "Questa tribù non ha ancora eletto un governo.",
|
||||
tribeGovCardTitle: "Governo Attuale",
|
||||
tribeGovCycleSince: "INIZIO CICLO",
|
||||
tribeGovCycleEnd: "FINE CICLO",
|
||||
tribeGovTimeRemaining: "TEMPO RIMANENTE",
|
||||
tribeGovPopulation: "POPOLAZIONE",
|
||||
tribeGovMethod: "METODO",
|
||||
tribeGovVotesReceived: "VOTI RICEVUTI",
|
||||
tribeGovLeader: "LEADER",
|
||||
tribeGovFilterGovernment: "GOVERNO",
|
||||
tribeGovFilterCandidatures: "CANDIDATURE",
|
||||
tribeGovFilterLaws: "LEGGI",
|
||||
tribeGovCandidatureId: "Candidatura",
|
||||
tribeGovCandidatureMethod: "Metodo",
|
||||
tribeGovCandidatureProposeBtn: "Pubblica candidatura",
|
||||
tribeGovRuleTitle: "Titolo della regola",
|
||||
tribeGovRuleBody: "Corpo della regola",
|
||||
tribeGovProposals: "PROPOSTE",
|
||||
tribeGovRevocations: "REVOCHE",
|
||||
tribeGovHistorical: "STORICO",
|
||||
tribeGovRules: "REGOLE",
|
||||
tribeGovernanceAlreadyPublished: "Questa tribù ha già una candidatura aperta.",
|
||||
tribeGovernanceProposeInternal: "Proponi candidatura interna",
|
||||
tribeGovernanceInternalCandidatures: "Candidature interne",
|
||||
|
|
|
|||
|
|
@ -1658,6 +1658,8 @@ module.exports = {
|
|||
tribeRecentSectionTitle: "Tribos recentes",
|
||||
tribeTopSectionTitle: "Tribos populares",
|
||||
tribeviewTribeButton: "Visitar tribo",
|
||||
tribeviewSubTribeButton: "Visitar sub-tribo",
|
||||
tribeRootLabel: "RAIZ",
|
||||
tribeDescription: "Explora ou cria tribos na tua rede.",
|
||||
tribeFilterAll: "TODOS",
|
||||
tribeFilterMine: "MEUS",
|
||||
|
|
@ -1852,6 +1854,7 @@ module.exports = {
|
|||
tribeStatusLabel: "Estado",
|
||||
tribeSubTribes: "SUB-TRIBOS",
|
||||
tribeSubTribesCreate: "Criar Sub-Tribo",
|
||||
tribeSubTribesStrictDenied: "O modo estrito da tribo não permite criar novas sub-tribos. Por favor, entre em contato com o administrador.",
|
||||
tribeSubTribesEmpty: "Ainda sem sub-tribos criadas.",
|
||||
tribeLarpCreateForbidden: "Tribos L.A.R.P. não podem ser criadas.",
|
||||
tribeLarpUpdateForbidden: "Tribos L.A.R.P. não podem ser atualizadas.",
|
||||
|
|
@ -2868,6 +2871,7 @@ module.exports = {
|
|||
padNoEntries: "Sem entradas ainda.",
|
||||
padAllSectionTitle: "Todos os Pads",
|
||||
padMineSectionTitle: "Os Meus Pads",
|
||||
padsDescription: "Gere editores de texto colaborativos cifrados na tua rede.",
|
||||
padRecentSectionTitle: "Pads Recentes",
|
||||
padOpenSectionTitle: "Pads Abertos",
|
||||
padClosedSectionTitle: "Pads Fechados",
|
||||
|
|
@ -3130,6 +3134,26 @@ module.exports = {
|
|||
tribeGovernanceDesc: "Governança interna desta tribo.",
|
||||
tribeGovernanceNoGov: "Sem governo ativo",
|
||||
tribeGovernanceNoGovDesc: "Esta tribo ainda não elegeu governo.",
|
||||
tribeGovCardTitle: "Governo Atual",
|
||||
tribeGovCycleSince: "INÍCIO DO CICLO",
|
||||
tribeGovCycleEnd: "FIM DO CICLO",
|
||||
tribeGovTimeRemaining: "TEMPO RESTANTE",
|
||||
tribeGovPopulation: "POPULAÇÃO",
|
||||
tribeGovMethod: "MÉTODO",
|
||||
tribeGovVotesReceived: "VOTOS RECEBIDOS",
|
||||
tribeGovLeader: "LÍDER",
|
||||
tribeGovFilterGovernment: "GOVERNO",
|
||||
tribeGovFilterCandidatures: "CANDIDATURAS",
|
||||
tribeGovFilterLaws: "LEIS",
|
||||
tribeGovCandidatureId: "Candidatura",
|
||||
tribeGovCandidatureMethod: "Método",
|
||||
tribeGovCandidatureProposeBtn: "Publicar candidatura",
|
||||
tribeGovRuleTitle: "Título da regra",
|
||||
tribeGovRuleBody: "Corpo da regra",
|
||||
tribeGovProposals: "PROPOSTAS",
|
||||
tribeGovRevocations: "REVOGAÇÕES",
|
||||
tribeGovHistorical: "HISTÓRICO",
|
||||
tribeGovRules: "REGRAS",
|
||||
tribeGovernanceAlreadyPublished: "Esta tribo já tem candidatura aberta.",
|
||||
tribeGovernanceProposeInternal: "Propor candidatura interna",
|
||||
tribeGovernanceInternalCandidatures: "Candidaturas internas",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,7 @@ const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
|
|||
return out.sort((a, b) => a.getTime() - b.getTime())
|
||||
}
|
||||
|
||||
module.exports = ({ cooler, pmModel }) => {
|
||||
module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
||||
let ssb
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
|
||||
|
|
@ -39,24 +39,38 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
|
||||
)
|
||||
|
||||
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null
|
||||
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c
|
||||
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c
|
||||
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}
|
||||
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {}
|
||||
|
||||
const buildIndex = (messages) => {
|
||||
const tomb = new Set()
|
||||
const nodes = new Map()
|
||||
const parent = new Map()
|
||||
const child = new Map()
|
||||
const authorByKey = new Map()
|
||||
const tombRequests = []
|
||||
|
||||
for (const m of messages) {
|
||||
const k = m.key
|
||||
const v = m.value || {}
|
||||
const c = v.content
|
||||
if (!c) continue
|
||||
if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
|
||||
if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
|
||||
if (c.type === "calendar") {
|
||||
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
|
||||
authorByKey.set(k, v.author)
|
||||
if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of tombRequests) {
|
||||
const targetAuthor = authorByKey.get(t.target)
|
||||
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
|
||||
}
|
||||
|
||||
const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
|
||||
const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
|
||||
|
||||
|
|
@ -71,21 +85,24 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const buildCalendar = (node, rootId) => {
|
||||
const c = node.c || {}
|
||||
if (c.type !== "calendar") return null
|
||||
const undec = c.encryptedPayload && c._decrypted === false
|
||||
return {
|
||||
key: node.key,
|
||||
rootId,
|
||||
title: safeText(c.title),
|
||||
title: undec ? "" : safeText(c.title),
|
||||
status: c.status || "OPEN",
|
||||
deadline: c.deadline || "",
|
||||
deadline: undec ? "" : (c.deadline || ""),
|
||||
tags: Array.isArray(c.tags) ? c.tags : [],
|
||||
author: c.author || node.author,
|
||||
participants: Array.isArray(c.participants) ? c.participants : [],
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
||||
updatedAt: c.updatedAt || null,
|
||||
tribeId: c.tribeId || null
|
||||
tribeId: c.tribeId || null,
|
||||
encrypted: !!undec
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const isClosed = (calendar) => {
|
||||
if (calendar.status === "CLOSED") return true
|
||||
if (!calendar.deadline) return false
|
||||
|
|
@ -126,7 +143,7 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
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")
|
||||
|
||||
const content = {
|
||||
let content = {
|
||||
type: "calendar",
|
||||
title: safeText(title),
|
||||
status: validStatus,
|
||||
|
|
@ -138,6 +155,7 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
updatedAt: now,
|
||||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
content = await encryptIfTribe(content)
|
||||
|
||||
const calMsg = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
|
|
@ -148,30 +166,36 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
|
||||
const allDateMsgs = []
|
||||
for (const d of dates) {
|
||||
const dateMsg = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({
|
||||
let dateContent = {
|
||||
type: "calendarDate",
|
||||
calendarId,
|
||||
date: d.toISOString(),
|
||||
label: safeText(firstDateLabel),
|
||||
author: userId,
|
||||
createdAt: new Date().toISOString()
|
||||
}, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
createdAt: new Date().toISOString(),
|
||||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
dateContent = await encryptIfTribe(dateContent)
|
||||
const dateMsg = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
allDateMsgs.push(dateMsg)
|
||||
}
|
||||
|
||||
if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
|
||||
for (const dateMsg of allDateMsgs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({
|
||||
let noteContent = {
|
||||
type: "calendarNote",
|
||||
calendarId,
|
||||
dateId: dateMsg.key,
|
||||
text: safeText(firstNote),
|
||||
author: userId,
|
||||
createdAt: new Date().toISOString()
|
||||
}, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
createdAt: new Date().toISOString(),
|
||||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
noteContent = await encryptIfTribe(noteContent)
|
||||
await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -183,90 +207,112 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const tipId = await this.resolveCurrentId(id)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.get(tipId, (err, item) => {
|
||||
if (err || !item?.content) return reject(new Error("Calendar not found"))
|
||||
if (item.content.author !== userId) return reject(new Error("Not the author"))
|
||||
const c = item.content
|
||||
const updated = {
|
||||
...c,
|
||||
title: data.title !== undefined ? safeText(data.title) : c.title,
|
||||
status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
|
||||
deadline: data.deadline !== undefined ? data.deadline : c.deadline,
|
||||
tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
|
||||
const item = await new Promise((resolve, reject) => {
|
||||
ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it))
|
||||
})
|
||||
if (!item || !item.content) throw new Error("Calendar not found")
|
||||
const oldDec = await decryptIfTribe(item.content)
|
||||
assertReadable(oldDec, "Calendar")
|
||||
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
|
||||
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 || ""),
|
||||
tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
|
||||
author: oldDec.author || userId,
|
||||
participants: oldDec.participants || [userId],
|
||||
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
|
||||
createdAt: oldDec.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
replaces: tipId
|
||||
}
|
||||
updated = await encryptIfTribe(updated)
|
||||
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
ssbClient.publish(tombstone, (e1) => {
|
||||
if (e1) return reject(e1)
|
||||
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
|
||||
})
|
||||
})
|
||||
})
|
||||
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
|
||||
return result
|
||||
},
|
||||
|
||||
async deleteCalendarById(id) {
|
||||
const tipId = await this.resolveCurrentId(id)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.get(tipId, (err, item) => {
|
||||
if (err || !item?.content) return reject(new Error("Calendar not found"))
|
||||
if (item.content.author !== userId) return reject(new Error("Not the author"))
|
||||
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)
|
||||
assertReadable(dec, "Calendar")
|
||||
if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
|
||||
})
|
||||
})
|
||||
return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
|
||||
},
|
||||
|
||||
async joinCalendar(calendarId) {
|
||||
const tipId = await this.resolveCurrentId(calendarId)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.get(tipId, (err, item) => {
|
||||
if (err || !item?.content) return reject(new Error("Calendar not found"))
|
||||
const c = item.content
|
||||
const participants = Array.isArray(c.participants) ? c.participants : []
|
||||
if (participants.includes(userId)) return resolve()
|
||||
const updated = { ...c, participants: [...participants, userId], updatedAt: new Date().toISOString(), replaces: tipId }
|
||||
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)
|
||||
assertReadable(dec, "Calendar")
|
||||
const participants = Array.isArray(dec.participants) ? dec.participants : []
|
||||
if (participants.includes(userId)) return
|
||||
let updated = {
|
||||
type: "calendar",
|
||||
title: dec.title || "",
|
||||
status: dec.status || "OPEN",
|
||||
deadline: dec.deadline || "",
|
||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||
author: dec.author,
|
||||
participants: [...participants, userId],
|
||||
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
|
||||
createdAt: dec.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
replaces: tipId
|
||||
}
|
||||
updated = await encryptIfTribe(updated)
|
||||
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
ssbClient.publish(tombstone, (e1) => {
|
||||
if (e1) return reject(e1)
|
||||
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
|
||||
})
|
||||
})
|
||||
})
|
||||
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
|
||||
return result
|
||||
},
|
||||
|
||||
async leaveCalendar(calendarId) {
|
||||
const tipId = await this.resolveCurrentId(calendarId)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.get(tipId, (err, item) => {
|
||||
if (err || !item?.content) return reject(new Error("Calendar not found"))
|
||||
const c = item.content
|
||||
if (c.author === userId) return reject(new Error("Author cannot leave"))
|
||||
const participants = Array.isArray(c.participants) ? c.participants : []
|
||||
if (!participants.includes(userId)) return resolve()
|
||||
const updated = { ...c, participants: participants.filter(p => p !== userId), updatedAt: new Date().toISOString(), replaces: tipId }
|
||||
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)
|
||||
assertReadable(dec, "Calendar")
|
||||
if ((dec.author || item.content.author) === userId) throw new Error("Author cannot leave")
|
||||
const participants = Array.isArray(dec.participants) ? dec.participants : []
|
||||
if (!participants.includes(userId)) return
|
||||
let updated = {
|
||||
type: "calendar",
|
||||
title: dec.title || "",
|
||||
status: dec.status || "OPEN",
|
||||
deadline: dec.deadline || "",
|
||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||
author: dec.author,
|
||||
participants: participants.filter(p => p !== userId),
|
||||
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
|
||||
createdAt: dec.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
replaces: tipId
|
||||
}
|
||||
updated = await encryptIfTribe(updated)
|
||||
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
ssbClient.publish(tombstone, (e1) => {
|
||||
if (e1) return reject(e1)
|
||||
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
|
||||
})
|
||||
})
|
||||
})
|
||||
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
|
||||
return result
|
||||
},
|
||||
|
||||
async getCalendarById(id) {
|
||||
const ssbClient = await openSsb()
|
||||
const messages = await readAll(ssbClient)
|
||||
const idx = buildIndex(messages)
|
||||
await decryptIndexNodes(idx)
|
||||
let tip = id
|
||||
while (idx.child.has(tip)) tip = idx.child.get(tip)
|
||||
if (idx.tomb.has(tip)) return null
|
||||
|
|
@ -285,6 +331,7 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const uid = viewerId || ssbClient.id
|
||||
const messages = await readAll(ssbClient)
|
||||
const idx = buildIndex(messages)
|
||||
await decryptIndexNodes(idx)
|
||||
const items = []
|
||||
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
|
||||
if (idx.tomb.has(tipId)) continue
|
||||
|
|
@ -319,15 +366,18 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
|
||||
const allMsgs = []
|
||||
for (const d of dates) {
|
||||
const msg = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish({
|
||||
let dateContent = {
|
||||
type: "calendarDate",
|
||||
calendarId: rootId,
|
||||
date: d.toISOString(),
|
||||
label: safeText(label),
|
||||
author: userId,
|
||||
createdAt: new Date().toISOString()
|
||||
}, (err, m) => err ? reject(err) : resolve(m))
|
||||
createdAt: new Date().toISOString(),
|
||||
...(cal.tribeId ? { tribeId: cal.tribeId } : {})
|
||||
}
|
||||
dateContent = await encryptIfTribe(dateContent)
|
||||
const msg = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
|
||||
})
|
||||
allMsgs.push(msg)
|
||||
}
|
||||
|
|
@ -338,10 +388,15 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const rootId = await this.resolveRootId(calendarId)
|
||||
const ssbClient = await openSsb()
|
||||
const messages = await readAll(ssbClient)
|
||||
const authorByKey = new Map()
|
||||
for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
|
||||
const tombstoned = new Set()
|
||||
for (const m of messages) {
|
||||
const c = (m.value || {}).content
|
||||
if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
|
||||
if (c && c.type === "tombstone" && c.target) {
|
||||
const targetAuthor = authorByKey.get(c.target)
|
||||
if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
|
||||
}
|
||||
}
|
||||
const dates = []
|
||||
for (const m of messages) {
|
||||
|
|
@ -350,13 +405,19 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const c = v.content
|
||||
if (!c || c.type !== "calendarDate") continue
|
||||
if (c.calendarId !== rootId) continue
|
||||
let dec = c
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
||||
dec = r && !r._undecryptable ? r : c
|
||||
if (r && r._undecryptable) continue
|
||||
}
|
||||
dates.push({
|
||||
key: m.key,
|
||||
calendarId: c.calendarId,
|
||||
date: c.date,
|
||||
label: c.label || "",
|
||||
author: c.author || v.author,
|
||||
createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
|
||||
calendarId: dec.calendarId || c.calendarId,
|
||||
date: dec.date,
|
||||
label: dec.label || "",
|
||||
author: dec.author || v.author,
|
||||
createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString()
|
||||
})
|
||||
}
|
||||
dates.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
|
|
@ -370,10 +431,15 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const cal = await this.getCalendarById(rootId)
|
||||
if (!cal) throw new Error("Calendar not found")
|
||||
const messages = await readAll(ssbClient)
|
||||
const authorByKey = new Map()
|
||||
for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
|
||||
const tombstoned = new Set()
|
||||
for (const m of messages) {
|
||||
const c = (m.value || {}).content
|
||||
if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
|
||||
if (c && c.type === "tombstone" && c.target) {
|
||||
const targetAuthor = authorByKey.get(c.target)
|
||||
if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
|
||||
}
|
||||
}
|
||||
let dateAuthor = null
|
||||
for (const m of messages) {
|
||||
|
|
@ -381,7 +447,12 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const c = (m.value || {}).content
|
||||
if (!c || c.type !== "calendarDate") continue
|
||||
if (tombstoned.has(m.key)) break
|
||||
dateAuthor = c.author || (m.value || {}).author
|
||||
let dec = c
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
||||
if (r && !r._undecryptable) dec = r
|
||||
}
|
||||
dateAuthor = dec.author || (m.value || {}).author
|
||||
break
|
||||
}
|
||||
if (!dateAuthor) throw new Error("Date not found")
|
||||
|
|
@ -407,38 +478,46 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const cal = await this.getCalendarById(rootId)
|
||||
if (!cal) throw new Error("Calendar not found")
|
||||
if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes")
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish({
|
||||
let noteContent = {
|
||||
type: "calendarNote",
|
||||
calendarId: rootId,
|
||||
dateId,
|
||||
text: safeText(text),
|
||||
author: userId,
|
||||
createdAt: new Date().toISOString()
|
||||
}, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
createdAt: new Date().toISOString(),
|
||||
...(cal.tribeId ? { tribeId: cal.tribeId } : {})
|
||||
}
|
||||
noteContent = await encryptIfTribe(noteContent)
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
},
|
||||
|
||||
async deleteNote(noteId) {
|
||||
const ssbClient = await openSsb()
|
||||
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")
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.get(noteId, (err, item) => {
|
||||
if (err || !item?.content) return reject(new Error("Note not found"))
|
||||
if (item.content.author !== userId) return reject(new Error("Not the author"))
|
||||
ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async getNotesForDate(calendarId, dateId) {
|
||||
const rootId = await this.resolveRootId(calendarId)
|
||||
const ssbClient = await openSsb()
|
||||
const messages = await readAll(ssbClient)
|
||||
const authorByKey = new Map()
|
||||
for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
|
||||
const tombstoned = new Set()
|
||||
for (const m of messages) {
|
||||
const c = (m.value || {}).content
|
||||
if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
|
||||
if (c && c.type === "tombstone" && c.target) {
|
||||
const targetAuthor = authorByKey.get(c.target)
|
||||
if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
|
||||
}
|
||||
}
|
||||
const notes = []
|
||||
for (const m of messages) {
|
||||
|
|
@ -447,13 +526,19 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
if (!c || c.type !== "calendarNote") continue
|
||||
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 (r && !r._undecryptable) dec = r
|
||||
else continue
|
||||
}
|
||||
notes.push({
|
||||
key: m.key,
|
||||
calendarId: c.calendarId,
|
||||
dateId: c.dateId,
|
||||
text: c.text || "",
|
||||
author: c.author || v.author,
|
||||
createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
|
||||
calendarId: dec.calendarId || c.calendarId,
|
||||
dateId: dec.dateId || c.dateId,
|
||||
text: dec.text || "",
|
||||
author: dec.author || v.author,
|
||||
createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString()
|
||||
})
|
||||
}
|
||||
notes.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
|
|
@ -473,10 +558,15 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
sentMarkers.add(`${c.calendarId}::${c.dateId}`)
|
||||
}
|
||||
|
||||
const authorByKey = new Map()
|
||||
for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
|
||||
const tombstoned = new Set()
|
||||
for (const m of messages) {
|
||||
const c = (m.value || {}).content
|
||||
if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
|
||||
if (c && c.type === "tombstone" && c.target) {
|
||||
const targetAuthor = authorByKey.get(c.target)
|
||||
if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
|
||||
}
|
||||
}
|
||||
|
||||
const dueByCalendar = new Map()
|
||||
|
|
@ -485,9 +575,15 @@ module.exports = ({ cooler, pmModel }) => {
|
|||
const v = m.value || {}
|
||||
const c = v.content
|
||||
if (!c || c.type !== "calendarDate") continue
|
||||
if (new Date(c.date).getTime() > now) continue
|
||||
let dec = c
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
||||
if (!r || r._undecryptable) continue
|
||||
dec = r
|
||||
}
|
||||
if (!dec.date || new Date(dec.date).getTime() > now) continue
|
||||
if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
|
||||
const entry = { key: m.key, calendarId: c.calendarId, date: c.date, label: c.label || "" }
|
||||
const entry = { key: m.key, calendarId: c.calendarId, date: dec.date, label: dec.label || "" }
|
||||
const list = dueByCalendar.get(c.calendarId) || []
|
||||
list.push(entry)
|
||||
dueByCalendar.set(c.calendarId, list)
|
||||
|
|
|
|||
|
|
@ -42,21 +42,30 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const parent = new Map()
|
||||
const child = new Map()
|
||||
const msgNodes = new Map()
|
||||
const authorByKey = new Map()
|
||||
const tombRequests = []
|
||||
|
||||
for (const m of messages) {
|
||||
const k = m.key
|
||||
const v = m.value || {}
|
||||
const c = v.content
|
||||
if (!c) continue
|
||||
if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
|
||||
if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
|
||||
if (c.type === "chat") {
|
||||
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
|
||||
authorByKey.set(k, v.author)
|
||||
if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
|
||||
} else if (c.type === "chatMessage") {
|
||||
msgNodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
|
||||
authorByKey.set(k, v.author)
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of tombRequests) {
|
||||
const targetAuthor = authorByKey.get(t.target)
|
||||
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
|
||||
}
|
||||
|
||||
const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
|
||||
const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const normalizeTags = (raw) => {
|
|||
|
||||
const ALLOWED_MAP_TYPES = new Set(["OPEN", "CLOSED", "SINGLE"]);
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
let ssb;
|
||||
|
||||
const openSsb = async () => {
|
||||
|
|
@ -21,6 +21,12 @@ module.exports = ({ cooler }) => {
|
|||
return ssb;
|
||||
};
|
||||
|
||||
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
|
||||
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
|
||||
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
|
||||
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
|
||||
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
|
||||
|
||||
const getAllMessages = async (ssbClient) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pull(
|
||||
|
|
@ -30,8 +36,8 @@ module.exports = ({ cooler }) => {
|
|||
});
|
||||
|
||||
const getMsg = async (ssbClient, key) =>
|
||||
new Promise((resolve, reject) => {
|
||||
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
|
||||
new Promise((resolve) => {
|
||||
ssbClient.get(key, (err, msg) => (err ? resolve(null) : resolve(msg)));
|
||||
});
|
||||
|
||||
const buildIndex = (messages) => {
|
||||
|
|
@ -40,6 +46,9 @@ module.exports = ({ cooler }) => {
|
|||
const parent = new Map();
|
||||
const child = new Map();
|
||||
const markers = new Map();
|
||||
const rawMarkers = new Map();
|
||||
const authorByKey = new Map();
|
||||
const tombRequests = [];
|
||||
|
||||
for (const m of messages) {
|
||||
const k = m.key;
|
||||
|
|
@ -48,22 +57,20 @@ module.exports = ({ cooler }) => {
|
|||
if (!c) continue;
|
||||
|
||||
if (c.type === "tombstone" && c.target) {
|
||||
tomb.add(c.target);
|
||||
tombRequests.push({ target: c.target, author: v.author });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.type === "mapMarker") {
|
||||
const mapId = c.mapId;
|
||||
if (mapId) {
|
||||
if (!markers.has(mapId)) markers.set(mapId, []);
|
||||
markers.get(mapId).push({
|
||||
authorByKey.set(k, v.author);
|
||||
if (!rawMarkers.has(mapId)) rawMarkers.set(mapId, []);
|
||||
rawMarkers.get(mapId).push({
|
||||
key: k,
|
||||
lat: parseFloat(c.lat) || 0,
|
||||
lng: parseFloat(c.lng) || 0,
|
||||
label: c.label || "",
|
||||
image: c.image || "",
|
||||
author: v.author || c.author,
|
||||
createdAt: c.createdAt || new Date(v.timestamp || m.timestamp || 0).toISOString()
|
||||
ts: v.timestamp || m.timestamp || 0,
|
||||
c,
|
||||
envAuthor: v.author
|
||||
});
|
||||
}
|
||||
continue;
|
||||
|
|
@ -73,6 +80,7 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const ts = v.timestamp || m.timestamp || 0;
|
||||
nodes.set(k, { key: k, ts, c });
|
||||
authorByKey.set(k, v.author);
|
||||
|
||||
if (c.replaces) {
|
||||
parent.set(k, c.replaces);
|
||||
|
|
@ -92,6 +100,11 @@ module.exports = ({ cooler }) => {
|
|||
return cur;
|
||||
};
|
||||
|
||||
for (const t of tombRequests) {
|
||||
const targetAuthor = authorByKey.get(t.target);
|
||||
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target);
|
||||
}
|
||||
|
||||
const roots = new Set();
|
||||
for (const id of nodes.keys()) roots.add(rootOf(id));
|
||||
|
||||
|
|
@ -101,24 +114,51 @@ module.exports = ({ cooler }) => {
|
|||
const forward = new Map();
|
||||
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
|
||||
|
||||
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward, markers };
|
||||
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward, markers, rawMarkers };
|
||||
};
|
||||
|
||||
const expandMarkers = async (idx) => {
|
||||
for (const [mapId, raws] of idx.rawMarkers.entries()) {
|
||||
const list = [];
|
||||
for (const r of raws) {
|
||||
let c = r.c;
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
|
||||
if (dec && !dec._undecryptable) c = dec;
|
||||
}
|
||||
list.push({
|
||||
key: r.key,
|
||||
lat: parseFloat(c.lat) || 0,
|
||||
lng: parseFloat(c.lng) || 0,
|
||||
label: c.label || "",
|
||||
image: c.image || "",
|
||||
author: c.author || r.envAuthor,
|
||||
encrypted: !!(r.c.encryptedPayload && (!c || c._undecryptable)),
|
||||
createdAt: c.createdAt || new Date(r.ts).toISOString()
|
||||
});
|
||||
}
|
||||
idx.markers.set(mapId, list);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const buildMap = (node, rootId, viewerId, markerList = []) => {
|
||||
const c = node.c || {};
|
||||
const undec = c.encryptedPayload && c._decrypted === false;
|
||||
return {
|
||||
key: node.key,
|
||||
rootId,
|
||||
title: c.title || "",
|
||||
title: undec ? "" : (c.title || ""),
|
||||
lat: parseFloat(c.lat) || 0,
|
||||
lng: parseFloat(c.lng) || 0,
|
||||
description: c.description || "",
|
||||
markerLabel: c.markerLabel || "",
|
||||
image: c.image || "",
|
||||
description: undec ? "" : (c.description || ""),
|
||||
markerLabel: undec ? "" : (c.markerLabel || ""),
|
||||
image: undec ? "" : (c.image || ""),
|
||||
mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE",
|
||||
tags: safeArr(c.tags),
|
||||
author: c.author,
|
||||
tribeId: c.tribeId || null,
|
||||
encrypted: !!undec,
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
||||
updatedAt: c.updatedAt || null,
|
||||
markers: markerList.filter((mk) => !mk.tombstoned)
|
||||
|
|
@ -159,7 +199,7 @@ module.exports = ({ cooler }) => {
|
|||
const now = new Date().toISOString();
|
||||
const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE";
|
||||
|
||||
const content = {
|
||||
let content = {
|
||||
type: "map",
|
||||
title: title || "",
|
||||
lat: parseFloat(lat) || 0,
|
||||
|
|
@ -175,6 +215,8 @@ module.exports = ({ cooler }) => {
|
|||
updatedAt: now
|
||||
};
|
||||
|
||||
content = await encryptIfTribe(content);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
});
|
||||
|
|
@ -187,32 +229,39 @@ module.exports = ({ cooler }) => {
|
|||
const oldMsg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!oldMsg || oldMsg.content?.type !== "map") throw new Error("Map not found");
|
||||
if (oldMsg.content.author !== userId) throw new Error("Not the author");
|
||||
const oldDecrypted = await decryptIfTribe(oldMsg.content);
|
||||
assertReadable(oldDecrypted, "Map");
|
||||
if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
|
||||
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDecrypted.tags);
|
||||
const now = new Date().toISOString();
|
||||
const mType = mapType && ALLOWED_MAP_TYPES.has(mapType) ? mapType : oldMsg.content.mapType;
|
||||
const mType = mapType && ALLOWED_MAP_TYPES.has(mapType) ? mapType : oldDecrypted.mapType;
|
||||
|
||||
const updated = {
|
||||
...oldMsg.content,
|
||||
let updated = {
|
||||
type: "map",
|
||||
replaces: tipId,
|
||||
title: title !== undefined ? title || "" : oldMsg.content.title || "",
|
||||
lat: lat !== undefined ? parseFloat(lat) || 0 : oldMsg.content.lat,
|
||||
lng: lng !== undefined ? parseFloat(lng) || 0 : oldMsg.content.lng,
|
||||
description: description !== undefined ? description || "" : oldMsg.content.description || "",
|
||||
title: title !== undefined ? title || "" : oldDecrypted.title || "",
|
||||
lat: lat !== undefined ? parseFloat(lat) || 0 : oldDecrypted.lat,
|
||||
lng: lng !== undefined ? parseFloat(lng) || 0 : oldDecrypted.lng,
|
||||
description: description !== undefined ? description || "" : oldDecrypted.description || "",
|
||||
markerLabel: oldDecrypted.markerLabel || "",
|
||||
mapType: mType,
|
||||
tags,
|
||||
...(image ? { image } : {}),
|
||||
createdAt: oldMsg.content.createdAt,
|
||||
author: oldDecrypted.author || userId,
|
||||
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
|
||||
...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})),
|
||||
createdAt: oldDecrypted.createdAt,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
updated = await encryptIfTribe(updated);
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
});
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
|
||||
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
async deleteMapById(id) {
|
||||
|
|
@ -222,7 +271,8 @@ module.exports = ({ cooler }) => {
|
|||
const msg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
|
||||
if (msg.content.author !== userId) throw new Error("Not the author");
|
||||
const decrypted = await decryptIfTribe(msg.content);
|
||||
if ((decrypted.author || msg.content.author) !== userId) throw new Error("Not the author");
|
||||
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
|
||||
|
||||
|
|
@ -245,22 +295,28 @@ module.exports = ({ cooler }) => {
|
|||
const node = idx.nodes.get(tipId);
|
||||
if (!node) throw new Error("Map not found");
|
||||
|
||||
const mapType = node.c.mapType || "SINGLE";
|
||||
const mapDecrypted = await decryptIfTribe(node.c);
|
||||
assertReadable(mapDecrypted, "Map");
|
||||
const mapType = mapDecrypted.mapType || node.c.mapType || "SINGLE";
|
||||
const mapAuthor = mapDecrypted.author || node.c.author;
|
||||
if (mapType === "SINGLE") throw new Error("Map does not allow markers");
|
||||
if (mapType === "CLOSED" && node.c.author !== userId) throw new Error("Only the map creator can add markers");
|
||||
if (mapType === "CLOSED" && mapAuthor !== userId) throw new Error("Only the map creator can add markers");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const content = {
|
||||
let content = {
|
||||
type: "mapMarker",
|
||||
mapId: tipId,
|
||||
lat: parseFloat(lat) || 0,
|
||||
lng: parseFloat(lng) || 0,
|
||||
label: label || "",
|
||||
author: userId,
|
||||
createdAt: now
|
||||
createdAt: now,
|
||||
...(node.c.tribeId ? { tribeId: node.c.tribeId } : {})
|
||||
};
|
||||
if (image) content.image = image;
|
||||
|
||||
content = await encryptIfTribe(content);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
});
|
||||
|
|
@ -276,6 +332,8 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const messages = await getAllMessages(ssbClient);
|
||||
const idx = buildIndex(messages);
|
||||
await decryptIndexNodes(idx);
|
||||
await expandMarkers(idx);
|
||||
|
||||
const items = [];
|
||||
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
|
||||
|
|
@ -312,6 +370,8 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const messages = await getAllMessages(ssbClient);
|
||||
const idx = buildIndex(messages);
|
||||
await decryptIndexNodes(idx);
|
||||
await expandMarkers(idx);
|
||||
|
||||
let tip = id;
|
||||
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
|
||||
|
|
@ -324,8 +384,13 @@ module.exports = ({ cooler }) => {
|
|||
if (!node) {
|
||||
const msg = await getMsg(ssbClient, tip);
|
||||
if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
|
||||
let c = msg.content;
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
|
||||
c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
|
||||
}
|
||||
const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root)));
|
||||
return buildMap({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer, markerList);
|
||||
return buildMap({ key: tip, ts: msg.timestamp || 0, c }, root, viewer, markerList);
|
||||
}
|
||||
|
||||
const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root)));
|
||||
|
|
|
|||
|
|
@ -106,19 +106,27 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
|
|||
const nodes = new Map()
|
||||
const parent = new Map()
|
||||
const child = new Map()
|
||||
const authorByKey = new Map()
|
||||
const tombRequests = []
|
||||
|
||||
for (const m of messages) {
|
||||
const k = m.key
|
||||
const v = m.value || {}
|
||||
const c = v.content
|
||||
if (!c) continue
|
||||
if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
|
||||
if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
|
||||
if (c.type === "pad") {
|
||||
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
|
||||
authorByKey.set(k, v.author)
|
||||
if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of tombRequests) {
|
||||
const targetAuthor = authorByKey.get(t.target)
|
||||
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
|
||||
}
|
||||
|
||||
const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
|
||||
const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
|
||||
|
||||
|
|
|
|||
|
|
@ -444,7 +444,10 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
const isTribe = term.powerType === 'tribe';
|
||||
let members = 1;
|
||||
if (isTribe && term.powerId) {
|
||||
const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
|
||||
let tribe = null;
|
||||
if (services.tribes) {
|
||||
try { tribe = await services.tribes.getTribeById(term.powerId); } catch {}
|
||||
}
|
||||
members = tribe && Array.isArray(tribe.members) ? tribe.members.length : 0;
|
||||
}
|
||||
const pol = await summarizePoliciesForTerm({ ...term });
|
||||
|
|
@ -1067,7 +1070,7 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
if (latestAny && !isExpiredTerm(latestAny)) return latestAny;
|
||||
|
||||
if (latestAny && isExpiredTerm(latestAny)) {
|
||||
try { await enactApprovedChanges(latestAny); } catch {}
|
||||
try { await enactApprovedChanges(latestAny); } catch (e) { console.error('enactApprovedChanges failed:', e); }
|
||||
}
|
||||
|
||||
const open = await listCandidaturesOpen();
|
||||
|
|
@ -1183,7 +1186,10 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
if (String(term.method || '').toUpperCase() === 'ANARCHY') return true;
|
||||
if (term.powerType === 'inhabitant') return term.powerId === userId;
|
||||
if (term.powerType === 'tribe') {
|
||||
const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
|
||||
let tribe = null;
|
||||
if (services.tribes) {
|
||||
try { tribe = await services.tribes.getTribeById(term.powerId); } catch {}
|
||||
}
|
||||
const members = ensureArray(tribe?.members);
|
||||
return members.includes(userId);
|
||||
}
|
||||
|
|
@ -1201,6 +1207,13 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
};
|
||||
|
||||
const tribeListByType = async (type, tribeId) => {
|
||||
let chainIds;
|
||||
try {
|
||||
chainIds = services.tribes && services.tribes.getChainIds
|
||||
? await services.tribes.getChainIds(tribeId)
|
||||
: [tribeId];
|
||||
} catch (_) { chainIds = [tribeId]; }
|
||||
const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]);
|
||||
const msgs = await tribeReadLog();
|
||||
const tomb = new Set();
|
||||
const replaced = new Set();
|
||||
|
|
@ -1209,7 +1222,7 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
const c = m.value?.content; if (!c) continue;
|
||||
if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
|
||||
if (c.type !== type) continue;
|
||||
if (c.tribeId !== tribeId) continue;
|
||||
if (!tribeIdSet.has(c.tribeId)) continue;
|
||||
if (c.replaces) replaced.add(c.replaces);
|
||||
items.set(m.key, { ...c, id: m.key, _ts: m.value?.timestamp || 0 });
|
||||
}
|
||||
|
|
@ -1226,6 +1239,77 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
return terms[0] || null;
|
||||
};
|
||||
|
||||
const tribeElectionInFlight = new Map();
|
||||
|
||||
async function tribePublishInitialTerm(tribeId) {
|
||||
if (!tribeId) throw new Error('Missing tribeId');
|
||||
await openSsb();
|
||||
const existing = await tribeListByType('tribeParliamentTerm', tribeId);
|
||||
if (existing.length > 0) return existing[0];
|
||||
const startAt = moment().toISOString();
|
||||
const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
|
||||
const term = {
|
||||
type: 'tribeParliamentTerm',
|
||||
tribeId,
|
||||
method: 'ANARCHY',
|
||||
leaderId: null,
|
||||
winnerVotes: 0,
|
||||
totalVotes: 0,
|
||||
startAt,
|
||||
endAt,
|
||||
createdBy: userId,
|
||||
createdAt: nowISO()
|
||||
};
|
||||
return await publishMsg(term);
|
||||
}
|
||||
|
||||
async function tribeResolveElectionImpl(tribeId) {
|
||||
const latest = await tribeGetCurrentTerm(tribeId);
|
||||
if (latest && !isExpiredTerm(latest)) return latest;
|
||||
const opens = (await tribeListCandidatures(tribeId)).filter(c => (c.status || 'OPEN') === 'OPEN');
|
||||
let chosen = null, totalVotes = 0, winnerVotes = 0;
|
||||
if (opens.length) {
|
||||
opens.sort((a, b) => Number(b.votes || 0) - Number(a.votes || 0) || new Date(a.createdAt) - new Date(b.createdAt));
|
||||
chosen = opens[0];
|
||||
totalVotes = opens.reduce((s, c) => s + Number(c.votes || 0), 0);
|
||||
winnerVotes = Number(chosen.votes || 0);
|
||||
if (winnerVotes <= 0) chosen = null;
|
||||
}
|
||||
const startAt = moment().toISOString();
|
||||
const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
|
||||
const term = {
|
||||
type: 'tribeParliamentTerm',
|
||||
tribeId,
|
||||
method: chosen ? String(chosen.method || 'DEMOCRACY').toUpperCase() : 'ANARCHY',
|
||||
leaderId: chosen ? chosen.candidateId : null,
|
||||
winnerVotes,
|
||||
totalVotes,
|
||||
startAt,
|
||||
endAt,
|
||||
createdBy: userId,
|
||||
createdAt: nowISO()
|
||||
};
|
||||
const res = await publishMsg(term);
|
||||
for (const c of opens) {
|
||||
try { await publishMsg({ type: 'tombstone', target: c.id, deletedAt: nowISO(), author: userId }); } catch {}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function tribeResolveElection(tribeId) {
|
||||
if (tribeElectionInFlight.has(tribeId)) return tribeElectionInFlight.get(tribeId);
|
||||
const p = tribeResolveElectionImpl(tribeId).catch(e => { console.error('tribeResolveElection failed:', e); return null; }).finally(() => tribeElectionInFlight.delete(tribeId));
|
||||
tribeElectionInFlight.set(tribeId, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function tribeEnsureTerm(tribeId) {
|
||||
const cur = await tribeGetCurrentTerm(tribeId);
|
||||
if (cur && !isExpiredTerm(cur)) return cur;
|
||||
if (!cur) return await tribePublishInitialTerm(tribeId);
|
||||
return await tribeResolveElection(tribeId);
|
||||
}
|
||||
|
||||
const tribeListCandidatures = (tribeId) => tribeListByType('tribeParliamentCandidature', tribeId);
|
||||
const tribeListRules = (tribeId) => tribeListByType('tribeParliamentRule', tribeId);
|
||||
|
||||
|
|
@ -1285,11 +1369,18 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
};
|
||||
|
||||
const tribeHasCandidatureInGlobalCycle = async (tribeId, globalTermStart) => {
|
||||
let chainIds;
|
||||
try {
|
||||
chainIds = services.tribes && services.tribes.getChainIds
|
||||
? await services.tribes.getChainIds(tribeId)
|
||||
: [tribeId];
|
||||
} catch (_) { chainIds = [tribeId]; }
|
||||
const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]);
|
||||
const msgs = await tribeReadLog();
|
||||
const cutoff = globalTermStart ? new Date(globalTermStart) : new Date(Date.now() - TERM_DAYS * 86400000);
|
||||
return msgs.some(m => {
|
||||
const c = m.value?.content; if (!c) return false;
|
||||
return c.type === 'parliamentCandidature' && c.targetType === 'tribe' && c.targetId === tribeId && (c.status || 'OPEN') === 'OPEN' && new Date(c.createdAt) >= cutoff;
|
||||
return c.type === 'parliamentCandidature' && c.targetType === 'tribe' && tribeIdSet.has(c.targetId) && (c.status || 'OPEN') === 'OPEN' && new Date(c.createdAt) >= cutoff;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -1326,7 +1417,10 @@ module.exports = ({ cooler, services = {} }) => {
|
|||
voteTribeCandidature: tribeVoteCandidature,
|
||||
publishTribeRule: tribePublishRule,
|
||||
deleteTribeRule: tribeDeleteRule,
|
||||
hasCandidatureInGlobalCycle: tribeHasCandidatureInGlobalCycle
|
||||
hasCandidatureInGlobalCycle: tribeHasCandidatureInGlobalCycle,
|
||||
publishInitialTerm: tribePublishInitialTerm,
|
||||
resolveElection: tribeResolveElection,
|
||||
ensureTerm: tribeEnsureTerm
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const parseBlobId = (blobMarkdown) => {
|
|||
const voteSum = (opinions = {}) =>
|
||||
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
let ssb;
|
||||
|
||||
const openSsb = async () => {
|
||||
|
|
@ -31,14 +31,20 @@ module.exports = ({ cooler }) => {
|
|||
return ssb;
|
||||
};
|
||||
|
||||
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
|
||||
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
|
||||
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
|
||||
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
|
||||
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
|
||||
|
||||
const getAllMessages = async (ssbClient) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
|
||||
});
|
||||
|
||||
const getMsg = async (ssbClient, key) =>
|
||||
new Promise((resolve, reject) => {
|
||||
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
|
||||
new Promise((resolve) => {
|
||||
ssbClient.get(key, (err, msg) => (err ? resolve(null) : resolve(msg)));
|
||||
});
|
||||
|
||||
const buildIndex = (messages) => {
|
||||
|
|
@ -46,6 +52,8 @@ module.exports = ({ cooler }) => {
|
|||
const nodes = new Map();
|
||||
const parent = new Map();
|
||||
const child = new Map();
|
||||
const authorByKey = new Map();
|
||||
const tombRequests = [];
|
||||
|
||||
for (const m of messages) {
|
||||
const k = m.key;
|
||||
|
|
@ -54,7 +62,7 @@ module.exports = ({ cooler }) => {
|
|||
if (!c) continue;
|
||||
|
||||
if (c.type === "tombstone" && c.target) {
|
||||
tomb.add(c.target);
|
||||
tombRequests.push({ target: c.target, author: v.author });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +70,7 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const ts = v.timestamp || m.timestamp || 0;
|
||||
nodes.set(k, { key: k, ts, c });
|
||||
authorByKey.set(k, v.author);
|
||||
|
||||
if (c.replaces) {
|
||||
parent.set(k, c.replaces);
|
||||
|
|
@ -81,6 +90,11 @@ module.exports = ({ cooler }) => {
|
|||
return cur;
|
||||
};
|
||||
|
||||
for (const t of tombRequests) {
|
||||
const targetAuthor = authorByKey.get(t.target);
|
||||
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target);
|
||||
}
|
||||
|
||||
const roots = new Set();
|
||||
for (const id of nodes.keys()) roots.add(rootOf(id));
|
||||
|
||||
|
|
@ -95,24 +109,28 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const buildTorrent = (node, rootId, viewerId) => {
|
||||
const c = node.c || {};
|
||||
const undec = c.encryptedPayload && c._decrypted === false;
|
||||
const voters = safeArr(c.opinions_inhabitants);
|
||||
return {
|
||||
key: node.key,
|
||||
rootId,
|
||||
url: c.url,
|
||||
url: undec ? "" : c.url,
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
||||
updatedAt: c.updatedAt || null,
|
||||
tags: safeArr(c.tags),
|
||||
author: c.author,
|
||||
title: c.title || "",
|
||||
description: c.description || "",
|
||||
title: undec ? "" : (c.title || ""),
|
||||
description: undec ? "" : (c.description || ""),
|
||||
size: c.size || 0,
|
||||
opinions: c.opinions || {},
|
||||
opinions_inhabitants: voters,
|
||||
hasVoted: viewerId ? voters.includes(viewerId) : false
|
||||
hasVoted: viewerId ? voters.includes(viewerId) : false,
|
||||
tribeId: c.tribeId || null,
|
||||
encrypted: !!undec
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
type: "torrent",
|
||||
|
||||
|
|
@ -141,13 +159,13 @@ module.exports = ({ cooler }) => {
|
|||
return root;
|
||||
},
|
||||
|
||||
async createTorrent(blobMarkdown, tagsRaw, title, description, size) {
|
||||
async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId) {
|
||||
const ssbClient = await openSsb();
|
||||
const blobId = parseBlobId(blobMarkdown);
|
||||
const tags = normalizeTags(tagsRaw) || [];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const content = {
|
||||
let content = {
|
||||
type: "torrent",
|
||||
url: blobId,
|
||||
createdAt: now,
|
||||
|
|
@ -158,9 +176,12 @@ module.exports = ({ cooler }) => {
|
|||
description: description || "",
|
||||
size: Number(size) || 0,
|
||||
opinions: {},
|
||||
opinions_inhabitants: []
|
||||
opinions_inhabitants: [],
|
||||
...(tribeId ? { tribeId } : {})
|
||||
};
|
||||
|
||||
content = await encryptIfTribe(content);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
});
|
||||
|
|
@ -173,30 +194,39 @@ module.exports = ({ cooler }) => {
|
|||
const oldMsg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!oldMsg || oldMsg.content?.type !== "torrent") throw new Error("Torrent not found");
|
||||
if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit torrent after it has received opinions.");
|
||||
if (oldMsg.content.author !== userId) throw new Error("Not the author");
|
||||
const oldDec = await decryptIfTribe(oldMsg.content);
|
||||
assertReadable(oldDec, "Torrent");
|
||||
if (Object.keys(oldDec.opinions || oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit torrent after it has received opinions.");
|
||||
if ((oldDec.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
|
||||
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
|
||||
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDec.tags);
|
||||
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const updated = {
|
||||
...oldMsg.content,
|
||||
let updated = {
|
||||
type: "torrent",
|
||||
replaces: tipId,
|
||||
url: blobId || oldMsg.content.url,
|
||||
url: blobId || oldDec.url,
|
||||
tags,
|
||||
title: title !== undefined ? title || "" : oldMsg.content.title || "",
|
||||
description: description !== undefined ? description || "" : oldMsg.content.description || "",
|
||||
createdAt: oldMsg.content.createdAt,
|
||||
title: title !== undefined ? title || "" : oldDec.title || "",
|
||||
description: description !== undefined ? description || "" : oldDec.description || "",
|
||||
size: oldDec.size || 0,
|
||||
opinions: oldDec.opinions || {},
|
||||
opinions_inhabitants: oldDec.opinions_inhabitants || [],
|
||||
author: oldDec.author || userId,
|
||||
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
|
||||
createdAt: oldDec.createdAt,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
updated = await encryptIfTribe(updated);
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
});
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
|
||||
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
async deleteTorrentById(id) {
|
||||
|
|
@ -206,7 +236,8 @@ module.exports = ({ cooler }) => {
|
|||
const msg = await getMsg(ssbClient, tipId);
|
||||
|
||||
if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
|
||||
if (msg.content.author !== userId) throw new Error("Not the author");
|
||||
const dec = await decryptIfTribe(msg.content);
|
||||
if ((dec.author || msg.content.author) !== userId) throw new Error("Not the author");
|
||||
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
|
||||
|
||||
|
|
@ -226,6 +257,7 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const messages = await getAllMessages(ssbClient);
|
||||
const idx = buildIndex(messages);
|
||||
await decryptIndexNodes(idx);
|
||||
|
||||
const items = [];
|
||||
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
|
||||
|
|
@ -270,6 +302,7 @@ module.exports = ({ cooler }) => {
|
|||
const viewer = viewerId || ssbClient.id;
|
||||
const messages = await getAllMessages(ssbClient);
|
||||
const idx = buildIndex(messages);
|
||||
await decryptIndexNodes(idx);
|
||||
|
||||
let tip = id;
|
||||
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
|
||||
|
|
@ -283,7 +316,12 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const msg = await getMsg(ssbClient, tip);
|
||||
if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
|
||||
return buildTorrent({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
|
||||
let c = msg.content;
|
||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
|
||||
c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
|
||||
}
|
||||
return buildTorrent({ key: tip, ts: msg.timestamp || 0, c }, root, viewer);
|
||||
},
|
||||
|
||||
async createOpinion(id, category) {
|
||||
|
|
@ -297,27 +335,39 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
|
||||
|
||||
const voters = safeArr(msg.content.opinions_inhabitants);
|
||||
const oldDec = await decryptIfTribe(msg.content);
|
||||
assertReadable(oldDec, "Torrent");
|
||||
const voters = safeArr(oldDec.opinions_inhabitants || msg.content.opinions_inhabitants);
|
||||
if (voters.includes(userId)) throw new Error("Already voted");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated = {
|
||||
...msg.content,
|
||||
let updated = {
|
||||
type: "torrent",
|
||||
replaces: tipId,
|
||||
url: oldDec.url,
|
||||
tags: oldDec.tags || [],
|
||||
title: oldDec.title || "",
|
||||
description: oldDec.description || "",
|
||||
size: oldDec.size || 0,
|
||||
opinions: {
|
||||
...msg.content.opinions,
|
||||
[category]: (msg.content.opinions?.[category] || 0) + 1
|
||||
...(oldDec.opinions || {}),
|
||||
[category]: ((oldDec.opinions || {})[category] || 0) + 1
|
||||
},
|
||||
opinions_inhabitants: voters.concat(userId),
|
||||
author: oldDec.author,
|
||||
...(msg.content.tribeId ? { tribeId: msg.content.tribeId } : {}),
|
||||
createdAt: oldDec.createdAt,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
updated = await encryptIfTribe(updated);
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
|
||||
});
|
||||
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
|
||||
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,9 +6,16 @@ const SENSITIVE_FIELDS = [
|
|||
'title', 'description', 'location', 'price', 'salary', 'options', 'votes',
|
||||
'category', 'tags', 'image', 'url', 'attendees', 'assignees', 'deadline',
|
||||
'goal', 'funded', 'refeeds', 'refeeds_inhabitants', 'opinions',
|
||||
'opinions_inhabitants', 'parentId', 'status', 'priority', 'date', 'mediaType'
|
||||
'opinions_inhabitants', 'status', 'priority', 'date', 'mediaType'
|
||||
];
|
||||
|
||||
const ENVELOPE_PRESERVE = new Set([
|
||||
'type', 'tribeId', 'contentType', 'replaces', 'target', 'author',
|
||||
'createdAt', 'updatedAt', 'encryptedPayload',
|
||||
'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId',
|
||||
'_decrypted', '_undecryptable'
|
||||
]);
|
||||
|
||||
const INVITE_SALT = 'SolarNET.HuB';
|
||||
|
||||
module.exports = (configPath) => {
|
||||
|
|
@ -26,7 +33,9 @@ module.exports = (configPath) => {
|
|||
};
|
||||
|
||||
const saveKeyring = () => {
|
||||
fs.writeFileSync(keyringPath, JSON.stringify(keyring, null, 2), 'utf8');
|
||||
const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
|
||||
fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), 'utf8');
|
||||
fs.renameSync(tmp, keyringPath);
|
||||
};
|
||||
|
||||
const generateTribeKey = () => crypto.randomBytes(32).toString('hex');
|
||||
|
|
@ -89,6 +98,23 @@ module.exports = (configPath) => {
|
|||
return decryptWithKey(encryptedKey, derived.toString('hex'));
|
||||
};
|
||||
|
||||
const encryptChainForInvite = (ancestryRootIds, inviteCode) => {
|
||||
const chain = ancestryRootIds.map(rootId => ({ rootId, key: getKey(rootId), gen: getGen(rootId) }));
|
||||
if (chain.some(e => !e.key)) return null;
|
||||
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
|
||||
return encryptWithKey(JSON.stringify(chain), derived.toString('hex'));
|
||||
};
|
||||
|
||||
const decryptChainFromInvite = (encryptedPayload, inviteCode) => {
|
||||
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
|
||||
try {
|
||||
const json = decryptWithKey(encryptedPayload, derived.toString('hex'));
|
||||
const parsed = JSON.parse(json);
|
||||
if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && e.key)) return parsed;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const encryptChain = (plaintext, keyChain) => {
|
||||
let data = plaintext;
|
||||
for (const keyHex of keyChain) {
|
||||
|
|
@ -106,18 +132,25 @@ module.exports = (configPath) => {
|
|||
return data;
|
||||
};
|
||||
|
||||
const encryptContent = (content, keyChain) => {
|
||||
const encryptContent = (content, keyChain, customFields) => {
|
||||
const payload = {};
|
||||
if (customFields) {
|
||||
for (const [k, v] of Object.entries(content)) {
|
||||
if (ENVELOPE_PRESERVE.has(k)) continue;
|
||||
payload[k] = v;
|
||||
}
|
||||
} else {
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (content[field] !== undefined) {
|
||||
payload[field] = content[field];
|
||||
if (content[field] !== undefined) payload[field] = content[field];
|
||||
}
|
||||
}
|
||||
const plaintext = JSON.stringify(payload);
|
||||
const encryptedPayload = encryptChain(plaintext, keyChain);
|
||||
const result = {};
|
||||
for (const [k, v] of Object.entries(content)) {
|
||||
if (!SENSITIVE_FIELDS.includes(k)) result[k] = v;
|
||||
if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) {
|
||||
result[k] = v;
|
||||
}
|
||||
}
|
||||
result.encryptedPayload = encryptedPayload;
|
||||
return result;
|
||||
|
|
@ -137,7 +170,7 @@ module.exports = (configPath) => {
|
|||
continue;
|
||||
}
|
||||
}
|
||||
return { ...content, encrypted: true };
|
||||
return { ...content, _undecryptable: true };
|
||||
};
|
||||
|
||||
const boxKeyForMember = (tribeKeyHex, memberFeedId, ssbKeys) => {
|
||||
|
|
@ -165,17 +198,85 @@ module.exports = (configPath) => {
|
|||
return sets;
|
||||
};
|
||||
|
||||
const resolveKeyChain = async (tribeId, tribesModel) => {
|
||||
if (!tribeId || !tribesModel) return null;
|
||||
let ancestryIds;
|
||||
try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
|
||||
if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
|
||||
const chain = [];
|
||||
for (const rootId of ancestryIds) {
|
||||
const key = getKey(rootId);
|
||||
if (!key) return null;
|
||||
chain.push(key);
|
||||
}
|
||||
return chain.length ? chain : null;
|
||||
};
|
||||
|
||||
const resolveKeyChainSets = async (tribeId, tribesModel) => {
|
||||
if (!tribeId || !tribesModel) return null;
|
||||
let ancestryIds;
|
||||
try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
|
||||
if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
|
||||
return buildKeyChainSets(ancestryIds);
|
||||
};
|
||||
|
||||
const encryptForTribe = async (content, tribeId, tribesModel) => {
|
||||
const chain = await resolveKeyChain(tribeId, tribesModel);
|
||||
if (!chain) throw new Error('Missing tribe key chain — cannot encrypt content for this tribe');
|
||||
return encryptContent(content, chain, true);
|
||||
};
|
||||
|
||||
const decryptFromTribe = async (content, tribesModel) => {
|
||||
if (!content || !content.encryptedPayload) return content;
|
||||
const tid = content.tribeId;
|
||||
if (!tid) return content;
|
||||
const sets = await resolveKeyChainSets(tid, tribesModel);
|
||||
if (!sets || !sets.length) return { ...content, _undecryptable: true };
|
||||
return decryptContent(content, sets);
|
||||
};
|
||||
|
||||
const createHelpers = (tribesModel) => ({
|
||||
async encryptIfTribe(content) {
|
||||
if (!content.tribeId || !tribesModel) return content;
|
||||
return await encryptForTribe(content, content.tribeId, tribesModel);
|
||||
},
|
||||
async decryptIfTribe(content) {
|
||||
if (!content || !content.encryptedPayload || !tribesModel) return content;
|
||||
return await decryptFromTribe(content, tribesModel);
|
||||
},
|
||||
assertReadable(decrypted, what) {
|
||||
if (decrypted && decrypted._undecryptable) throw new Error(`${what} is tribe-encrypted and cannot be decrypted with available keys`);
|
||||
},
|
||||
async decryptIndexNodes(idx) {
|
||||
if (!tribesModel) return;
|
||||
for (const [k, n] of idx.nodes.entries()) {
|
||||
if (!n.c || !n.c.encryptedPayload) continue;
|
||||
const dec = await decryptFromTribe(n.c, tribesModel);
|
||||
if (dec && !dec._undecryptable) {
|
||||
idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
|
||||
} else {
|
||||
idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loadKeyring();
|
||||
|
||||
return {
|
||||
SENSITIVE_FIELDS,
|
||||
ENVELOPE_PRESERVE,
|
||||
loadKeyring, saveKeyring,
|
||||
generateTribeKey, getKey, getKeys, getGen, setKey, addNewKey,
|
||||
encryptWithKey, decryptWithKey,
|
||||
encryptForInvite, decryptFromInvite,
|
||||
encryptChainForInvite, decryptChainFromInvite,
|
||||
encryptChain, decryptChain,
|
||||
encryptContent, decryptContent,
|
||||
boxKeyForMember, unboxKeyFromMember,
|
||||
buildKeyChainSets,
|
||||
resolveKeyChain, resolveKeyChainSets,
|
||||
encryptForTribe, decryptFromTribe,
|
||||
createHelpers,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@ const categories = require('../backend/opinion_categories');
|
|||
const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS'];
|
||||
const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
const TYPE = 'tribe-content';
|
||||
|
||||
const resolveKeyChain = async (tribeId) =>
|
||||
(tribeCrypto && tribesModel) ? tribeCrypto.resolveKeyChain(tribeId, tribesModel) : null;
|
||||
|
||||
const resolveKeyChainSets = async (tribeId) =>
|
||||
(tribeCrypto && tribesModel) ? tribeCrypto.resolveKeyChainSets(tribeId, tribesModel) : null;
|
||||
|
||||
const publish = async (content) => {
|
||||
const ssbClient = await openSsb();
|
||||
return new Promise((resolve, reject) =>
|
||||
|
|
@ -30,26 +36,48 @@ module.exports = ({ cooler }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const buildIndex = (msgs, tribeId, contentType) => {
|
||||
const buildIndex = async (msgs, tribeId, contentType) => {
|
||||
const tombstoned = new Set();
|
||||
const replaced = new Map();
|
||||
const items = new Map();
|
||||
const authorByKey = new Map();
|
||||
const tombRequests = [];
|
||||
|
||||
for (const m of msgs) {
|
||||
const c = m.value?.content;
|
||||
if (!c) continue;
|
||||
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
|
||||
if (c.type === 'tombstone' && c.target) { tombRequests.push({ target: c.target, author: m.value?.author }); continue; }
|
||||
if (c.type !== TYPE) continue;
|
||||
authorByKey.set(m.key, m.value?.author);
|
||||
if (tribeId && c.tribeId !== tribeId) continue;
|
||||
if (contentType && c.contentType !== contentType) continue;
|
||||
if (c.replaces) replaced.set(c.replaces, m.key);
|
||||
items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
|
||||
}
|
||||
|
||||
for (const t of tombRequests) {
|
||||
const targetAuthor = authorByKey.get(t.target);
|
||||
if (targetAuthor && t.author === targetAuthor) tombstoned.add(t.target);
|
||||
}
|
||||
|
||||
for (const id of tombstoned) items.delete(id);
|
||||
for (const oldId of replaced.keys()) items.delete(oldId);
|
||||
|
||||
return [...items.values()].sort((a, b) => {
|
||||
const result = [...items.values()];
|
||||
if (tribeCrypto && tribesModel) {
|
||||
const keyChainCache = new Map();
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (!result[i].encryptedPayload) continue;
|
||||
const tid = result[i].tribeId;
|
||||
if (!keyChainCache.has(tid)) {
|
||||
const sets = await tribeCrypto.resolveKeyChainSets(tid, tribesModel);
|
||||
keyChainCache.set(tid, sets || []);
|
||||
}
|
||||
result[i] = tribeCrypto.decryptContent(result[i], keyChainCache.get(tid));
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => {
|
||||
const ta = Date.parse(a.updatedAt || a.createdAt) || a._ts || 0;
|
||||
const tb = Date.parse(b.updatedAt || b.createdAt) || b._ts || 0;
|
||||
return tb - ta;
|
||||
|
|
@ -102,12 +130,17 @@ module.exports = ({ cooler }) => {
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const keyChain = await resolveKeyChain(tribeId);
|
||||
if (keyChain && keyChain.length > 0) {
|
||||
return publish(tribeCrypto.encryptContent(content, keyChain));
|
||||
}
|
||||
return publish(content);
|
||||
},
|
||||
|
||||
async update(contentId, data, existing) {
|
||||
if (!existing) existing = await this.getById(contentId);
|
||||
if (!existing) throw new Error('Content not found');
|
||||
if (existing._undecryptable) throw new Error('Content is tribe-encrypted and cannot be decrypted with available keys');
|
||||
if (data.status && !VALID_STATUSES.includes(data.status)) {
|
||||
throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
|
||||
}
|
||||
|
|
@ -149,6 +182,10 @@ module.exports = ({ cooler }) => {
|
|||
createdAt: existing.createdAt,
|
||||
updatedAt: now,
|
||||
};
|
||||
const keyChain = await resolveKeyChain(existing.tribeId);
|
||||
if (keyChain && keyChain.length > 0) {
|
||||
return publish(tribeCrypto.encryptContent(updated, keyChain));
|
||||
}
|
||||
return publish(updated);
|
||||
},
|
||||
|
||||
|
|
@ -167,25 +204,35 @@ module.exports = ({ cooler }) => {
|
|||
const tombstoned = new Set();
|
||||
const replaced = new Map();
|
||||
const items = new Map();
|
||||
const authorByKey = new Map();
|
||||
const tombRequests = [];
|
||||
|
||||
for (const m of msgs) {
|
||||
const c = m.value?.content;
|
||||
if (!c) continue;
|
||||
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
|
||||
if (c.type === 'tombstone' && c.target) { tombRequests.push({ target: c.target, author: m.value?.author }); continue; }
|
||||
if (c.type !== TYPE) continue;
|
||||
authorByKey.set(m.key, m.value?.author);
|
||||
if (c.replaces) replaced.set(c.replaces, m.key);
|
||||
items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
|
||||
}
|
||||
for (const t of tombRequests) {
|
||||
const targetAuthor = authorByKey.get(t.target);
|
||||
if (targetAuthor && t.author === targetAuthor) tombstoned.add(t.target);
|
||||
}
|
||||
|
||||
let latestId = contentId;
|
||||
while (replaced.has(latestId)) latestId = replaced.get(latestId);
|
||||
if (tombstoned.has(latestId)) return null;
|
||||
return items.get(latestId) || null;
|
||||
const item = items.get(latestId) || null;
|
||||
if (!item || !item.encryptedPayload || !tribeCrypto || !tribesModel) return item;
|
||||
const keyChainSets = await tribeCrypto.resolveKeyChainSets(item.tribeId, tribesModel);
|
||||
return tribeCrypto.decryptContent(item, keyChainSets || []);
|
||||
},
|
||||
|
||||
async listByTribe(tribeId, contentType, filter) {
|
||||
const msgs = await readLog();
|
||||
let items = buildIndex(msgs, tribeId, contentType);
|
||||
let items = await buildIndex(msgs, tribeId, contentType);
|
||||
|
||||
if (filter === 'open') items = items.filter(i => i.status === 'OPEN');
|
||||
if (filter === 'closed') items = items.filter(i => i.status === 'CLOSED');
|
||||
|
|
|
|||
|
|
@ -6,13 +6,52 @@ const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|||
const INVITE_CODE_BYTES = 16;
|
||||
const VALID_INVITE_MODES = ['strict', 'open'];
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
module.exports = ({ cooler, tribeCrypto }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
|
||||
|
||||
let tribeIndex = null;
|
||||
let tribeIndexTs = 0;
|
||||
|
||||
const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isLARP', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
|
||||
|
||||
const arraysEqual = (a, b) => {
|
||||
const aa = Array.isArray(a) ? a : [];
|
||||
const bb = Array.isArray(b) ? b : [];
|
||||
if (aa.length !== bb.length) return false;
|
||||
for (let i = 0; i < aa.length; i++) if (aa[i] !== bb[i]) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const validMembershipDelta = (prevMembers, nextMembers, author) => {
|
||||
const prev = Array.isArray(prevMembers) ? prevMembers : [];
|
||||
const next = Array.isArray(nextMembers) ? nextMembers : [];
|
||||
const added = next.filter(m => !prev.includes(m));
|
||||
const removed = prev.filter(m => !next.includes(m));
|
||||
if (added.length === 0 && removed.length === 0) return true;
|
||||
if (added.length === 1 && removed.length === 0 && added[0] === author) return true;
|
||||
if (removed.length === 1 && added.length === 0 && removed[0] === author) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const validInvitesDelta = (prevInvites, nextInvites, author, rootAuthor) => {
|
||||
if (author === rootAuthor) return true;
|
||||
const prevCodes = new Set((prevInvites || []).map(i => typeof i === 'string' ? i : i?.code).filter(Boolean));
|
||||
const nextCodes = new Set((nextInvites || []).map(i => typeof i === 'string' ? i : i?.code).filter(Boolean));
|
||||
for (const c of nextCodes) if (!prevCodes.has(c)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const structuralFieldsEqual = (prev, next) => {
|
||||
for (const f of STRUCTURAL_FIELDS) {
|
||||
const a = prev[f];
|
||||
const b = next[f];
|
||||
if (Array.isArray(a) || Array.isArray(b)) { if (!arraysEqual(a, b)) return false; continue; }
|
||||
if (a !== b && !(a == null && b == null)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const buildTribeIndex = async () => {
|
||||
if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
|
||||
const client = await openSsb();
|
||||
|
|
@ -21,23 +60,68 @@ module.exports = ({ cooler }) => {
|
|||
client.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, msgs) => {
|
||||
if (err) return reject(err);
|
||||
const tombstoned = new Set();
|
||||
const parent = new Map();
|
||||
const child = new Map();
|
||||
const tribes = new Map();
|
||||
const tombstones = new Map();
|
||||
const tribeMsgs = new Map();
|
||||
for (const msg of msgs) {
|
||||
const k = msg.key;
|
||||
const c = msg.value?.content;
|
||||
if (!c) continue;
|
||||
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
|
||||
const author = msg.value?.author;
|
||||
if (c.type === 'tombstone' && c.target) {
|
||||
tombstones.set(c.target, { author, ts: msg.value?.timestamp });
|
||||
continue;
|
||||
}
|
||||
if (c.type !== 'tribe') continue;
|
||||
if (c.replaces) {
|
||||
parent.set(k, c.replaces);
|
||||
child.set(c.replaces, k);
|
||||
tribeMsgs.set(k, { id: k, content: c, author, _ts: msg.value?.timestamp });
|
||||
}
|
||||
tribes.set(k, { id: k, content: c, _ts: msg.value?.timestamp });
|
||||
const tribes = new Map();
|
||||
const parent = new Map();
|
||||
const child = new Map();
|
||||
const rootByTip = new Map();
|
||||
for (const [k, entry] of tribeMsgs.entries()) {
|
||||
const c = entry.content;
|
||||
if (!c.replaces) {
|
||||
tribes.set(k, entry);
|
||||
rootByTip.set(k, k);
|
||||
}
|
||||
const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
|
||||
}
|
||||
let progress = true;
|
||||
while (progress) {
|
||||
progress = false;
|
||||
for (const [k, entry] of tribeMsgs.entries()) {
|
||||
if (tribes.has(k)) continue;
|
||||
const replaces = entry.content.replaces;
|
||||
if (!replaces) continue;
|
||||
const parentEntry = tribes.get(replaces);
|
||||
if (!parentEntry) continue;
|
||||
if (child.has(replaces)) continue;
|
||||
const root = rootByTip.get(replaces);
|
||||
const rootEntry = tribes.get(root);
|
||||
const rootAuthor = rootEntry?.author;
|
||||
const isRootAuthor = entry.author === rootAuthor;
|
||||
const prevMembers = Array.isArray(parentEntry.content.members) ? parentEntry.content.members : [];
|
||||
if (!isRootAuthor) {
|
||||
if (!prevMembers.includes(entry.author) && !(entry.content.members || []).includes(entry.author)) continue;
|
||||
if (!validMembershipDelta(prevMembers, entry.content.members, entry.author)) continue;
|
||||
if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue;
|
||||
if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue;
|
||||
}
|
||||
parent.set(k, replaces);
|
||||
child.set(replaces, k);
|
||||
tribes.set(k, entry);
|
||||
rootByTip.set(k, root);
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
const tombstoned = new Set();
|
||||
for (const [target, t] of tombstones.entries()) {
|
||||
const tribeEntry = tribes.get(target);
|
||||
if (!tribeEntry) continue;
|
||||
const root = rootByTip.get(target);
|
||||
const rootAuthor = tribes.get(root)?.author;
|
||||
if (t.author === rootAuthor) tombstoned.add(target);
|
||||
}
|
||||
const rootOf = (id) => rootByTip.get(id) || id;
|
||||
const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur; };
|
||||
const tipByRoot = new Map();
|
||||
for (const k of tribes.keys()) {
|
||||
|
|
@ -45,7 +129,7 @@ module.exports = ({ cooler }) => {
|
|||
const tip = tipOf(root);
|
||||
tipByRoot.set(root, tip);
|
||||
}
|
||||
tribeIndex = { tribes, tombstoned, parent, child, tipByRoot };
|
||||
tribeIndex = { tribes, tombstoned, parent, child, tipByRoot, rootByTip };
|
||||
tribeIndexTs = Date.now();
|
||||
resolve(tribeIndex);
|
||||
})
|
||||
|
|
@ -56,7 +140,7 @@ module.exports = ({ cooler }) => {
|
|||
return {
|
||||
type: 'tribe',
|
||||
|
||||
async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN') {
|
||||
async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
|
||||
if (!VALID_INVITE_MODES.includes(inviteMode)) {
|
||||
throw new Error('Invalid invite mode. Must be "strict" or "open"');
|
||||
}
|
||||
|
|
@ -83,11 +167,16 @@ module.exports = ({ cooler }) => {
|
|||
inviteMode,
|
||||
status: status || 'OPEN',
|
||||
parentTribeId: parentTribeId || null,
|
||||
mapUrl: String(mapUrl || '').trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
author: userId,
|
||||
};
|
||||
const result = await new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
|
||||
if (tribeCrypto) {
|
||||
const tribeKey = tribeCrypto.generateTribeKey();
|
||||
tribeCrypto.setKey(result.key, tribeKey, 1);
|
||||
}
|
||||
tribeIndex = null;
|
||||
return result;
|
||||
},
|
||||
|
|
@ -103,7 +192,15 @@ module.exports = ({ cooler }) => {
|
|||
throw new Error('Only tribe members can generate invites in open mode');
|
||||
}
|
||||
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
|
||||
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code];
|
||||
let invite = code;
|
||||
if (tribeCrypto) {
|
||||
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null);
|
||||
if (Array.isArray(ancestryIds) && ancestryIds.length) {
|
||||
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code);
|
||||
if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(ancestryIds[0]) };
|
||||
}
|
||||
}
|
||||
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite];
|
||||
const inviteLog = Array.isArray(tribe.inviteLog) ? [...tribe.inviteLog] : [];
|
||||
inviteLog.push({ code, generatedBy: userId, generatedAt: new Date().toISOString(), status: 'pending', usedBy: null, usedAt: null });
|
||||
await this.updateTribeById(tribeId, { invites, inviteLog });
|
||||
|
|
@ -126,27 +223,75 @@ module.exports = ({ cooler }) => {
|
|||
const idx = members.indexOf(userId);
|
||||
if (idx === -1) throw new Error('User is not a member of this tribe');
|
||||
members.splice(idx, 1);
|
||||
return this.updateTribeById(tribeId, { members });
|
||||
await this.updateTribeById(tribeId, { members });
|
||||
await this.rotateTribeKey(tribeId, members);
|
||||
},
|
||||
|
||||
async joinByInvite(code) {
|
||||
const ssb = await openSsb();
|
||||
const userId = ssb.id;
|
||||
const tribes = await this.listAll();
|
||||
const tribe = tribes.find(t => t.invites && t.invites.includes(code));
|
||||
if (!tribe) throw new Error('Invalid or expired invite code');
|
||||
if (tribe.members.includes(userId)) {
|
||||
let matchedTribe = null;
|
||||
let matchedInvite = null;
|
||||
for (const t of tribes) {
|
||||
if (!t.invites) continue;
|
||||
for (const inv of t.invites) {
|
||||
if (typeof inv === 'string' && inv === code) {
|
||||
matchedTribe = t; matchedInvite = inv; break;
|
||||
}
|
||||
if (typeof inv === 'object' && inv.code === code) {
|
||||
matchedTribe = t; matchedInvite = inv; break;
|
||||
}
|
||||
}
|
||||
if (matchedTribe) break;
|
||||
}
|
||||
if (!matchedTribe) throw new Error('Invalid or expired invite code');
|
||||
if (matchedTribe.members.includes(userId)) {
|
||||
throw new Error('Already a member of this tribe');
|
||||
}
|
||||
const members = [...tribe.members, userId];
|
||||
const invites = tribe.invites.filter(c => c !== code);
|
||||
const inviteLog = Array.isArray(tribe.inviteLog) ? tribe.inviteLog.map(entry =>
|
||||
entry.code === code
|
||||
? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() }
|
||||
: entry
|
||||
let storedTribeKey = null;
|
||||
let storedGen = 1;
|
||||
let storedRootId = null;
|
||||
if (tribeCrypto && typeof matchedInvite === 'object') {
|
||||
if (matchedInvite.ekChain) {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code);
|
||||
if (Array.isArray(chain) && chain.length) {
|
||||
for (const entry of chain) tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
|
||||
storedRootId = chain[0].rootId;
|
||||
storedTribeKey = chain[0].key;
|
||||
storedGen = chain[0].gen || 1;
|
||||
}
|
||||
} else if (matchedInvite.ek) {
|
||||
storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
|
||||
storedRootId = await this.getRootId(matchedTribe.id);
|
||||
storedGen = matchedInvite.gen || 1;
|
||||
tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen);
|
||||
}
|
||||
}
|
||||
const members = [...matchedTribe.members, userId];
|
||||
const invites = matchedTribe.invites.filter(inv => {
|
||||
if (typeof inv === 'string') return inv !== code;
|
||||
return inv.code !== code;
|
||||
});
|
||||
const inviteLog = Array.isArray(matchedTribe.inviteLog) ? matchedTribe.inviteLog.map(entry =>
|
||||
entry.code === code ? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() } : entry
|
||||
) : [];
|
||||
await this.updateTribeById(tribe.id, { members, invites, inviteLog });
|
||||
return tribe.id;
|
||||
await this.updateTribeById(matchedTribe.id, { members, invites, inviteLog });
|
||||
if (tribeCrypto && storedTribeKey && storedRootId) {
|
||||
const ssbKeys = require('../server/node_modules/ssb-keys');
|
||||
const memberKeys = {};
|
||||
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(storedTribeKey, userId, ssbKeys); } catch (_) {}
|
||||
if (matchedTribe.author && matchedTribe.author !== userId) {
|
||||
try { memberKeys[matchedTribe.author] = tribeCrypto.boxKeyForMember(storedTribeKey, matchedTribe.author, ssbKeys); } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
await new Promise((resolve) => {
|
||||
ssb.publish({ type: 'tribe-keys', tribeId: storedRootId, generation: storedGen, memberKeys }, () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.ensureFollowTribeMembers(matchedTribe.id).catch(() => {});
|
||||
return matchedTribe.id;
|
||||
},
|
||||
|
||||
async deleteTribeById(tribeId) {
|
||||
|
|
@ -154,7 +299,61 @@ module.exports = ({ cooler }) => {
|
|||
},
|
||||
|
||||
async updateTribeMembers(tribeId, members) {
|
||||
return this.updateTribeById(tribeId, { members });
|
||||
const tribe = await this.getTribeById(tribeId);
|
||||
const oldMembers = tribe.members || [];
|
||||
await this.updateTribeById(tribeId, { members });
|
||||
const removed = oldMembers.filter(m => !members.includes(m));
|
||||
const added = members.filter(m => !oldMembers.includes(m));
|
||||
if (removed.length > 0) {
|
||||
await this.rotateTribeKey(tribeId, members);
|
||||
} else if (added.length > 0) {
|
||||
await this.distributeTribeKey(tribeId, added);
|
||||
}
|
||||
},
|
||||
|
||||
async distributeTribeKey(tribeId, toMembers) {
|
||||
if (!tribeCrypto) return;
|
||||
const ssb = await openSsb();
|
||||
const ssbKeys = require('../server/node_modules/ssb-keys');
|
||||
const rootId = await this.getRootId(tribeId);
|
||||
const currentKey = tribeCrypto.getKey(rootId);
|
||||
if (!currentKey) return;
|
||||
const gen = tribeCrypto.getGen(rootId);
|
||||
const memberKeys = {};
|
||||
for (const memberId of toMembers) {
|
||||
try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {}
|
||||
}
|
||||
if (!Object.keys(memberKeys).length) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys }, (err, res) => err ? reject(err) : resolve(res));
|
||||
});
|
||||
await this.ensureFollowTribeMembers(tribeId).catch(() => {});
|
||||
},
|
||||
|
||||
async ensureTribeKeyDistribution(tribeId) {
|
||||
if (!tribeCrypto) return;
|
||||
const ssb = await openSsb();
|
||||
const userId = ssb.id;
|
||||
const tribe = await this.getTribeById(tribeId).catch(() => null);
|
||||
if (!tribe || tribe.author !== userId) return;
|
||||
const rootId = await this.getRootId(tribeId);
|
||||
const currentKey = tribeCrypto.getKey(rootId);
|
||||
if (!currentKey) return;
|
||||
const gen = tribeCrypto.getGen(rootId);
|
||||
const msgs = await new Promise((resolve, reject) => {
|
||||
pull(ssb.createLogStream({ limit: logLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m)));
|
||||
});
|
||||
const distributed = new Set();
|
||||
for (const m of msgs) {
|
||||
const c = m.value?.content;
|
||||
if (!c || c.type !== 'tribe-keys') continue;
|
||||
if (c.tribeId !== rootId) continue;
|
||||
if ((c.generation || 0) < gen) continue;
|
||||
for (const mid of Object.keys(c.memberKeys || {})) distributed.add(mid);
|
||||
}
|
||||
const members = Array.isArray(tribe.members) ? tribe.members : [];
|
||||
const missing = members.filter(m => m !== userId && !distributed.has(m));
|
||||
if (missing.length > 0) await this.distributeTribeKey(tribeId, missing);
|
||||
},
|
||||
|
||||
async publishUpdatedTribe(tribeId, updatedTribe) {
|
||||
|
|
@ -174,6 +373,7 @@ module.exports = ({ cooler }) => {
|
|||
inviteMode: updatedTribe.inviteMode,
|
||||
status: updatedTribe.status || 'OPEN',
|
||||
parentTribeId: updatedTribe.parentTribeId || null,
|
||||
mapUrl: updatedTribe.mapUrl || "",
|
||||
createdAt: updatedTribe.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
author: updatedTribe.author,
|
||||
|
|
@ -206,6 +406,7 @@ module.exports = ({ cooler }) => {
|
|||
inviteMode: tribe.content.inviteMode || 'strict',
|
||||
status: tribe.content.status || 'OPEN',
|
||||
parentTribeId: tribe.content.parentTribeId || null,
|
||||
mapUrl: tribe.content.mapUrl || "",
|
||||
createdAt: tribe.content.createdAt,
|
||||
updatedAt: tribe.content.updatedAt,
|
||||
author: tribe.content.author,
|
||||
|
|
@ -214,7 +415,12 @@ module.exports = ({ cooler }) => {
|
|||
},
|
||||
|
||||
async listAll() {
|
||||
const { tribes, tombstoned, tipByRoot } = await buildTribeIndex();
|
||||
const { tribes, tombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
|
||||
const resolveParent = (pid) => {
|
||||
if (!pid) return null;
|
||||
const root = rootByTip.get(pid) || pid;
|
||||
return tipByRoot.get(root) || pid;
|
||||
};
|
||||
const items = [];
|
||||
for (const [root, tip] of tipByRoot) {
|
||||
if (tombstoned.has(root) || tombstoned.has(tip)) continue;
|
||||
|
|
@ -234,7 +440,8 @@ module.exports = ({ cooler }) => {
|
|||
invites: Array.isArray(c.invites) ? c.invites : [],
|
||||
inviteMode: c.inviteMode || 'strict',
|
||||
status: c.status || 'OPEN',
|
||||
parentTribeId: c.parentTribeId || null,
|
||||
parentTribeId: resolveParent(c.parentTribeId),
|
||||
mapUrl: c.mapUrl || "",
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
author: c.author,
|
||||
|
|
@ -255,6 +462,148 @@ module.exports = ({ cooler }) => {
|
|||
return ids;
|
||||
},
|
||||
|
||||
async getRootId(tribeId) {
|
||||
const { parent } = await buildTribeIndex();
|
||||
let root = tribeId;
|
||||
while (parent.has(root)) root = parent.get(root);
|
||||
return root;
|
||||
},
|
||||
|
||||
async getAncestryChain(tribeId) {
|
||||
const rootId = await this.getRootId(tribeId);
|
||||
const tribe = await this.getTribeById(tribeId);
|
||||
const chain = [rootId];
|
||||
let currentTribe = tribe;
|
||||
while (currentTribe.parentTribeId) {
|
||||
const parentRootId = await this.getRootId(currentTribe.parentTribeId);
|
||||
chain.push(parentRootId);
|
||||
try {
|
||||
currentTribe = await this.getTribeById(currentTribe.parentTribeId);
|
||||
} catch (e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return chain;
|
||||
},
|
||||
|
||||
async rotateTribeKey(tribeId, remainingMembers) {
|
||||
if (!tribeCrypto) return;
|
||||
const ssb = await openSsb();
|
||||
const ssbKeys = require('../server/node_modules/ssb-keys');
|
||||
const rootId = await this.getRootId(tribeId);
|
||||
const oldKey = tribeCrypto.getKey(rootId);
|
||||
if (!oldKey) return;
|
||||
const newKey = tribeCrypto.generateTribeKey();
|
||||
const newGen = tribeCrypto.addNewKey(rootId, newKey);
|
||||
const memberKeys = {};
|
||||
for (const memberId of remainingMembers) {
|
||||
memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys);
|
||||
}
|
||||
const entries = Object.entries(memberKeys);
|
||||
const BATCH_SIZE = 20;
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const batch = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
|
||||
await new Promise((resolve, reject) => {
|
||||
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batch },
|
||||
(err, res) => err ? reject(err) : resolve(res));
|
||||
});
|
||||
}
|
||||
const tribe = await this.getTribeById(tribeId);
|
||||
if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
|
||||
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => [rootId]);
|
||||
const updatedInvites = tribe.invites.map(inv => {
|
||||
if (typeof inv === 'object' && inv.code) {
|
||||
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, inv.code);
|
||||
if (ekChain) return { code: inv.code, ekChain, gen: newGen };
|
||||
return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen };
|
||||
}
|
||||
return inv;
|
||||
});
|
||||
await this.updateTribeInvites(tribeId, updatedInvites);
|
||||
}
|
||||
},
|
||||
|
||||
async processIncomingKeys() {
|
||||
if (!tribeCrypto) return;
|
||||
const ssb = await openSsb();
|
||||
const ssbKeys = require('../server/node_modules/ssb-keys');
|
||||
const config = require('../server/ssb_config');
|
||||
const msgs = await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssb.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
|
||||
);
|
||||
});
|
||||
for (const m of msgs) {
|
||||
const c = m.value?.content;
|
||||
if (!c || c.type !== 'tribe-keys') continue;
|
||||
const myEntry = c.memberKeys && c.memberKeys[ssb.id];
|
||||
if (!myEntry) continue;
|
||||
const currentGen = tribeCrypto.getGen(c.tribeId);
|
||||
if (c.generation <= currentGen) continue;
|
||||
const newKey = tribeCrypto.unboxKeyFromMember(myEntry, config.keys, ssbKeys);
|
||||
if (newKey) {
|
||||
tribeCrypto.addNewKey(c.tribeId, newKey);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async ensureFollowTribeMembers(tribeId) {
|
||||
const ssb = await openSsb();
|
||||
const me = ssb.id;
|
||||
let tribe;
|
||||
try { tribe = await this.getTribeById(tribeId); } catch { return; }
|
||||
const rootId = await this.getRootId(tribeId).catch(() => tribeId);
|
||||
const tribeChainIds = await this.getChainIds(tribeId).catch(() => [tribeId]);
|
||||
const tribeRootSet = new Set([rootId]);
|
||||
const tribeChainSet = new Set(tribeChainIds);
|
||||
tribeChainSet.add(tribeId);
|
||||
const discovered = new Set();
|
||||
const myFollows = new Map();
|
||||
await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssb.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, msgs) => {
|
||||
if (err) return reject(err);
|
||||
for (const m of msgs) {
|
||||
const v = m.value;
|
||||
if (!v) continue;
|
||||
const c = v.content;
|
||||
if (!c) continue;
|
||||
if (v.author === me && c.type === 'contact' && c.contact && typeof c.following === 'boolean') {
|
||||
myFollows.set(c.contact, c.following);
|
||||
continue;
|
||||
}
|
||||
if (c.type === 'tribe-keys' && c.tribeId && tribeRootSet.has(c.tribeId) && c.memberKeys && typeof c.memberKeys === 'object') {
|
||||
for (const fid of Object.keys(c.memberKeys)) discovered.add(fid);
|
||||
if (v.author) discovered.add(v.author);
|
||||
continue;
|
||||
}
|
||||
if (c.type === 'tribe' && Array.isArray(c.members)) {
|
||||
if (tribeChainSet.has(m.key) || tribeChainSet.has(c.replaces || '')) {
|
||||
for (const fid of c.members) if (fid) discovered.add(fid);
|
||||
if (c.author) discovered.add(c.author);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
});
|
||||
const baseMembers = Array.isArray(tribe.members) ? tribe.members : [];
|
||||
for (const fid of baseMembers) discovered.add(fid);
|
||||
if (tribe.author) discovered.add(tribe.author);
|
||||
discovered.delete(me);
|
||||
const members = [...discovered].filter(Boolean);
|
||||
if (!members.length) return;
|
||||
for (const memberId of members) {
|
||||
if (myFollows.get(memberId) === true) continue;
|
||||
await new Promise((resolve) => {
|
||||
ssb.publish({ type: 'contact', contact: memberId, following: true }, () => resolve());
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async updateTribeById(tribeId, updatedContent) {
|
||||
const ssb = await openSsb();
|
||||
const tribe = await this.getTribeById(tribeId);
|
||||
|
|
@ -293,6 +642,70 @@ module.exports = ({ cooler }) => {
|
|||
const parentRoot = rootOf(parentId);
|
||||
const all = await this.listAll();
|
||||
return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
|
||||
},
|
||||
|
||||
async isTribeMember(userId, tribeId) {
|
||||
if (!userId || !tribeId) return false;
|
||||
try {
|
||||
const tribe = await this.getTribeById(tribeId);
|
||||
if (!tribe) return false;
|
||||
if (tribe.author === userId) return true;
|
||||
return Array.isArray(tribe.members) && tribe.members.includes(userId);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async canAccessTribe(userId, tribeId) {
|
||||
if (!userId || !tribeId) return false;
|
||||
try {
|
||||
const tribe = await this.getTribeById(tribeId);
|
||||
if (!tribe) return false;
|
||||
if (tribe.author === userId) return true;
|
||||
if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
|
||||
const effective = await this.getEffectiveStatus(tribeId);
|
||||
return !effective.isPrivate;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async getEffectiveStatus(tribeId) {
|
||||
let current;
|
||||
try { current = await this.getTribeById(tribeId); } catch (e) { return { isPrivate: true, chain: [] }; }
|
||||
const chain = [{ id: current.id, isAnonymous: !!current.isAnonymous, author: current.author }];
|
||||
let cursor = current;
|
||||
const seen = new Set([current.id]);
|
||||
while (cursor.parentTribeId && !seen.has(cursor.parentTribeId)) {
|
||||
seen.add(cursor.parentTribeId);
|
||||
try {
|
||||
cursor = await this.getTribeById(cursor.parentTribeId);
|
||||
chain.push({ id: cursor.id, isAnonymous: !!cursor.isAnonymous, author: cursor.author });
|
||||
} catch (e) { break; }
|
||||
}
|
||||
const isPrivate = chain.some(c => c.isAnonymous);
|
||||
return { isPrivate, chain };
|
||||
},
|
||||
|
||||
async listTribesForViewer(userId) {
|
||||
const all = await this.listAll();
|
||||
const out = [];
|
||||
for (const t of all) {
|
||||
if (!t.isAnonymous) { out.push(t); continue; }
|
||||
if (t.author === userId || (Array.isArray(t.members) && t.members.includes(userId))) out.push(t);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async getViewerTribeScope(userId) {
|
||||
const all = await this.listAll();
|
||||
const memberOf = new Set();
|
||||
const createdBy = new Set();
|
||||
for (const t of all) {
|
||||
if (t.author === userId) { createdBy.add(t.id); memberOf.add(t.id); continue; }
|
||||
if (Array.isArray(t.members) && t.members.includes(userId)) memberOf.add(t.id);
|
||||
}
|
||||
return { memberOf, createdBy, allTribes: all };
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.5",
|
||||
"description": "Oasis Social Networking Project Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ const cliArgs = ~i ? argv.slice(0, i) : argv;
|
|||
let config = Config('ssb', minimist(conf));
|
||||
config = { ...config, ...configData };
|
||||
|
||||
// Set blob size limit to 50MB
|
||||
const megabyte = Math.pow(2, 20);
|
||||
config.blobs = config.blobs || {};
|
||||
config.blobs.max = 50 * megabyte;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const generateFilterButtons = (filters, currentFilter, action, search = {}) =>
|
|||
);
|
||||
|
||||
const getViewDetailsAction = (type, block) => {
|
||||
if (block && block.content && typeof block.content.encryptedPayload === 'string') return null;
|
||||
switch (type) {
|
||||
case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
|
||||
case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`;
|
||||
|
|
@ -119,6 +120,10 @@ const getViewDetailsAction = (type, block) => {
|
|||
case 'courtsSettlementAccepted': return `/courts`;
|
||||
case 'courtsNomination': return `/courts`;
|
||||
case 'courtsNominationVote': return `/courts`;
|
||||
case 'calendarDate':
|
||||
case 'calendarNote': return block.content?.calendarId ? `/calendars/${encodeURIComponent(block.content.calendarId)}` : `/calendars`;
|
||||
case 'padEntry': return block.content?.padId ? `/pads/${encodeURIComponent(block.content.padId)}` : `/pads`;
|
||||
case 'chatMessage': return block.content?.roomId ? `/chats/${encodeURIComponent(block.content.roomId)}` : `/chats`;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -196,9 +201,10 @@ const renderBlockDiagram = (blocks, qs) => {
|
|||
].filter(Boolean).join(' | ') || '—';
|
||||
|
||||
const datagramQs = qs ? `${qs}&view=datagram` : '?view=datagram';
|
||||
const typeClass = `bd-type-${String(block.type || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
return a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}${datagramQs}`, class: 'block-diagram-link' },
|
||||
div({ class: 'block-diagram', style: `border-color:${color};` },
|
||||
div({ class: 'block-diagram-ruler', style: `border-bottom-color:${color};` },
|
||||
div({ class: `block-diagram ${typeClass}` },
|
||||
div({ class: 'block-diagram-ruler' },
|
||||
span('0'), span('4'), span('8'), span('16'), span('24'), span('31')
|
||||
),
|
||||
div({ class: 'block-diagram-grid' },
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
|
|||
const q = safeText(params.q || "")
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q })
|
||||
const isAuthor = String(chat.author) === String(userId)
|
||||
const isMember = safeArr(chat.members).includes(userId)
|
||||
const isMember = safeArr(chat.members).includes(userId) || (!!chat.tribeId && !!chat.isTribeMember)
|
||||
const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
|
||||
const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY"
|
||||
|
||||
|
|
@ -250,7 +250,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
|
|||
) : null
|
||||
),
|
||||
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
|
||||
isAuthor
|
||||
isAuthor && chat.status === "INVITE-ONLY"
|
||||
? form({ method: "POST", action: `/chats/generate-invite` },
|
||||
input({ type: "hidden", name: "chatId", value: chat.key }),
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ exports.padsView = async (pads, filter, padToEdit, params) => {
|
|||
|
||||
exports.singlePadView = async (pad, entries, params) => {
|
||||
const isAuthor = String(pad.author) === String(userId)
|
||||
const isMember = pad.members.includes(userId)
|
||||
const isMember = pad.members.includes(userId) || (!!pad.tribeId && !!pad.isTribeMember)
|
||||
const padClosed = pad.isClosed
|
||||
const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
|
||||
const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
|
||||
|
|
@ -231,7 +231,7 @@ exports.singlePadView = async (pad, entries, params) => {
|
|||
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
|
||||
),
|
||||
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
|
||||
isAuthor
|
||||
isAuthor && pad.status === "INVITE-ONLY"
|
||||
? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
|
||||
)
|
||||
|
|
@ -271,7 +271,7 @@ exports.singlePadView = async (pad, entries, params) => {
|
|||
)
|
||||
)
|
||||
: null,
|
||||
!isRestrictedInviteOnly && (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
|
||||
!isRestrictedInviteOnly && !isAuthor && !isMember && pad.status === "OPEN" && !padClosed
|
||||
? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
|
||||
button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ exports.statsView = (stats, filter) => {
|
|||
h2(title),
|
||||
p(description)
|
||||
),
|
||||
div({ class: 'mode-buttons' },
|
||||
div({ class: 'mode-buttons stats-grid' },
|
||||
modes.map(m =>
|
||||
form({ method: 'GET', action: '/stats' },
|
||||
input({ type: 'hidden', name: 'filter', value: m }),
|
||||
|
|
@ -105,15 +105,15 @@ exports.statsView = (stats, filter) => {
|
|||
),
|
||||
section(
|
||||
div({ style: headerStyle },
|
||||
h3({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsCreatedAt}: `, span({ style: 'color:#888;' }, stats.createdAt)),
|
||||
h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' },
|
||||
a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, style: 'color:#007bff; text-decoration:none;' }, stats.id)
|
||||
h3({ class: 'stats-h-row' }, `${i18n.statsCreatedAt}: `, span({ class: 'stats-muted-888' }, stats.createdAt)),
|
||||
h3({ class: 'stats-section-h' },
|
||||
a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, class: 'stats-link' }, stats.id)
|
||||
),
|
||||
div({ style: 'margin-bottom:16px;' },
|
||||
ul({ style: 'list-style-type:none; padding:0; margin:0;' },
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsBlobsSize}: `, span({ style: 'color:#888;' }, stats.statsBlobsSize)),
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsBlockchainSize}: `, span({ style: 'color:#888;' }, stats.statsBlockchainSize)),
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, strong(`${i18n.statsSize}: `, span({ style: 'color:#888;' }, span({ style: 'color:#555;' }, stats.folderSize))))
|
||||
div({ class: 'stats-mb-16' },
|
||||
ul({ class: 'stats-list-reset' },
|
||||
li({ class: 'stats-h-row' }, `${i18n.statsBlobsSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlobsSize)),
|
||||
li({ class: 'stats-h-row' }, `${i18n.statsBlockchainSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlockchainSize)),
|
||||
li({ class: 'stats-h-row' }, strong(`${i18n.statsSize}: `, span({ class: 'stats-muted-888' }, span({ class: 'stats-muted-555' }, stats.folderSize))))
|
||||
)
|
||||
)
|
||||
),
|
||||
|
|
@ -157,7 +157,7 @@ exports.statsView = (stats, filter) => {
|
|||
span(`${networkCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-network', style: 'width:100%;' })
|
||||
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
|
|
@ -182,7 +182,7 @@ exports.statsView = (stats, filter) => {
|
|||
span(`${networkCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-network', style: 'width:100%;' })
|
||||
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${tombPct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
|
|
@ -202,7 +202,7 @@ exports.statsView = (stats, filter) => {
|
|||
span(`${maxAnnualCO2} g CO₂`)
|
||||
),
|
||||
div({ class: 'carbon-bar-track' },
|
||||
div({ class: 'carbon-bar-fill carbon-bar-max', style: 'width:100%;' })
|
||||
div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' })
|
||||
),
|
||||
p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
|
||||
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
|
||||
|
|
@ -210,22 +210,22 @@ exports.statsView = (stats, filter) => {
|
|||
})()
|
||||
),
|
||||
div({ style: headerStyle },
|
||||
h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsBankingTitle),
|
||||
ul({ style: 'list-style-type:none; padding:0; margin:0;' },
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsEcoWalletLabel}: `, a({ href: '/wallet', style: 'color:#007bff; text-decoration:none; word-break:break-all;' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)),
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsTotalEcoAddresses}: `, span({ style: 'color:#888;' }, String(stats?.banking?.totalAddresses || 0)))
|
||||
h3({ class: 'stats-section-h' }, i18n.statsBankingTitle),
|
||||
ul({ class: 'stats-list-reset' },
|
||||
li({ class: 'stats-h-row' }, `${i18n.statsEcoWalletLabel}: `, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)),
|
||||
li({ class: 'stats-h-row' }, `${i18n.statsTotalEcoAddresses}: `, span({ class: 'stats-muted-888' }, String(stats?.banking?.totalAddresses || 0)))
|
||||
)
|
||||
),
|
||||
div({ style: headerStyle },
|
||||
h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsLogsTitle || 'Logs'),
|
||||
ul({ style: 'list-style-type:none; padding:0; margin:0;' },
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsLogsEntries || 'Entries'}: `, span({ style: 'color:#888;' }, String(stats?.logsCount || 0)))
|
||||
h3({ class: 'stats-section-h' }, i18n.statsLogsTitle || 'Logs'),
|
||||
ul({ class: 'stats-list-reset' },
|
||||
li({ class: 'stats-h-row' }, `${i18n.statsLogsEntries || 'Entries'}: `, span({ class: 'stats-muted-888' }, String(stats?.logsCount || 0)))
|
||||
)
|
||||
),
|
||||
div({ style: headerStyle },
|
||||
h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsAITraining),
|
||||
ul({ style: 'list-style-type:none; padding:0; margin:0;' },
|
||||
li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsAIExchanges}: `, span({ style: 'color:#888;' }, String(C(stats, 'aiExchange') || 0)))
|
||||
h3({ class: 'stats-section-h' }, i18n.statsAITraining),
|
||||
ul({ class: 'stats-list-reset' },
|
||||
li({ class: 'stats-h-row' }, `${i18n.statsAIExchanges}: `, span({ class: 'stats-muted-888' }, String(C(stats, 'aiExchange') || 0)))
|
||||
)
|
||||
),
|
||||
div({ style: headerStyle }, h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)),
|
||||
|
|
@ -233,7 +233,7 @@ exports.statsView = (stats, filter) => {
|
|||
? div({ class: 'stats-container' }, [
|
||||
div({ style: blockStyle },
|
||||
h2(i18n.statsActivity7d),
|
||||
table({ style: 'width:100%; border-collapse: collapse;' },
|
||||
table({ class: 'stats-table' },
|
||||
tr(th(i18n.day), th(i18n.messages)),
|
||||
...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
|
||||
),
|
||||
|
|
@ -242,7 +242,7 @@ exports.statsView = (stats, filter) => {
|
|||
),
|
||||
div({ style: blockStyle },
|
||||
h2(`${i18n.statsDiscoveredTribes}: ${stats.allTribesPublic.length}`),
|
||||
table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
|
||||
table({ class: 'stats-table-mt8' },
|
||||
...stats.allTribesPublic.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
|
||||
)
|
||||
),
|
||||
|
|
@ -317,7 +317,7 @@ exports.statsView = (stats, filter) => {
|
|||
? div({ class: 'stats-container' }, [
|
||||
div({ style: blockStyle },
|
||||
h2(i18n.statsActivity7d),
|
||||
table({ style: 'width:100%; border-collapse: collapse;' },
|
||||
table({ class: 'stats-table' },
|
||||
tr(th(i18n.day), th(i18n.messages)),
|
||||
...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
|
||||
),
|
||||
|
|
@ -326,14 +326,14 @@ exports.statsView = (stats, filter) => {
|
|||
),
|
||||
div({ style: blockStyle },
|
||||
h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribesDetailed.length}`),
|
||||
table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
|
||||
table({ class: 'stats-table-mt8' },
|
||||
...stats.memberTribesDetailed.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
|
||||
)
|
||||
),
|
||||
Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
|
||||
? div({ style: blockStyle },
|
||||
h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.myPrivateTribesDetailed.length}`),
|
||||
table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
|
||||
table({ class: 'stats-table-mt8' },
|
||||
...stats.myPrivateTribesDetailed.map(tp => tr(td(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name))))
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ const renderTorrentTable = (torrents, filter, params = {}) => {
|
|||
|
||||
const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
|
||||
const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
|
||||
const tribeId = safeText(params.tribeId || "");
|
||||
return div(
|
||||
{ class: "div-center audio-form" },
|
||||
form(
|
||||
|
|
@ -214,6 +215,7 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
|
|||
enctype: "multipart/form-data"
|
||||
},
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
|
||||
span(i18n.torrentFileLabel),
|
||||
br(),
|
||||
input({ type: "file", name: "torrent", accept: ".torrent", required: filter !== "edit" }),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe");
|
||||
const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td, thead, tbody, th } = require("../server/node_modules/hyperaxe");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const QRCode = require('../server/node_modules/qrcode');
|
||||
const { template, i18n } = require('./main_views');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
|
|
@ -201,7 +202,8 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
);
|
||||
|
||||
const isEdit = filter === 'edit' && tribeId;
|
||||
const tribeToEdit = isEdit ? tribes.find(t => t.id === tribeId) : {};
|
||||
const tribeToEdit = (isEdit ? tribes.find(t => t.id === tribeId) : null) || {};
|
||||
const isSubEdit = isEdit && !!tribeToEdit.parentTribeId;
|
||||
const createForm = (filter === 'create' || isEdit) ? div({ class: 'create-tribe-form' },
|
||||
h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle),
|
||||
form({
|
||||
|
|
@ -233,23 +235,25 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
br,
|
||||
input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }),
|
||||
br,
|
||||
label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
|
||||
br,
|
||||
select({ name: 'isAnonymous', id: 'isAnonymous' },
|
||||
isSubEdit ? null : label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
|
||||
isSubEdit ? null : br,
|
||||
isSubEdit ? null : select({ name: 'isAnonymous', id: 'isAnonymous' },
|
||||
option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate),
|
||||
option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
|
||||
),
|
||||
br(), br(),
|
||||
isSubEdit ? null : br(),
|
||||
isSubEdit ? null : br(),
|
||||
label({ for: 'inviteMode' }, i18n.tribeModeLabel),
|
||||
br,
|
||||
select({ name: 'inviteMode', id: 'inviteMode' },
|
||||
option({ value: 'strict', selected: tribeToEdit.inviteMode === 'strict' ? 'selected' : undefined }, i18n.tribeStrict),
|
||||
option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen)
|
||||
),
|
||||
br(), br(),
|
||||
label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
|
||||
br,
|
||||
select({ name: 'isLARP', id: 'isLARP' },
|
||||
isSubEdit ? null : br(),
|
||||
isSubEdit ? null : br(),
|
||||
isSubEdit ? null : label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
|
||||
isSubEdit ? null : br,
|
||||
isSubEdit ? null : select({ name: 'isLARP', id: 'isLARP' },
|
||||
option({ value: 'false', selected: tribeToEdit.isLARP !== true ? 'selected' : undefined }, i18n.tribeNo),
|
||||
option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes)
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue