feat: add QR codes for tribe invites, pub invites and user profile sharing

- tribes_view: renderInvitePage now shows QR of the invite code
- invites_view: snhInvite box shows QR of pub invite code
- inhabitants_view: user profile shows QR of SSB ID (own card + profile view)
- style.css: add QR code styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SITO 2026-04-25 02:46:48 +02:00
parent f88a179711
commit 54ad8a12fc
21655 changed files with 2101340 additions and 0 deletions

View file

@ -0,0 +1 @@
# Oasis Mobile v0.6.5

View file

@ -0,0 +1,18 @@
console.log('[Oasis Mobile] Node.js backend starting...');
process.env.HOME = process.env.HOME || '/data/data/com.solarnethub.oasis';
process.env.ssb_appname = process.env.ssb_appname || 'ssb';
const path = require('path');
const backendPath = path.join(__dirname, 'src', 'backend', 'backend.js');
console.log('[Oasis] Launching Oasis Social Network Utopia...');
console.log('[Oasis] Backend path:', backendPath);
console.log('[Oasis] HOME:', process.env.HOME);
try {
require(backendPath);
console.log('[Oasis] Backend started on port 3000');
} catch (error) {
console.error('[Oasis] Error starting backend:', error);
}

View file

@ -0,0 +1,199 @@
{
"name": "@krakenslab/oasis",
"version": "0.6.6",
"description": "Oasis Social Networking Project Utopia",
"repository": {
"type": "git",
"url": "git+ssh://git@code.03c8.net/krakenlabs/oasis.git"
},
"license": "AGPL-3.0",
"author": "psy <epsylon@riseup.net>",
"main": "src/index.js",
"bin": {
"oasis": "npm run start"
},
"scripts": {
"start": "npm run start:ssb && sleep 10 && npm run start:backend",
"start:backend": "node ../backend/backend.js",
"start:ssb": "node SSB_server.js start &",
"fix": "common-good fix",
"postinstall": "node ../../scripts/patch-node-modules.js",
"prestart": "",
"test": "tap --timeout 240 && common-good test",
"preversion": "npm test",
"version": "mv docs/CHANGELOG.md ./ && mv CHANGELOG.md docs/ && git add docs/CHANGELOG.md"
},
"dependencies": {
"@koa/router": "^13.1.0",
"@open-rpc/client-js": "^1.8.1",
"abstract-level": "^2.0.1",
"archiver": "^7.0.1",
"await-exec": "^0.1.2",
"axios": "^1.10.0",
"base64-url": "^2.3.3",
"broadcast-stream": "^0.2.1",
"caller-path": "^4.0.0",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"debug": "^4.3.1",
"env-paths": "^2.2.1",
"epidemic-broadcast-trees": "^9.0.4",
"express": "^5.1.0",
"file-type": "^16.5.4",
"gpt-3-encoder": "^1.1.4",
"has-network": "0.0.1",
"highlight.js": "11.0.0",
"hyperaxe": "^2.0.1",
"ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
"is-svg": "^4.4.0",
"is-valid-domain": "^0.1.6",
"koa": "^2.7.0",
"koa-body": "^6.0.1",
"koa-bodyparser": "^4.4.1",
"koa-mount": "^4.0.0",
"koa-static": "^5.0.0",
"lodash": "^4.17.21",
"lodash.shuffle": "^4.2.0",
"minimist": "^1.2.8",
"mkdirp": "^3.0.1",
"module-alias": "^2.2.3",
"moment": "^2.30.1",
"multiblob": "^1.13.0",
"multiserver": "^3.3.1",
"multiserver-address": "^1.0.1",
"muxrpc": "^8.0.0",
"muxrpc-validation": "^3.0.2",
"muxrpcli": "^3.1.2",
"node-iframe": "^1.8.5",
"node-llama-cpp": "^3.10.0",
"non-private-ip": "^2.2.0",
"open": "^8.4.2",
"packet-stream": "^2.0.6",
"packet-stream-codec": "^1.2.0",
"pdfjs-dist": "^5.2.133",
"piexifjs": "^1.0.4",
"pretty-ms": "^7.0.1",
"pull-abortable": "^4.1.1",
"pull-cat": "~1.1.5",
"pull-file": "^1.0.0",
"pull-many": "~1.0.6",
"pull-paramap": "^1.2.2",
"pull-pushable": "^2.2.0",
"pull-sort": "^1.0.2",
"pull-stream": "^3.7.0",
"punycode.js": "^2.3.1",
"qrcode": "^1.5.4",
"remark-html": "^16.0.1",
"require-style": "^1.1.0",
"secret-stack": "^6.3.1",
"ssb-about": "^2.0.1",
"ssb-autofollow": "^1.1.0",
"ssb-backlinks": "^2.1.1",
"ssb-blobs": "^2.0.1",
"ssb-box": "^1.0.1",
"ssb-caps": "^1.0.1",
"ssb-client": "^4.9.0",
"ssb-config": "^3.4.4",
"ssb-conn": "6.0.3",
"ssb-conn-db": "^1.0.5",
"ssb-conn-hub": "^1.2.0",
"ssb-conn-staging": "^1.0.0",
"ssb-db": "^20.4.1",
"ssb-device-address": "^1.1.6",
"ssb-ebt": "^9.1.2",
"ssb-friend-pub": "^1.0.7",
"ssb-friends": "^5.0.0",
"ssb-gossip": "^1.1.1",
"ssb-invite": "^3.0.3",
"ssb-invite-client": "^1.3.3",
"ssb-keys": "^8.0.0",
"ssb-lan": "^1.0.0",
"ssb-legacy-conn": "^1.0.17",
"ssb-links": "^3.0.10",
"ssb-local": "^1.0.0",
"ssb-logging": "^1.0.0",
"ssb-markdown": "^3.6.0",
"ssb-master": "^1.0.3",
"ssb-meme": "^1.1.0",
"ssb-mentions": "^0.5.2",
"ssb-msgs": "^5.2.0",
"ssb-no-auth": "^1.0.0",
"ssb-onion": "^1.0.0",
"ssb-partial-replication": "^3.0.1",
"ssb-plugins": "^1.0.2",
"ssb-private": "^1.1.0",
"ssb-query": "^2.4.5",
"ssb-ref": "^2.16.0",
"ssb-replication-scheduler": "^3.0.0",
"ssb-room": "^0.0.10",
"ssb-search": "^1.3.0",
"ssb-server": "file:packages/ssb-server",
"ssb-tangle": "^1.0.1",
"ssb-thread-schema": "^1.1.1",
"ssb-threads": "^10.0.4",
"ssb-tunnel": "^2.0.0",
"ssb-unix-socket": "^1.0.0",
"ssb-ws": "^6.2.3",
"tokenizers-linux-x64-gnu": "^0.13.4-rc1",
"unzipper": "^0.12.3",
"util": "^0.12.5",
"yargs": "^17.7.2"
},
"overrides": {
"caller-path": "^4.0.0",
"is-valid-domain": "^0.1.6",
"highlight.js": "11.0.0",
"@babel/traverse": "7.23.2",
"trim": "0.0.3",
"json5": "2.2.2",
"debug": "^4.3.1",
"postcss": "8.4.31",
"punycode": "2.3.1",
"ejs": "3.1.10",
"babel-traverse": "7.0.0-alpha.1",
"ssb-conn": "6.0.3",
"ssb-ref": "^2.16.0",
"secret-stack": "^6.3.1",
"ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
"lodash.set": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
},
"devDependencies": {
"@types/debug": "^4.1.5",
"@types/koa": "^2.11.3",
"@types/koa__router": "^12.0.4",
"@types/koa-mount": "^4.0.0",
"@types/koa-static": "^4.0.1",
"@types/lodash": "^4.14.150",
"@types/mkdirp": "^2.0.0",
"@types/nodemon": "^1.19.0",
"@types/pull-stream": "^3.6.0",
"@types/sharp": "^0.32.0",
"@types/supertest": "^6.0.2",
"@types/yargs": "^17.0.2",
"changelog-version": "^2.0.0",
"common-good": "^4.0.3",
"husky": "^9.1.7",
"nodemon": "^3.1.7",
"npm-force-resolutions": "^0.0.10",
"patch-package": "^8.0.0",
"stylelint-config-recommended": "^14.0.1",
"supertest": "^7.0.0",
"tap": "^21.0.1"
},
"optionalDependencies": {
"fsevents": "^2.3.2",
"sharp": "^0.33.5"
},
"bugs": {
"url": "https://code.03c8.net/KrakensLab/snh-oasis/issues"
},
"homepage": "https://code.03c8.net/KrakensLab/snh-oasis",
"directories": {
"doc": "docs"
},
"keywords": [],
"engines": {
"node": "^10.0.0 || >=12.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,258 @@
const pull = require('../server/node_modules/pull-stream');
const FileType = require("../server/node_modules/file-type");
const promisesFs = require('fs').promises;
const ssb = require("../client/gui");
const config = require("../server/SSB_server").config;
const cooler = ssb({ offline: config.offline });
let sharp;
try {
sharp = require("sharp");
} catch (e) {
}
const stripImageMetadata = async (buffer) => {
if (typeof sharp !== "function") return buffer;
try {
return await sharp(buffer).rotate().toBuffer();
} catch {
return buffer;
}
};
const PDF_METADATA_KEYS = [
'/Title', '/Author', '/Subject', '/Keywords',
'/Creator', '/Producer', '/CreationDate', '/ModDate'
];
const stripPdfMetadata = (buffer) => {
try {
let str = buffer.toString('binary');
for (const key of PDF_METADATA_KEYS) {
const keyBytes = key;
const regex = new RegExp(
keyBytes.replace(/\//g, '\\/') + '\\s*\\([^)]*\\)',
'g'
);
str = str.replace(regex, keyBytes + ' ()');
const hexRegex = new RegExp(
keyBytes.replace(/\//g, '\\/') + '\\s*<[^>]*>',
'g'
);
str = str.replace(hexRegex, keyBytes + ' <>');
}
return Buffer.from(str, 'binary');
} catch {
return buffer;
}
};
const MAX_BLOB_SIZE = 50 * 1024 * 1024;
class FileTooLargeError extends Error {
constructor(fileName, fileSize) {
super(`File too large: ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`);
this.name = 'FileTooLargeError';
this.fileName = fileName;
this.fileSize = fileSize;
}
}
const handleBlobUpload = async function (ctx, fileFieldName) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
return null;
}
const blobUpload = ctx.request.files[fileFieldName];
if (!blobUpload) return null;
let data = await promisesFs.readFile(blobUpload.filepath);
if (data.length === 0) return null;
if (data.length > MAX_BLOB_SIZE) {
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || fileFieldName, data.length);
}
const EXTENSION_MIME_MAP = {
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
'.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.bmp': 'image/bmp'
};
const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' };
try {
const fileType = await FileType.fromBuffer(data);
blob.mime = (fileType && fileType.mime) ? fileType.mime : null;
} catch {
blob.mime = null;
}
if (!blob.mime && blob.name) {
const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
}
if (!blob.mime) {
blob.mime = 'application/octet-stream';
}
if (blob.mime.startsWith('image/')) {
data = await stripImageMetadata(data);
} else if (blob.mime === 'application/pdf') {
data = stripPdfMetadata(data);
}
const ssbClient = await cooler.open();
blob.id = await new Promise((resolve, reject) => {
pull(
pull.values([data]),
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
);
});
if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`;
return `\n[${blob.name}](${blob.id})`;
};
function waitForBlob(ssbClient, blobId, timeoutMs = 60000) {
return new Promise((resolve, reject) => {
let done = false;
const finishOk = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
const finishErr = (err) => {
if (done) return;
done = true;
clearTimeout(timer);
reject(err);
};
const timer = setTimeout(() => {
finishErr(new Error(`Timeout waiting for blob ${blobId}`));
}, timeoutMs);
if (!ssbClient.blobs || typeof ssbClient.blobs.has !== 'function') {
return finishErr(new Error('ssb.blobs.has is not available'));
}
ssbClient.blobs.has(blobId, (err, has) => {
if (err) return finishErr(err);
if (has) return finishOk();
if (typeof ssbClient.blobs.want !== 'function') {
return finishErr(new Error('ssb.blobs.want is not available'));
}
ssbClient.blobs.want(blobId, (err2) => {
if (err2) return finishErr(err2);
finishOk();
});
});
});
}
const serveBlob = async function (ctx) {
const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
const raw = decodeURIComponent(encodedParam);
if (!raw) {
ctx.status = 400;
ctx.body = 'Invalid blob id';
return;
}
const blobId = raw.startsWith('&') ? raw : `&${raw}`;
const ssbClient = await cooler.open();
try {
await waitForBlob(ssbClient, blobId, 60000);
} catch (err) {
ctx.status = 504;
ctx.body = 'Blob not available';
return;
}
let buffer;
try {
buffer = await new Promise((resolve, reject) => {
pull(
ssbClient.blobs.get(blobId),
pull.collect((err, chunks) => {
if (err) return reject(err);
resolve(Buffer.concat(chunks));
})
);
});
} catch (err) {
ctx.status = 500;
ctx.body = 'Error reading blob';
return;
}
const size = buffer.length;
let mime = 'application/octet-stream';
try {
const ft = await FileType.fromBuffer(buffer);
if (ft && ft.mime) mime = ft.mime;
} catch {}
ctx.type = mime;
ctx.set('Content-Disposition', `inline; filename="${raw}"`);
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
const range = ctx.headers.range;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
let start = match[1] ? parseInt(match[1], 10) : 0;
let end = match[2] ? parseInt(match[2], 10) : size - 1;
if (Number.isNaN(start) || start < 0) start = 0;
if (Number.isNaN(end) || end >= size) end = size - 1;
if (start > end || start >= size) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
const chunk = buffer.slice(start, end + 1);
ctx.status = 206;
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(chunk.length));
ctx.body = chunk;
} else {
ctx.status = 200;
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(size));
ctx.body = buffer;
}
};
module.exports = { handleBlobUpload, serveBlob, FileTooLargeError };

View file

@ -0,0 +1,118 @@
const fs = require("fs");
const path = require("path");
const FILE = path.join(__dirname, "../configs/media-favorites.json");
const DEFAULT = {
audios: [],
bookmarks: [],
documents: [],
images: [],
videos: []
};
const safeArr = (v) => (Array.isArray(v) ? v : []);
let queue = Promise.resolve();
const withLock = (fn) => {
queue = queue.then(fn, fn);
return queue;
};
const normalize = (raw) => {
const out = {};
for (const k of Object.keys(DEFAULT)) {
const list = safeArr(raw?.[k]).map((x) => String(x || "").trim()).filter(Boolean);
out[k] = Array.from(new Set(list));
}
return out;
};
const ensureFile = async () => {
try {
await fs.promises.access(FILE);
} catch (e) {
const dir = path.dirname(FILE);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8");
}
};
const readAll = async () => {
await ensureFile();
try {
const txt = await fs.promises.readFile(FILE, "utf8");
return normalize(JSON.parse(txt || "{}"));
} catch (e) {
const fixed = normalize(DEFAULT);
await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8");
return fixed;
}
};
const writeAll = async (data) => {
const dir = path.dirname(FILE);
const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`);
const txt = JSON.stringify(normalize(data), null, 2);
await fs.promises.writeFile(tmp, txt, "utf8");
await fs.promises.rename(tmp, FILE);
};
const assertKind = (kind) => {
const k = String(kind || "").trim();
if (!Object.prototype.hasOwnProperty.call(DEFAULT, k)) throw new Error("Invalid favorites kind");
return k;
};
const lastArg = (args, n) => args[args.length - n];
const kindFromArgs = (args) => {
const k = String(lastArg(args, 1) || "").trim();
return assertKind(k);
};
const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
exports.getFavoriteSet = async (kind) => {
const k = assertKind(kind);
const data = await readAll();
return new Set(safeArr(data[k]).map(String));
};
exports.addFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
const favId = String(id || "").trim();
if (!favId) return;
const data = await readAll();
const set = new Set(safeArr(data[k]).map(String));
set.add(favId);
data[k] = Array.from(set);
await writeAll(data);
});
exports.removeFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
const favId = String(id || "").trim();
if (!favId) return;
const data = await readAll();
const set = new Set(safeArr(data[k]).map(String));
set.delete(favId);
data[k] = Array.from(set);
await writeAll(data);
});
exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
exports.addToFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.addFavorite(kind, id);
};
exports.removeFromFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.removeFavorite(kind, id);
};

View file

@ -0,0 +1,50 @@
const positive = [
"interesting",
"necessary",
"useful",
"informative",
"wellResearched",
"accurate",
"insightful",
"actionable",
"creative",
"inspiring",
"love",
"funny",
"clear",
"uplifting"
];
const constructive = [
"unnecessary",
"rejected",
"needsSources",
"wrong",
"lowQuality",
"confusing",
"misleading",
"offTopic",
"duplicate",
"clickbait",
"propaganda"
];
const moderation = [
"spam",
"troll",
"adultOnly",
"nsfw",
"violent",
"toxic",
"harassment",
"hate",
"scam",
"triggering"
];
const all = [...positive, ...constructive, ...moderation];
all.positive = positive;
all.constructive = constructive;
all.moderation = moderation;
module.exports = all;

View file

@ -0,0 +1,48 @@
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderTextWithStyles(text) {
if (!text) return ''
const i18n = getI18n()
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
`<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
)
.replace(/\[video:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></video>`
)
.replace(/\[audio:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></audio>`
)
.replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
)
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
)
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
)
.replace(/#(\w+)/g, (_, tag) =>
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${tag}</a>`
)
.replace(/(https?:\/\/[^\s]+)/g, url =>
`<a href="${url}" target="_blank" class="styled-link">${url}</a>`
)
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
`<a href="mailto:${email}" class="styled-link">${email}</a>`
)
}
module.exports = { renderTextWithStyles }

View file

@ -0,0 +1,92 @@
const { a, img, video, audio } = require("../server/node_modules/hyperaxe");
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderUrl(text) {
if (typeof text !== 'string') return [text];
const blobImageRegex = /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobVideoRegex = /\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobAudioRegex = /\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobPdfRegex = /\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const mdMentionRegex = /\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
const rawMentionRegex = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
const allMatches = [];
for (const m of text.matchAll(blobImageRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-image', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobVideoRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-video', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobAudioRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-audio', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobPdfRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-pdf', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(mdMentionRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'md-mention', name: m[1], feedId: m[2] });
}
for (const m of text.matchAll(rawMentionRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'raw-mention', feedId: m[1] });
}
for (const m of text.matchAll(urlRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'url', text: m[0] });
}
for (const m of text.matchAll(emailRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'email', text: m[0] });
}
allMatches.sort((a, b) => a.index - b.index);
const filtered = [];
let lastEnd = 0;
for (const m of allMatches) {
if (m.index < lastEnd) continue;
filtered.push(m);
lastEnd = m.index + m.length;
}
const result = [];
let cursor = 0;
for (const m of filtered) {
if (cursor < m.index) {
result.push(text.slice(cursor, m.index));
}
if (m.type === 'blob-image') {
result.push(img({ src: `/blob/${encodeURIComponent(m.blob)}`, alt: m.name || '', class: 'post-image' }));
} else if (m.type === 'blob-video') {
result.push(video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(m.blob)}` }));
} else if (m.type === 'blob-audio') {
result.push(audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(m.blob)}` }));
} else if (m.type === 'blob-pdf') {
const i18n = getI18n();
const label = m.name || i18n.pdfFallbackLabel || 'PDF';
result.push(a({ href: `/blob/${encodeURIComponent(m.blob)}`, class: 'post-pdf', target: '_blank' }, label));
} else if (m.type === 'md-mention') {
const feedWithAt = '@' + m.feedId;
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.name));
} else if (m.type === 'raw-mention') {
const feedWithAt = '@' + m.feedId;
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...'));
} else if (m.type === 'url') {
const href = m.text.startsWith('http') ? m.text : `https://${m.text}`;
result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
} else if (m.type === 'email') {
result.push(a({ href: `mailto:${m.text}` }, m.text));
}
cursor = m.index + m.length;
}
if (cursor < text.length) {
result.push(text.slice(cursor));
}
return result;
}
module.exports = { renderUrl };

View file

@ -0,0 +1,52 @@
"use strict";
let purify;
try {
const { JSDOM } = require('../server/node_modules/jsdom');
const DOMPurify = require('../server/node_modules/dompurify');
const window = new JSDOM('').window;
purify = DOMPurify(window);
} catch (e) {
purify = null;
}
function regexSanitize(input, allowedTags) {
if (typeof input !== 'string') return '';
const tagSet = new Set(allowedTags);
return input
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/on\w+\s*=\s*[^\s>]*/gi, '')
.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tag) => {
return tagSet.has(tag.toLowerCase()) ? match.replace(/\s+(on\w+|style)\s*=\s*["'][^"']*["']/gi, '') : '';
});
}
const STRIP_TAGS = ['p','br','b','strong','i','em','u','ul','ol','li','blockquote','code','pre'];
const SANITIZE_TAGS = ['p','br','hr','b','strong','i','em','u','s','del','ul','ol','li','blockquote','code','pre','a','span','div','h1','h2','h3','h4','h5','h6','img','video','audio','table','thead','tbody','tr','th','td'];
const stripDangerousTags = (input) => {
if (typeof input !== 'string') return '';
if (purify) return purify.sanitize(input, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: STRIP_TAGS, ALLOWED_ATTR: [],
FORBID_TAGS: ['svg','math','iframe','object','embed','form','input','textarea','select','button','script'],
FORBID_ATTR: ['style']
});
return regexSanitize(input, STRIP_TAGS);
};
const sanitizeHtml = (input) => {
if (typeof input !== 'string') return '';
if (purify) return purify.sanitize(input, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: SANITIZE_TAGS,
ALLOWED_ATTR: ['href','class','target','rel','src','alt','title','controls'],
FORBID_TAGS: ['svg','math','iframe','object','embed','form','input','textarea','select','button','script','style','link','meta'],
FORBID_ATTR: ['onerror','onload','onclick','onmouseover','onfocus','onblur','onsubmit','onchange','style']
});
return regexSanitize(input, SANITIZE_TAGS);
};
module.exports = { stripDangerousTags, sanitizeHtml };

View file

@ -0,0 +1,121 @@
const fetch = require('../server/node_modules/node-fetch');
const { existsSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
const { join } = require('path');
const localpackage = join(__dirname, '../server/package.json');
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json'; // Official SNH-Oasis
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json'; // Mirror SNH-Oasis
let printed = false;
async function extractVersionFromText(text) {
try {
const versionMatch = text.match(/"version":\s*"([^"]+)"/);
if (versionMatch) {
return versionMatch[1];
} else {
throw new Error('Version not found in the response.');
}
} catch (error) {
console.error("Error extracting version:", error.message);
return null;
}
}
async function diffVersion(body, callback) {
try {
const remoteData = JSON.parse(body);
const remoteVersion = remoteData.version;
const localData = JSON.parse(readFileSync(localpackage, 'utf8'));
const localVersion = localData.version;
const updateFlagPath = join(__dirname, "../server/.update_required");
if (remoteVersion !== localVersion) {
writeFileSync(updateFlagPath, JSON.stringify({ required: true }));
callback("required");
} else {
if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
callback(""); // no updates required
}
} catch (error) {
console.error("Error comparing versions:", error.message);
callback("error");
}
}
async function checkMirror(callback) {
try {
const response = await fetch(remoteUrl2, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Referer': 'https://raw.githubusercontent.com',
'Origin': 'https://raw.githubusercontent.com'
}
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.text();
callback(null, data);
} catch (error) {
console.error("\noasis@version: no updates requested.\n");
callback(error);
}
}
exports.getRemoteVersion = async () => {
if (existsSync('../../.git')) {
try {
const response = await fetch(remoteUrl, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Referer': 'https://code.03c8.net',
'Origin': 'https://code.03c8.net'
}
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.text();
diffVersion(data, (status) => {
if (status === "required" && !printed) {
printed = true;
console.log("\noasis@version: new code updates are available!\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n");
} else if (status === "") {
console.log("\noasis@version: no updates requested.\n");
}
});
} catch (error) {
checkMirror((err, data) => {
if (err) {
console.error("\noasis@version: no updates requested.\n");
} else {
diffVersion(data, (status) => {
if (status === "required" && !printed) {
printed = true;
console.log("\noasis@version: new code updates are available!\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n");
} else {
console.log("oasis@version: no updates requested.\n");
}
});
}
});
}
}
};

View file

@ -0,0 +1,17 @@
const fs = require("fs");
const path = require("path");
const STORAGE_DIR = path.join(__dirname, "..", "configs");
const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
function ensure() {
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
}
function readAll() { ensure(); return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); }
function writeAll(m) { fs.writeFileSync(ADDR_PATH, JSON.stringify(m, null, 2)); }
async function getAddress(userId) { const m = readAll(); return m[userId] || null; }
async function setAddress(userId, address) { const m = readAll(); m[userId] = address; writeAll(m); return true; }
module.exports = { getAddress, setAddress };

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<title>oasis favicon</title>
<text x="0" y="14">🏝️</text>
</svg>

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -0,0 +1,67 @@
.hljs-comment,
.hljs-quote {
color: var(--base03);
}
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: var(--base08);
}
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: var(--base09);
}
.hljs-attribute {
color: var(--base0A);
}
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: var(--base0B);
}
.hljs-title,
.hljs-section {
color: var(--base0D);
}
.hljs-keyword,
.hljs-selector-tag {
color: var(--base0E);
}
.hljs {
display: block;
overflow-x: auto;
color: var(--base05);
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-string, .hljs-link {
background: #FFA500;
user-select: text;
}

View file

@ -0,0 +1,498 @@
html {
-webkit-text-size-adjust: 100%;
box-sizing: border-box;
overflow-x: hidden !important;
}
*, *::before, *::after {
box-sizing: inherit;
}
body {
font-size: 15px;
overflow-x: hidden;
max-width: 100vw;
}
img,
video,
canvas,
iframe,
table {
max-width: 100% !important;
}
pre,
code {
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.header {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
padding: 4px !important;
gap: 4px !important;
overflow: visible !important;
}
.header-content {
display: flex !important;
flex-wrap: wrap !important;
width: 100% !important;
max-width: 100% !important;
padding: 2px !important;
gap: 4px !important;
overflow: visible !important;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
align-items: stretch !important;
gap: 6px !important;
}
.top-bar-left nav ul,
.top-bar-mid nav ul,
.top-bar-right nav ul {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
.top-bar-left nav ul li,
.top-bar-mid nav ul li,
.top-bar-right nav ul li {
margin: 0 !important;
}
.top-bar-left nav ul li a,
.top-bar-mid nav ul li a,
.top-bar-right nav ul li a {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 6px 8px !important;
font-size: 0.9rem !important;
white-space: nowrap !important;
}
.search-input,
.feed-search-input,
.activity-search-input {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
height: 40px !important;
font-size: 16px !important;
}
.main-content {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
gap: 12px !important;
}
.sidebar-left,
.sidebar-right,
.main-column {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
padding: 10px !important;
border-left: none !important;
border-right: none !important;
}
.sidebar-left {
order: 1 !important;
}
.main-column {
order: 3 !important;
}
.sidebar-right {
order: 2 !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex !important;
flex-direction: column !important;
}
.oasis-nav-header {
font-size: 0.85rem !important;
}
.oasis-nav-list li a {
font-size: 0.9rem !important;
padding: 8px 12px !important;
}
button,
input[type="submit"],
input[type="button"],
.filter-btn,
.create-button,
.edit-btn,
.delete-btn,
.join-btn,
.leave-btn,
.buy-btn {
min-height: 44px !important;
font-size: 16px !important;
white-space: normal !important;
text-align: center !important;
}
.feed-row,
.comment-body-row,
table {
display: block !important;
width: 100% !important;
overflow-x: auto !important;
}
textarea,
input,
select {
width: 100% !important;
max-width: 100% !important;
font-size: 16px !important;
}
.gallery {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 8px !important;
}
footer,
.footer {
display: block !important;
width: 100% !important;
max-width: 100% !important;
padding: 12px !important;
overflow-x: auto !important;
}
footer div {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
}
h1 { font-size: 1.35em !important; }
h2 { font-size: 1.2em !important; }
h3 { font-size: 1em !important; }
.small,
.time {
font-size: 0.8rem !important;
}
.created-at {
display: block !important;
width: 100% !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
line-height: 1.5 !important;
font-size: 0.8rem !important;
}
.header-content .created-at {
display: block !important;
flex: 1 1 100% !important;
min-width: 0 !important;
max-width: 100% !important;
}
.post-meta,
.feed-post-meta,
.feed-row .small,
.feed-row .time,
.feed-row .created-at {
display: block !important;
width: 100% !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
line-height: 1.4 !important;
}
.post-meta,
.feed-post-meta {
flex-direction: column !important;
gap: 4px !important;
}
.mode-buttons {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 8px !important;
grid-template-columns: 1fr !important;
}
.mode-buttons-cols {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 8px !important;
}
.mode-buttons-row {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 8px !important;
}
.mode-buttons .column,
.mode-buttons > div {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 6px !important;
grid-template-columns: 1fr !important;
}
.mode-buttons form {
width: 100% !important;
}
.mode-buttons .filter-btn,
.mode-buttons button {
width: 100% !important;
}
.filter-group {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 6px !important;
}
.filter-group form {
width: 100% !important;
}
.inhabitant-card {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
}
.inhabitant-left {
width: 100% !important;
text-align: center !important;
}
.inhabitant-details {
width: 100% !important;
}
.inhabitant-photo,
.inhabitant-photo-details {
max-width: 200px !important;
margin: 0 auto !important;
}
.inhabitants-list,
.inhabitants-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.tribe-card {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
}
.tribe-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
grid-template-columns: 1fr !important;
}
.tribes-list,
.tribes-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.tribe-card-image {
width: 100% !important;
max-width: 100% !important;
}
.tribe-section-nav {
overflow-x: auto !important;
flex-wrap: nowrap !important;
-webkit-overflow-scrolling: touch;
}
.tribe-section-group {
flex-shrink: 0 !important;
}
.tribe-section-btn {
font-size: 11px !important;
padding: 4px 8px !important;
white-space: nowrap !important;
}
.tribe-details {
flex-direction: column !important;
}
.tribe-side {
width: 100% !important;
}
.tribe-main {
width: 100% !important;
}
.tribe-content-grid {
grid-template-columns: 1fr !important;
}
.tribe-inhabitants-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-media-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-overview-grid {
grid-template-columns: 1fr !important;
}
.tribe-mode-buttons {
flex-wrap: wrap !important;
}
.tribe-card-hero-image {
height: 180px !important;
}
.tribe-votation-option {
flex-direction: column !important;
align-items: flex-start !important;
}
.tribe-content-header {
flex-direction: column !important;
gap: 8px !important;
}
.tribe-content-filters {
flex-wrap: wrap !important;
}
.tribe-media-filters {
flex-wrap: wrap !important;
}
.forum-card {
flex-direction: column !important;
}
.forum-score-col,
.forum-main-col,
.root-vote-col {
width: 100% !important;
}
.forum-header-row {
flex-direction: column !important;
gap: 4px !important;
}
.forum-meta {
flex-wrap: wrap !important;
gap: 8px !important;
}
.forum-thread-header {
flex-direction: column !important;
}
.forum-comment {
margin-left: 0 !important;
padding-left: 8px !important;
}
.comment-body-row {
flex-direction: column !important;
}
.comment-vote-col,
.comment-text-col {
width: 100% !important;
}
.forum-score-box,
.forum-score-form {
flex-direction: row !important;
justify-content: center !important;
}
.new-message-form textarea,
.comment-textarea {
width: 100% !important;
}
[style*="grid-template-columns: repeat(6"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns: repeat(3"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(6"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(3"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns: repeat(auto-fit"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(auto-fit"] {
grid-template-columns: 1fr !important;
}
[style*="width:50%"] {
width: 100% !important;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,444 @@
body {
background-color: #F9F9F9 !important;
color: #2C2C2C !important;
font-family: 'Roboto', sans-serif !important;
}
.main-column {
background-color: #FFFFFF !important;
border: 1px solid #E0E0E0 !important;
}
button, input[type="submit"], input[type="button"] {
background-color: #FF6F00 !important;
color: #FFFFFF !important;
border: none !important;
border-radius: 6px !important;
padding: 10px 20px !important;
cursor: pointer !important;
font-weight: 600 !important;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #FF8F00 !important;
}
input, textarea, select {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
border: 1px solid #E0E0E0 !important;
border-radius: 4px !important;
padding: 8px !important;
font-size: 16px !important;
}
a {
color: #007BFF !important;
text-decoration: none !important;
}
a:hover {
text-decoration: underline !important;
}
p {
color: black !important;
text-decoration: none !important;
}
.created-at, .about-time, .time {
font-size: 0.9rem;
color: black;
}
table {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
width: 100% !important;
border-collapse: collapse !important;
}
table th {
background-color: #F8F8F8 !important;
padding: 12px 15px !important;
text-align: left !important;
font-weight: 600 !important;
}
table tr:nth-child(even) {
background-color: #FAFAFA !important;
}
table td {
padding: 12px 15px !important;
}
.profile {
background-color: #FFFFFF !important;
padding: 20px !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
.profile .name {
color: #FF6F00 !important;
font-size: 20px !important;
font-weight: 700 !important;
}
.avatar {
border: 3px solid #FF6F00 !important;
border-radius: 50% !important;
width: 60px !important;
height: 60px !important;
}
article, section {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
padding: 20px !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
}
.post-preview img {
border-radius: 8px !important;
max-width: 100% !important;
height: auto !important;
}
.post-preview .image-container {
max-width: 100% !important;
overflow: hidden !important;
display: block !important;
margin: 0 auto !important;
}
div {
background-color: #FFFFFF !important;
border: 1px solid #E0E0E0 !important;
}
div .header-content {
width: 100% !important;
}
::-webkit-scrollbar {
width: 8px !important;
}
::-webkit-scrollbar-thumb {
background-color: #B0B0B0 !important;
border-radius: 8px !important;
}
::-webkit-scrollbar-track {
background-color: #F9F9F9 !important;
}
.action-container {
background-color: #FFFFFF !important;
border: 1px solid #E0E0E0 !important;
padding: 20px !important;
border-radius: 8px !important;
color: #2C2C2C !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
}
footer {
background-color: #FFFFFF !important;
border-top: 1px solid #E0E0E0 !important;
padding: 15px 0 !important;
}
footer a {
background-color: #007BFF !important;
color: #FFFFFF !important;
padding: 10px 20px !important;
border-radius: 6px !important;
text-decoration: none !important;
font-weight: 600 !important;
}
footer a:hover {
background-color: #0056b3 !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex !important;
flex-direction: column !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
margin: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
display: block;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
display: block;
width: 100%;
padding: 12px 16px;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
background-color: #ffffff !important;
color: #2C2C2C !important;
border: 1px solid #D0D0D0 !important;
text-align: left;
box-sizing: border-box;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #f0f0f0 !important;
}
.filter-btn,
.create-button,
.edit-btn,
.delete-btn,
.join-btn,
.leave-btn,
.buy-btn {
background-color: #FF6F00 !important;
color: #FFFFFF !important;
border: none !important;
}
.filter-btn:hover,
.create-button:hover {
background-color: #FF8F00 !important;
color: #FFFFFF !important;
}
.card {
border-radius: 16px;
padding: 16px 24px;
margin-bottom: 24px;
color: #4A4A4A;
font-family: inherit;
box-shadow: 0 4px 30px 0 rgba(0, 0, 0, 0.1);
background-color: #F4F4F4;
}
.card-section {
border: none;
padding: 16px 0 0 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
margin-top: 16px;
padding-top: 0px;
border: none;
}
.card-label {
color: #2D2D2D;
font-weight: bold;
letter-spacing: 1.5px;
line-height: 1.2;
margin-bottom: 8px;
}
.card-footer {
margin-top: 12px;
font-weight: 500;
color: #6C6C6C;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 12px;
background: none;
border: none;
padding-top: 0;
margin-bottom: 12px;
}
.card-body {
margin-top: 12px;
margin-bottom: 16px;
padding: 0;
}
.card-field {
display: flex;
align-items: baseline;
padding: 0;
margin-bottom: 8px;
border: none;
background: none;
}
.card-tags {
margin: 5px 0 3px 0;
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.card-tags a.tag-link {
text-decoration: none;
color: #181818;
background: #D94F4F;
padding: 5px 13px 4px 13px;
border-radius: 7px;
font-size: .98em;
border: none;
font-weight: bold;
}
.card-tags a.tag-link:hover {
background: #D94F4F;
color: #111;
cursor: pointer;
}
a.user-link {
background-color: #FFD600;
color: #FFFFFF;
padding: 12px 24px;
border-radius: 5px;
text-align: center;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid #FFD600;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
font-size: 1em;
}
a.user-link:hover {
background-color: #FFD600;
border-color: #FFD600;
color: #FFFFFF;
cursor: pointer;
}
a.user-link:focus {
background-color: #9A2F2F;
border-color: #9A2F2F;
color: #FFFFFF;
}
.date-link {
background-color: #2F3C32;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
margin-left: 12px;
}
.date-link:hover {
background-color: #3E4A3D;
color: #fff;
}
.activitySpreadInhabitant2 {
background-color: #3E4A3D;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.activityVotePost {
background-color: #3B5C42;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.update-banner {
background-color: #FFF3E0 !important;
border-bottom-color: #FFE0B2 !important;
color: #E65100 !important;
}
.update-banner-link {
color: #FF6F00 !important;
}
.snh-invite-code {
color: #007BFF !important;
}
.carbon-bar-track {
background: #e0e0e0 !important;
}
.carbon-bar-max {
background: #ccc !important;
}
/* Blockexplorer */
.blockchain-view { background-color: #F4F4F4 !important; color: #2C2C2C !important; }
.block { background: #FFFFFF !important; box-shadow: 0 2px 12px rgba(0,0,0,0.06) !important; }
.block:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.10) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #2D2D2D !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #007BFF !important; }
.block-content-preview, .block-content { background: #F8F8F8 !important; color: #2C2C2C !important; }
.block-author { color: #FF6F00 !important; background: rgba(255,111,0,0.08) !important; }
.block-author:hover { color: #FF8F00 !important; }
.block-url { color: #007BFF !important; }
.block-row--details .block-url { background: #F0F0F0 !important; }
.block-row--details .block-url:hover { background: #E0E0E0 !important; color: #FF6F00 !important; }
.btn-singleview { background: #F0F0F0 !important; color: #FF6F00 !important; }
.btn-singleview:hover { background: #E0E0E0 !important; color: #FF8F00 !important; }
.btn-back { background: #FF6F00 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; }
.block-diagram { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
.block-diagram-ruler { color: #2D2D2D !important; background: #F8F8F8 !important; border-bottom-color: #E0E0E0 !important; }
.block-diagram-ruler span { color: #2D2D2D !important; }
.block-diagram-cell { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
.bd-label { color: #2D2D2D !important; }
.bd-value { color: #007BFF !important; }
.deleted-label { color: #D32F2F !important; }
/* Tribes */
.tribe-card { background: #FFFFFF !important; border-color: #E0E0E0 !important; }
.tribe-card:hover { border-color: #007BFF !important; }
.tribe-card-title { color: #2D2D2D !important; }
.tribe-card-description { color: #555 !important; }
.tribe-info-table td { border-color: #E0E0E0 !important; }
.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; }
.tribe-action-btn { border-color: #FF6F00 !important; color: #FF6F00 !important; }
.tribe-action-btn:hover { background: #FF6F00 !important; color: #fff !important; }
.tribe-subtribe-link { background: #F0F0F0 !important; border-color: #E0E0E0 !important; color: #FF6F00 !important; }
.tribe-thumb-link { border-color: #E0E0E0 !important; }
.tribe-thumb-link:hover { border-color: #FF6F00 !important; }
.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; }

View file

@ -0,0 +1,359 @@
body {
background-color: #121212;
color: #FFD700;
}
header, footer {
background-color: #1F1F1F;
}
.sidebar-left, .sidebar-right {
background-color: #1A1A1A;
border: 1px solid #333;
}
.main-column {
background-color: #1C1C1C;
}
button, input[type="submit"], input[type="button"] {
background-color: #444;
color: #FFD700;
border: 1px solid #444;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #333;
border-color: #666;
}
input, textarea, select {
background-color: #333;
color: #FFD700;
border: 1px solid #555;
}
a {
color: #FFD700;
}
a:hover {
color: #FFDD44;
}
table {
background-color: #222;
color: #FFD700;
}
table th {
background-color: #333;
}
table tr:nth-child(even) {
background-color: #2A2A2A;
}
nav ul li a:hover {
color: #FFDD44;
text-decoration: underline;
}
.profile {
background-color: #222;
padding: 15px;
border-radius: 8px;
}
.profile .name {
color: #FFD700;
}
.avatar {
border: 3px solid #FFD700;
}
article, section {
background-color: #1C1C1C;
color: #FFD700;
}
.article img {
border: 3px solid #FFD700;
}
.post-preview img {
border: 3px solid #FFD700;
}
.post-preview .image-container {
max-width: 100%;
overflow: hidden;
display: block;
margin: 0 auto;
}
div {
background-color: #1A1A1A;
border: 1px solid #333;
}
div .header-content {
width: 100%;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #444;
border-radius: 10px;
}
::-webkit-scrollbar-track {
background-color: #222;
}
.action-container {
background-color: #1A1A1A;
border: 1px solid #333;
padding: 10px;
border-radius: 8px;
color: #FFD700;
}
footer {
background-color: #1F1F1F;
padding: 10px 0;
}
footer a {
background-color: #444;
color: #FFD700;
text-align: center;
padding: 8px 16px;
border-radius: 5px;
text-decoration: none;
}
footer a:hover {
background-color: #333;
color: #FFDD44;
}
.card {
border-radius: 16px;
padding: 0px 24px 10px 24px;
margin-bottom: 16px;
color: #FFD600;
font-family: inherit;
box-shadow: 0 2px 20px 0 #FFD60024;
}
.card-section {
border:none;
padding: 10px 0 0 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0px;
margin-top: 8px;
padding-top: 0px;
border: none;
}
.card-label {
color: #ffa300;
font-weight: bold;
letter-spacing: 1.5px;
line-height: 1.2;
margin-bottom: 0;
}
.card-footer {
margin-top: 6px;
font-weight: 500;
color: #ff9900;
font-size: 1.07em;
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding-top: 0;
margin-bottom: 6;
}
.card-body {
margin-top: 0;
margin-bottom: 4;
padding: 0;
}
.card-field {
display: flex;
align-items: baseline;
padding: 0;
margin-bottom: 0;
border: none;
background: none;
}
.card-tags {
margin: 5px 0 3px 0;
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.card-tags a.tag-link {
text-decoration: none;
color: #181818;
background: #FFD600;
padding: 5px 13px 4px 13px;
border-radius: 7px;
font-size: .98em;
border: none;
font-weight: bold;
}
.card-tags a.tag-link:hover {
background: #ffe86a;
color: #111;
cursor: pointer;
}
a.user-link {
background-color: #FFA500;
color: #000;
padding: 8px 16px;
border-radius: 5px;
text-align: center;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
font-size: 0.8em;
}
a.user-link:hover {
background-color: #FFD700;
border-color: #FFD700;
color: #000;
cursor: pointer;
}
a.user-link:focus {
background-color: #007B9F;
border-color: #007B9F;
color: #fff;
}
.date-link {
background-color: #444;
color: #FFD600;
padding: 8px 16px;
border-radius: 5px;
margin-left: 8px;
}
.date-link:hover {
background-color: #555;
color: #FFD700;
}
.activitySpreadInhabitant2 {
background-color: #007B9F;
color: #fff;
padding: 8px 16px;
border-radius: 5px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.activityVotePost {
background-color: #557d3b;
color: #fff;
padding: 8px 16px;
border-radius: 5px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.update-banner {
background-color: #1a1400;
border-bottom-color: #3a2e00;
color: #FFD700;
}
.update-banner-link {
color: #FFD700;
}
.oasis-footer-center a {
color: #FFA500;
}
.oasis-footer-center a:hover {
color: #FFD700;
}
.snh-invite-code {
color: #FFA500 !important;
}
/* Blockexplorer */
.blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
.block { background: #23242a !important; }
.block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #ffa300 !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFD700 !important; }
.block-content-preview, .block-content { background: #222326 !important; color: #FFD700 !important; }
.block-author { color: #FFD700 !important; background: rgba(255,163,0,0.08) !important; }
.block-author:hover { color: #FFDD44 !important; }
.block-url { color: #FFD700 !important; }
.block-row--details .block-url { background: #1f2023 !important; }
.block-row--details .block-url:hover { background: #292b36 !important; color: #ffa300 !important; }
.btn-singleview { background: #1e1f23 !important; color: #ffa300 !important; }
.btn-singleview:hover { background: #2d2e34 !important; color: #FFD700 !important; }
.btn-back { background: #21232b !important; color: #ffa300 !important; }
.btn-back:hover { background: #292b36 !important; color: #FFD700 !important; }
.block-info-table td, .pm-info-table td { border-color: #444 !important; }
.block-diagram { border-color: #555 !important; background: #191b20 !important; }
.block-diagram-ruler { color: #ffa300 !important; background: #111 !important; border-bottom-color: #555 !important; }
.block-diagram-ruler span { color: #ffa300 !important; }
.block-diagram-cell { border-color: #555 !important; background: #1e1f23 !important; }
.bd-label { color: #ffa300 !important; }
.bd-value { color: #FFD700 !important; }
/* Tribes */
.tribe-card { background: #23242a !important; border-color: #444 !important; }
.tribe-card:hover { border-color: #ffa300 !important; }
.tribe-card-description { color: #cfd3e1 !important; }
.tribe-info-table td { border-color: #444 !important; }
.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; }
.tribe-action-btn { border-color: #ffa300 !important; color: #ffa300 !important; }
.tribe-action-btn:hover { background: #ffa300 !important; color: #000 !important; }
.tribe-subtribe-link { background: #191b20 !important; border-color: #444 !important; color: #ffa300 !important; }
.tribe-thumb-link { border-color: #444 !important; }
.tribe-thumb-link:hover { border-color: #ffa300 !important; }
.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; }

View file

@ -0,0 +1,455 @@
body {
background-color: #000000 !important;
color: #00FF00 !important;
font-family: 'Courier New', monospace;
}
header, footer {
background-color: #000000;
color: #00FF00;
padding: 20px;
text-align: center;
font-size: 18px;
}
footer {
border-top: 2px solid #00FF00;
}
.sidebar-left, .sidebar-right {
background-color: #000000;
color: #00FF00;
border-right: 2px solid #00FF00;
padding: 15px;
margin-bottom: 10px;
}
.main-column {
background-color: #000000;
color: #00FF00;
padding: 20px;
border-left: 2px solid #00FF00;
margin-bottom: 20px;
}
button, input[type="submit"], input[type="button"] {
background-color: #000000;
color: #00FF00;
border: 2px solid #00FF00;
border-radius: 8px;
padding: 10px 20px;
cursor: pointer;
font-family: 'Courier New', monospace;
text-transform: none;
font-size: 16px;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #00FF00;
color: #000000;
border-color: #00FF00;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
}
input, textarea, select {
background-color: #1A1A1A;
color: #00FF00;
border: 1px solid #00FF00;
border-radius: 5px;
padding: 10px;
font-family: 'Courier New', monospace;
font-size: 16px;
}
a {
color: #00FF00;
text-decoration: none;
font-weight: normal;
font-size: 16px;
}
table {
background-color: #000000;
color: #00FF00;
width: 100%;
border: 1px solid #00FF00;
font-size: 16px;
}
table th {
background-color: #00FF00;
color: #000000;
}
table tr:nth-child(even) {
background-color: #1A1A1A;
}
nav ul {
background-color: #000000;
padding: 0;
margin: 0;
}
nav ul li {
display: inline-block;
margin-right: 10px;
}
nav ul li a {
color: #00FF00;
padding: 10px;
display: inline-block;
font-family: 'Courier New', monospace;
text-transform: none;
font-size: 16px;
}
.profile {
background-color: #1A1A1A;
color: #00FF00;
padding: 20px;
border-radius: 8px;
border: 1px solid #00FF00;
box-shadow: 0 2px 5px rgba(0,255,0,0.5);
}
.profile .name {
color: #00FF00;
font-size: 18px;
font-weight: bold;
}
.avatar {
border: 4px solid #00FF00;
border-radius: 50%;
width: 80px;
height: 80px;
margin-bottom: 10px;
}
article, section {
background-color: #1A1A1A;
color: #00FF00;
padding: 20px;
border-radius: 10px;
border: 1px solid #00FF00;
box-shadow: 0 4px 10px rgba(0, 255, 0, 0.5);
}
.post-preview {
background-color: #00FF00;
padding: 15px;
border-radius: 8px;
color: #000000;
}
.post-preview img {
border-radius: 8px;
max-width: 100%;
}
.post-preview .image-container {
display: block;
margin: 0 auto;
max-width: 100%;
overflow: hidden;
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-thumb {
background-color: #00FF00;
border-radius: 10px;
}
::-webkit-scrollbar-track {
background-color: #000000;
}
.action-container {
background-color: #1A1A1A;
border: 2px solid #00FF00;
padding: 20px;
border-radius: 8px;
color: #00FF00;
box-shadow: 0 2px 5px rgba(0, 255, 0, 0.5);
}
footer a {
background-color: #00FF00;
color: #000000;
padding: 8px 20px;
border-radius: 5px;
text-decoration: none;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
background-color: #000000 !important;
border: 2px solid #00FF00 !important;
padding: 12px 16px;
box-shadow: 0 0 12px #00FF00;
border-radius: 8px;
display: flex;
gap: 12px;
}
.sidebar-left,
.sidebar-right {
background-color: #000000 !important;
border: 2px solid #00FF00 !important;
box-shadow: 0 0 15px #00FF00;
padding: 16px;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
background-color: #000000 !important;
color: #00FF00 !important;
border: 1px solid #00FF00 !important;
font-weight: bold;
border-radius: 6px;
padding: 10px 14px;
display: flex;
justify-content: flex-start;
box-shadow: 0 0 6px #00FF00;
transition: background-color 0.3s ease, color 0.3s ease;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #00FF00 !important;
color: #000000 !important;
}
.card {
border-radius: 16px;
padding: 16px 24px;
margin-bottom: 24px;
color: #00FF00;
font-family: 'Courier New', monospace;
box-shadow: 0 4px 30px 0 rgba(0, 255, 0, 0.2);
background-color: #1A1A1A;
border: 1px solid #00FF00;
}
.card-section {
border: none;
padding: 16px 0 0 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
margin-top: 16px;
padding-top: 0px;
border: none;
}
.card-label {
color: #00FF00;
font-weight: bold;
letter-spacing: 1.5px;
line-height: 1.2;
margin-bottom: 8px;
}
.card-footer {
margin-top: 12px;
font-weight: 500;
color: #00FF00;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 12px;
background: none;
border: none;
padding-top: 0;
margin-bottom: 12px;
}
.card-body {
margin-top: 12px;
margin-bottom: 16px;
padding: 0;
}
.card-field {
display: flex;
align-items: baseline;
padding: 0;
margin-bottom: 8px;
border: none;
background: none;
}
.card-tags {
margin: 5px 0 3px 0;
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.card-tags a.tag-link {
text-decoration: none;
color: #00FF00;
background: #000000;
padding: 5px 13px 4px 13px;
border-radius: 7px;
font-size: .98em;
border: none;
font-weight: bold;
}
.card-tags a.tag-link:hover {
background: #00FF00;
color: #000000;
cursor: pointer;
}
a.user-link {
background-color: #00FF00;
color: #000000;
padding: 12px 24px;
border-radius: 5px;
text-align: center;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid #00FF00;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
font-size: 1em;
}
a.user-link:hover {
background-color: #000000;
border-color: #00FF00;
color: #00FF00;
cursor: pointer;
}
a.user-link:focus {
background-color: #00FF00;
border-color: #00FF00;
color: #000000;
}
.date-link {
background-color: #00FF00;
color: #000000;
padding: 12px 24px;
border-radius: 8px;
margin-left: 12px;
}
.date-link:hover {
background-color: #000000;
color: #00FF00;
}
.activitySpreadInhabitant2 {
background-color: #1A1A1A;
color: #00FF00;
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.activityVotePost {
background-color: #00FF00;
color: #000000;
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.update-banner {
background-color: #001a00;
border-bottom-color: #003300;
color: #00FF00;
}
.update-banner-link {
color: #00FF00;
}
.snh-invite-code {
color: #00FF00 !important;
}
/* Blockexplorer */
.blockchain-view { background-color: #000000 !important; color: #00FF00 !important; }
.block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; }
.block:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #00FF00 !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #00FF00 !important; }
.block-content-preview, .block-content { background: #1A1A1A !important; color: #00FF00 !important; }
.block-author { color: #00FF00 !important; background: rgba(0,255,0,0.08) !important; }
.block-author:hover { color: #00FF00 !important; }
.block-url { color: #00FF00 !important; }
.block-row--details .block-url { background: #1A1A1A !important; }
.block-row--details .block-url:hover { background: #00FF00 !important; color: #000000 !important; }
.btn-singleview { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
.btn-singleview:hover { background: #00FF00 !important; color: #000000 !important; }
.btn-back { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
.btn-back:hover { background: #00FF00 !important; color: #000000 !important; }
.block-info-table td, .pm-info-table td { border-color: #00FF00 !important; }
.block-diagram { border-color: #00FF00 !important; background: #000000 !important; }
.block-diagram-ruler { color: #00FF00 !important; background: #000000 !important; border-bottom-color: #00FF00 !important; }
.block-diagram-ruler span { color: #00FF00 !important; }
.block-diagram-cell { border-color: #00FF00 !important; background: #1A1A1A !important; }
.bd-label { color: #00FF00 !important; }
.bd-value { color: #00FF00 !important; }
.deleted-label { color: #ff3333 !important; }
/* Tribes */
.tribe-card { background: #1A1A1A !important; border-color: #00FF00 !important; }
.tribe-card:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
.tribe-card-title { color: #00FF00 !important; }
.tribe-card-description { color: #00FF00 !important; }
.tribe-info-table td { border-color: #00FF00 !important; }
.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; }
.tribe-action-btn { border-color: #00FF00 !important; color: #00FF00 !important; }
.tribe-action-btn:hover { background: #00FF00 !important; color: #000 !important; }
.tribe-subtribe-link { background: #000000 !important; border-color: #00FF00 !important; color: #00FF00 !important; }
.tribe-thumb-link { border-color: #00FF00 !important; }
.tribe-thumb-link:hover { box-shadow: 0 0 10px rgba(0,255,0,0.3) !important; }
.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; }

View file

@ -0,0 +1,765 @@
body {
background-color: #121212;
color: #FFD700;
}
header, footer {
background-color: #1F1F1F;
}
.sidebar-left, .sidebar-right {
background-color: #1A1A1A;
border: 1px solid #333;
}
.main-column {
background-color: #1C1C1C;
}
button, input[type="submit"], input[type="button"] {
background-color: #444;
color: #FFD700;
border: 1px solid #444;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #333;
border-color: #666;
}
input, textarea, select {
background-color: #333;
color: #FFD700;
border: 1px solid #555;
}
a {
color: #FFD700;
}
a:hover {
color: #FFDD44;
}
table {
background-color: #222;
color: #FFD700;
}
table th {
background-color: #333;
}
table tr:nth-child(even) {
background-color: #2A2A2A;
}
nav ul li a:hover {
color: #FFDD44;
text-decoration: underline;
}
.profile {
background-color: #222;
padding: 15px;
border-radius: 8px;
}
.profile .name {
color: #FFD700;
}
.avatar {
border: 3px solid #FFD700;
}
article, section {
background-color: #1C1C1C;
color: #FFD700;
}
.article img {
border: 3px solid #FFD700;
}
.post-preview img {
border: 3px solid #FFD700;
}
.post-preview .image-container {
max-width: 100%;
overflow: hidden;
display: block;
margin: 0 auto;
}
div {
background-color: #1A1A1A;
border: 1px solid #333;
}
div .header-content {
width: 100%;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #444;
border-radius: 10px;
}
::-webkit-scrollbar-track {
background-color: #222;
}
.action-container {
background-color: #1A1A1A;
border: 1px solid #333;
padding: 10px;
border-radius: 8px;
color: #FFD700;
}
footer {
background-color: #1F1F1F;
padding: 10px 0;
}
footer a {
background-color: #444;
color: #FFD700;
text-align: center;
padding: 8px 16px;
border-radius: 5px;
text-decoration: none;
}
footer a:hover {
background-color: #333;
color: #FFDD44;
}
.card {
border-radius: 16px;
padding: 0px 24px 10px 24px;
margin-bottom: 16px;
color: #FFD600;
font-family: inherit;
box-shadow: 0 2px 20px 0 #FFD60024;
}
.card-section {
border:none;
padding: 10px 0 0 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0px;
margin-top: 8px;
padding-top: 0px;
border: none;
}
.card-label {
color: #ffa300;
font-weight: bold;
letter-spacing: 1.5px;
line-height: 1.2;
margin-bottom: 0;
}
.card-footer {
margin-top: 6px;
font-weight: 500;
color: #ff9900;
font-size: 1.07em;
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding-top: 0;
margin-bottom: 6;
}
.card-body {
margin-top: 0;
margin-bottom: 4;
padding: 0;
}
.card-field {
display: flex;
align-items: baseline;
padding: 0;
margin-bottom: 0;
border: none;
background: none;
}
.card-tags {
margin: 5px 0 3px 0;
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.card-tags a.tag-link {
text-decoration: none;
color: #181818;
background: #FFD600;
padding: 5px 13px 4px 13px;
border-radius: 7px;
font-size: .98em;
border: none;
font-weight: bold;
}
.card-tags a.tag-link:hover {
background: #ffe86a;
color: #111;
cursor: pointer;
}
a.user-link {
background-color: #FFA500;
color: #000;
padding: 8px 16px;
border-radius: 5px;
text-align: center;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
font-size: 0.8em;
}
a.user-link:hover {
background-color: #FFD700;
border-color: #FFD700;
color: #000;
cursor: pointer;
}
a.user-link:focus {
background-color: #007B9F;
border-color: #007B9F;
color: #fff;
}
.date-link {
background-color: #444;
color: #FFD600;
padding: 8px 16px;
border-radius: 5px;
margin-left: 8px;
}
.date-link:hover {
background-color: #555;
color: #FFD700;
}
.activitySpreadInhabitant2 {
background-color: #007B9F;
color: #fff;
padding: 8px 16px;
border-radius: 5px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.activityVotePost {
background-color: #557d3b;
color: #fff;
padding: 8px 16px;
border-radius: 5px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
html {
-webkit-text-size-adjust: 100%;
box-sizing: border-box;
overflow-x: hidden !important;
}
*, *::before, *::after {
box-sizing: inherit;
}
body {
font-size: 15px;
overflow-x: hidden;
max-width: 100vw;
}
img,
video,
canvas,
iframe,
table {
max-width: 100% !important;
}
pre,
code {
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.header {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
padding: 4px !important;
gap: 4px !important;
overflow: visible !important;
}
.header-content {
display: flex !important;
flex-wrap: wrap !important;
width: 100% !important;
max-width: 100% !important;
padding: 2px !important;
gap: 4px !important;
overflow: visible !important;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
align-items: stretch !important;
gap: 6px !important;
}
.top-bar-left nav ul,
.top-bar-mid nav ul,
.top-bar-right nav ul {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
.top-bar-left nav ul li,
.top-bar-mid nav ul li,
.top-bar-right nav ul li {
margin: 0 !important;
}
.top-bar-left nav ul li a,
.top-bar-mid nav ul li a,
.top-bar-right nav ul li a {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 6px 8px !important;
font-size: 0.9rem !important;
white-space: nowrap !important;
}
.search-input,
.feed-search-input,
.activity-search-input {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
height: 40px !important;
font-size: 16px !important;
}
.main-content {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
gap: 12px !important;
}
.sidebar-left,
.sidebar-right,
.main-column {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
padding: 10px !important;
border-left: none !important;
border-right: none !important;
}
.sidebar-left {
order: 1 !important;
}
.main-column {
order: 3 !important;
}
.sidebar-right {
order: 2 !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex !important;
flex-direction: column !important;
}
.oasis-nav-header {
font-size: 0.85rem !important;
}
.oasis-nav-list li a {
font-size: 0.9rem !important;
padding: 8px 12px !important;
}
button,
input[type="submit"],
input[type="button"],
.filter-btn,
.create-button,
.edit-btn,
.delete-btn,
.join-btn,
.leave-btn,
.buy-btn {
min-height: 44px !important;
font-size: 16px !important;
white-space: normal !important;
text-align: center !important;
}
.feed-row,
.comment-body-row,
table {
display: block !important;
width: 100% !important;
overflow-x: auto !important;
}
textarea,
input,
select {
width: 100% !important;
max-width: 100% !important;
font-size: 16px !important;
}
.gallery {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 8px !important;
}
footer,
.footer {
display: block !important;
width: 100% !important;
max-width: 100% !important;
padding: 12px !important;
overflow-x: auto !important;
}
footer div {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
}
h1 { font-size: 1.35em !important; }
h2 { font-size: 1.2em !important; }
h3 { font-size: 1em !important; }
.small,
.time {
font-size: 0.8rem !important;
}
.created-at {
display: block !important;
width: 100% !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
line-height: 1.5 !important;
font-size: 0.8rem !important;
}
.header-content .created-at {
display: block !important;
flex: 1 1 100% !important;
min-width: 0 !important;
max-width: 100% !important;
}
.post-meta,
.feed-post-meta,
.feed-row .small,
.feed-row .time,
.feed-row .created-at {
display: block !important;
width: 100% !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
line-height: 1.4 !important;
}
.post-meta,
.feed-post-meta {
flex-direction: column !important;
gap: 4px !important;
}
.mode-buttons {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 8px !important;
grid-template-columns: 1fr !important;
}
.mode-buttons-cols {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 8px !important;
}
.mode-buttons-row {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 8px !important;
}
.mode-buttons .column,
.mode-buttons > div {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 6px !important;
grid-template-columns: 1fr !important;
}
.mode-buttons form {
width: 100% !important;
}
.mode-buttons .filter-btn,
.mode-buttons button {
width: 100% !important;
}
.filter-group {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
gap: 6px !important;
}
.filter-group form {
width: 100% !important;
}
.inhabitant-card {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
}
.inhabitant-left {
width: 100% !important;
text-align: center !important;
}
.inhabitant-details {
width: 100% !important;
}
.inhabitant-photo,
.inhabitant-photo-details {
max-width: 200px !important;
margin: 0 auto !important;
}
.inhabitants-list,
.inhabitants-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.tribe-card {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
}
.tribe-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
grid-template-columns: 1fr !important;
}
.tribes-list,
.tribes-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.tribe-card-image {
width: 100% !important;
max-width: 100% !important;
}
.forum-card {
flex-direction: column !important;
}
.forum-score-col,
.forum-main-col,
.root-vote-col {
width: 100% !important;
}
.forum-header-row {
flex-direction: column !important;
gap: 4px !important;
}
.forum-meta {
flex-wrap: wrap !important;
gap: 8px !important;
}
.forum-thread-header {
flex-direction: column !important;
}
.forum-comment {
margin-left: 0 !important;
padding-left: 8px !important;
}
.comment-body-row {
flex-direction: column !important;
}
.comment-vote-col,
.comment-text-col {
width: 100% !important;
}
.forum-score-box,
.forum-score-form {
flex-direction: row !important;
justify-content: center !important;
}
.new-message-form textarea,
.comment-textarea {
width: 100% !important;
}
[style*="grid-template-columns: repeat(6"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns: repeat(3"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(6"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(3"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns: repeat(auto-fit"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(auto-fit"] {
grid-template-columns: 1fr !important;
}
[style*="width:50%"] {
width: 100% !important;
}
.update-banner {
background-color: #1a1400;
border-bottom-color: #3a2e00;
color: #FFD700;
}
.update-banner-link {
color: #FFD700;
}
.snh-invite-code {
color: #FFA500 !important;
word-break: break-all !important;
}
body div.header {
flex-direction: row !important;
flex-wrap: wrap !important;
gap: 2px !important;
align-items: center !important;
padding: 4px 2px !important;
}
body div.top-bar-left {
display: contents !important;
}
body div.top-bar-left > a.logo-icon {
flex: 0 0 100% !important;
}
body div.top-bar-left > nav,
body div.top-bar-left > nav > ul,
body div.top-bar-right,
body div.top-bar-right nav,
body div.top-bar-right nav ul {
display: contents !important;
}
body div.top-bar-left nav ul li a,
body div.top-bar-right nav ul li a {
padding: 5px 5px !important;
font-size: 0.82rem !important;
min-height: auto !important;
border: none !important;
}

View file

@ -0,0 +1,490 @@
body {
background-color: #4B0A6D;
color: #E5E5E5;
font-family: 'Arial', sans-serif;
}
footer {
border-top: 2px solid #9B1C96;
}
.sidebar-left, .sidebar-right {
background-color: #39006D;
border: 1px solid #9B1C96;
color: #E5E5E5;
padding: 15px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.main-column {
background-color: #1A1A1A;
border: 1px solid #9B1C96;
padding: 20px;
color: #E5E5E5;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
button, input[type="submit"], input[type="button"] {
background-color: #9B1C96;
color: #E5E5E5;
border: 2px solid #6A0066;
border-radius: 10px;
padding: 12px 24px;
cursor: pointer;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #6A0066;
border-color: #9B1C96;
box-shadow: 0 0 15px rgba(155, 28, 150, 0.8);
}
input, textarea, select {
background-color: #333333;
color: #E5E5E5;
border: 1px solid #9B1C96;
border-radius: 5px;
padding: 12px;
}
input:focus, textarea:focus, select:focus {
background-color: #39006D;
outline: none;
}
a {
color: #9B1C96;
text-decoration: none;
}
a:hover {
color: #E5E5E5;
}
table {
background-color: #1A1A1A;
color: #E5E5E5;
width: 100%;
border-collapse: collapse;
}
table th {
background-color: #6A0066;
}
table tr:nth-child(even) {
background-color: #333333;
}
nav ul {
background-color: #39006D;
padding: 0;
margin: 0;
list-style: none;
}
nav ul li {
display: inline-block;
margin-right: 10px;
}
nav ul li a {
color: #E5E5E5;
padding: 15px;
display: inline-block;
text-transform: none;
font-weight: bold;
letter-spacing: 0;
}
.profile {
background-color: #333333;
color: #E5E5E5;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.profile .name {
color: #9B1C96;
font-size: 20px;
font-weight: bold;
}
.avatar {
border: 4px solid #9B1C96;
border-radius: 50%;
width: 80px;
height: 80px;
margin-bottom: 10px;
}
article, section {
background-color: #333333;
color: #E5E5E5;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.post-preview {
background-color: #39006D;
padding: 15px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.post-preview img {
border-radius: 8px;
max-width: 100%;
}
.post-preview .image-container {
display: block;
margin: 0 auto;
max-width: 100%;
overflow: hidden;
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-thumb {
background-color: #9B1C96;
border-radius: 10px;
}
::-webkit-scrollbar-track {
background-color: #1A1A1A;
}
.action-container {
background-color: #333333;
border: 2px solid #9B1C96;
padding: 20px;
border-radius: 8px;
color: #E5E5E5;
box-shadow: 0 2px 5px rgba(0,0,0,0.5);
}
footer a {
background-color: #9B1C96;
color: #FFFFFF;
padding: 8px 20px;
border-radius: 5px;
text-decoration: none;
}
footer a:hover {
background-color: #6A0066;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
background-color: #682B94 !important;
color: #FFE082 !important;
border: 1px solid #B86ADE !important;
font-weight: 600;
border-radius: 6px;
padding: 12px 16px;
display: flex;
justify-content: flex-start;
box-sizing: border-box;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #682B94 !important;
border-color: #FFD54F !important;
color: #FFFFFF !important;
}
body {
background-color: #2D0B47 !important;
}
.main-column,
article,
section,
.action-container,
.profile,
.post-preview {
background-color: #3C1360 !important;
border-color: #B86ADE !important;
color: #FFEEDB !important;
}
input,
textarea,
select {
background-color: #4B1A72 !important;
color: #FFFFFF !important;
border-color: #BB5EFF !important;
}
button,
input[type="submit"],
input[type="button"] {
background-color: #A34AD8 !important;
color: #FFFFFF !important;
border-color: #751E9F !important;
}
header {
background-color: #5A1A85 !important;
border-bottom: 1px solid #B86ADE !important;
box-shadow: none !important;
}
.header {
background-color: #5A1A85 !important;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
background-color: #39006D !important;
border: 1px solid #9B1C96 !important;
padding: 15px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
display: flex;
gap: 12px;
border-radius: 10px;
}
.sidebar-left,
.sidebar-right {
padding: 16px;
box-sizing: border-box;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
}
.card {
border-radius: 16px;
padding: 16px 24px;
margin-bottom: 24px;
color: #E5E5E5;
font-family: inherit;
box-shadow: 0 4px 30px 0 rgba(0, 0, 0, 0.2);
background-color: #3C1360;
border: 1px solid #B86ADE;
}
.card-section {
border: none;
padding: 16px 0 0 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
margin-top: 16px;
padding-top: 0px;
border: none;
}
.card-label {
color: #9B1C96;
font-weight: bold;
letter-spacing: 1.5px;
line-height: 1.2;
margin-bottom: 8px;
}
.card-footer {
margin-top: 12px;
font-weight: 500;
color: #B86ADE;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 12px;
background: none;
border: none;
padding-top: 0;
margin-bottom: 12px;
}
.card-body {
margin-top: 12px;
margin-bottom: 16px;
padding: 0;
}
.card-field {
display: flex;
align-items: baseline;
padding: 0;
margin-bottom: 8px;
border: none;
background: none;
}
.card-tags {
margin: 5px 0 3px 0;
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.card-tags a.tag-link {
text-decoration: none;
color: #FFFFFF;
background: #D94F4F;
padding: 5px 13px 4px 13px;
border-radius: 7px;
font-size: .98em;
border: none;
font-weight: bold;
}
.card-tags a.tag-link:hover {
background: #B86ADE;
color: #E5E5E5;
cursor: pointer;
}
a.user-link {
background-color: #FFD600;
color: #3C1360;
padding: 12px 24px;
border-radius: 5px;
text-align: center;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid #FFD600;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
font-size: 1em;
}
a.user-link:hover {
background-color: #FFB600;
border-color: #FFB600;
color: #3C1360;
cursor: pointer;
}
a.user-link:focus {
background-color: #9A2F2F;
border-color: #9A2F2F;
color: #E5E5E5;
}
.date-link {
background-color: #2F3C32;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
margin-left: 12px;
}
.date-link:hover {
background-color: #3E4A3D;
color: #fff;
}
.activitySpreadInhabitant2 {
background-color: #3E4A3D;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.activityVotePost {
background-color: #3B5C42;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.update-banner {
background-color: #1a0025;
border-bottom-color: #3d004d;
color: #E5E5E5;
}
.update-banner-link {
color: #c44bc1;
}
.snh-invite-code {
color: #9B1C96 !important;
}
/* Blockexplorer */
.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
.block { background: #3C1360 !important; border: 1px solid #B86ADE !important; }
.block:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #B86ADE !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFEEDB !important; }
.block-content-preview, .block-content { background: #4B1A72 !important; color: #FFEEDB !important; }
.block-author { color: #FFD600 !important; background: rgba(255,214,0,0.08) !important; }
.block-author:hover { color: #FFB600 !important; }
.block-url { color: #B86ADE !important; }
.block-row--details .block-url { background: #4B1A72 !important; }
.block-row--details .block-url:hover { background: #5A1A85 !important; color: #FFD600 !important; }
.btn-singleview { background: #4B1A72 !important; color: #B86ADE !important; }
.btn-singleview:hover { background: #5A1A85 !important; color: #FFD600 !important; }
.btn-back { background: #A34AD8 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #751E9F !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #B86ADE !important; }
.block-diagram { border-color: #B86ADE !important; background: #2D0B47 !important; }
.block-diagram-ruler { color: #B86ADE !important; background: #1A0030 !important; border-bottom-color: #B86ADE !important; }
.block-diagram-ruler span { color: #B86ADE !important; }
.block-diagram-cell { border-color: #B86ADE !important; background: #3C1360 !important; }
.bd-label { color: #B86ADE !important; }
.bd-value { color: #FFEEDB !important; }
.deleted-label { color: #ff5555 !important; }
/* Tribes */
.tribe-card { background: #3C1360 !important; border-color: #B86ADE !important; }
.tribe-card:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
.tribe-card-title { color: #FFEEDB !important; }
.tribe-card-description { color: #FFEEDB !important; }
.tribe-info-table td { border-color: #B86ADE !important; }
.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; }
.tribe-action-btn { border-color: #B86ADE !important; color: #B86ADE !important; }
.tribe-action-btn:hover { background: #B86ADE !important; color: #000 !important; }
.tribe-subtribe-link { background: #4B1A72 !important; border-color: #B86ADE !important; color: #FFD600 !important; }
.tribe-thumb-link { border-color: #B86ADE !important; }
.tribe-thumb-link:hover { border-color: #FFD600 !important; }
.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; }

View file

@ -0,0 +1,15 @@
const path = require('path');
let i18n = {};
const languages = ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt'];
languages.forEach(language => {
try {
const languagePath = path.join(__dirname, `oasis_${language}.js`);
const languageData = require(languagePath);
i18n[language] = languageData[language];
} catch (error) {
console.error(`Failed to load language file for ${language}:`, error);
}
});
module.exports = i18n;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
module.exports = {
feed: 'createFeedStream',
history: 'createHistoryStream',
hist: 'createHistoryStream',
public: 'getPublicKey',
pub: 'getPublicKey',
log: 'createLogStream',
logt: 'messagesByType',
conf: 'config',
};

View file

@ -0,0 +1,124 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
const debug = require('../server/node_modules/debug')('oasis');
const lodash = require('../server/node_modules/lodash');
const ssbClient = require('../server/node_modules/ssb-client');
const ssbConfig = require('../server/node_modules/ssb-config');
const ssbKeys = require('../server/node_modules/ssb-keys');
const { printMetadata } = require('../server/ssb_metadata');
const updateFlagPath = path.join(__dirname, "../server/.update_required");
let internalSSB = null;
try {
const { server } = require('../server/SSB_server');
internalSSB = server;
} catch {}
if (process.env.OASIS_TEST) {
ssbConfig.path = fs.mkdtempSync(path.join(os.tmpdir(), "oasis-"));
ssbConfig.keys = ssbKeys.generate();
}
const socketPath = path.join(ssbConfig.path, "socket");
const publicInteger = ssbConfig.keys.public.replace(".ed25519", "");
const remote = `unix:${socketPath}~noauth:${publicInteger}`;
const connect = (options) =>
new Promise((resolve, reject) => {
ssbClient(process.env.OASIS_TEST ? ssbConfig.keys : null, options)
.then(resolve)
.catch(reject);
});
let closing = false;
let clientHandle;
const attemptConnectionWithBackoff = (attempt = 1) => {
const maxAttempts = 5;
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
return new Promise((resolve, reject) => {
connect({ remote })
.then(resolve)
.catch((error) => {
if (attempt >= maxAttempts) {
return reject(new Error("Failed to connect after multiple attempts"));
}
setTimeout(() => {
attemptConnectionWithBackoff(attempt + 1).then(resolve).catch(reject);
}, delay);
});
});
};
let pendingConnection = null;
const ensureConnection = (customConfig) => {
if (pendingConnection === null) {
pendingConnection = new Promise((resolve) => {
setTimeout(() => {
attemptConnectionWithBackoff()
.then(resolve)
.catch(() => {
resolve(null);
});
});
});
const cancel = () => (pendingConnection = null);
pendingConnection.then(cancel, cancel);
}
return pendingConnection;
};
module.exports = ({ offline }) => {
const customConfig = JSON.parse(JSON.stringify(ssbConfig));
if (offline === true) {
lodash.set(customConfig, "conn.autostart", false);
}
lodash.set(
customConfig,
"conn.hops",
lodash.get(ssbConfig, "conn.hops", lodash.get(ssbConfig.friends, "hops", 0))
);
const cooler = {
open() {
return new Promise((resolve, reject) => {
if (internalSSB) {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS GUI running at: http://localhost:3000', colors.yellow);
return resolve(internalSSB);
}
if (clientHandle && clientHandle.closed === false) {
return resolve(clientHandle);
}
ensureConnection(customConfig).then((ssb) => {
if (!ssb) return reject(new Error("No SSB server available"));
clientHandle = ssb;
if (closing) {
cooler.close();
reject(new Error("Closing Oasis"));
} else {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS GUI running at: http://localhost:3000', colors.yellow);
resolve(ssb);
}
}).catch(reject);
});
},
close() {
closing = true;
if (clientHandle && clientHandle.closed === false) {
clientHandle.close();
}
},
};
return cooler;
};

View file

@ -0,0 +1,122 @@
const path = require("path");
const Koa = require(path.join(__dirname, "../server/node_modules/koa"));
const koaStatic = require(path.join(__dirname, "../server/node_modules/koa-static"));
const { join } = require("path");
const mount = require(path.join(__dirname, "../server/node_modules/koa-mount"));
module.exports = ({ host, port, middleware, allowHost }) => {
const assets = new Koa()
assets.use(koaStatic(join(__dirname, "..", "client", "assets")));
const app = new Koa();
const validHosts = [];
const isValidRequest = (request) => {
if (validHosts.includes(request.hostname) !== true) {
return false;
}
if (request.method !== "GET") {
if (request.header.referer == null) {
return false;
}
try {
const refererUrl = new URL(request.header.referer);
if (validHosts.includes(refererUrl.hostname) !== true) {
return false;
}
if (refererUrl.pathname.startsWith("/blob/")) {
return false;
}
} catch (e) {
return false;
}
}
return true;
};
app.on("error", (err, ctx) => {
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
return;
}
console.error(err);
if (ctx && isValidRequest(ctx.request)) {
err.message = err.stack;
err.expose = true;
}
return null;
});
app.use(mount("/assets", assets));
// pdf viewer
app.use(mount("/js", koaStatic(path.join(__dirname, 'public/js'))));
app.use(koaStatic(path.join(__dirname, 'public')));
app.use(async (ctx, next) => {
//console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests
const csp = [
"default-src 'self'",
"script-src 'self' http://localhost:3000/js",
"style-src 'self'",
"img-src 'self'",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join("; ");
ctx.set("Content-Security-Policy", csp);
ctx.set("X-Frame-Options", "SAMEORIGIN");
ctx.set("X-Content-Type-Options", "nosniff");
ctx.set("Referrer-Policy", "same-origin");
ctx.set("Permissions-Policy", "speaker=(self)");
const validHostsString = validHosts.join(" or ");
ctx.assert(
isValidRequest(ctx.request),
400,
`Request must be addressed to ${validHostsString} and non-GET requests must contain non-blob referer.`
);
await next();
});
// pdf viewer
const pdfjsPath = path.join(__dirname, '../server/node_modules/pdfjs-dist/build/pdf.min.js');
app.use(koaStatic(pdfjsPath));
middleware.forEach((m) => app.use(m));
const server = app.listen({ host, port });
server.on("listening", () => {
const address = server.address();
if (typeof address === "string") {
throw new Error("HTTP server should never bind to Unix socket");
}
if (allowHost !== null) {
validHosts.push(allowHost);
}
validHosts.push(address.address);
if (validHosts.includes(host) === false) {
validHosts.push(host);
}
});
return server;
};

View file

@ -0,0 +1,85 @@
"use strict";
const path = require('path');
const yargs = require(path.join(__dirname, '../server/node_modules/yargs'));
const { hideBin } = require(path.join(__dirname, '../server/node_modules/yargs/helpers'));
const _ = require(path.join(__dirname, '../server/node_modules/lodash'));
const moduleAlias = require(path.join(__dirname, '../server/node_modules/module-alias'));
moduleAlias.addAlias('punycode', 'punycode/');
const cli = (presets, defaultConfigFile) =>
yargs(hideBin(process.argv))
.scriptName("oasis")
.env("OASIS")
.help("h")
.alias("h", "help")
.usage("Usage: $0 [options]")
.options("open", {
describe:
"Automatically open app in web browser. Use --no-open to disable.",
default: _.get(presets, "open", true),
type: "boolean",
})
.options("offline", {
describe:
"Don't try to connect to scuttlebutt peers or pubs. This can be changed on the 'settings' page while Oasis is running.",
default: _.get(presets, "offline", false),
type: "boolean",
})
.options("host", {
describe: "Hostname for web app to listen on",
default: _.get(presets, "host", "localhost"),
type: "string",
})
.options("allow-host", {
describe:
"Extra hostname to be whitelisted (useful when running behind a proxy)",
default: _.get(presets, "allow-host", null),
type: "string",
})
.options("port", {
describe: "Port for web app to listen on",
default: _.get(presets, "port", 3000),
type: "number",
})
.options("public", {
describe:
"Assume Oasis is being hosted publicly, disable HTTP POST and redact messages from people who haven't given consent for public web hosting.",
default: _.get(presets, "public", false),
type: "boolean",
})
.options("debug", {
describe: "Use verbose output for debugging",
default: _.get(presets, "debug", false),
type: "boolean",
})
.options("theme", {
describe: "The theme to use, if a theme hasn't been set in the cookies",
default: _.get(presets, "theme", "classic-light"),
type: "string",
})
.options("wallet-url", {
describe: "The URL of the remote ECOin wallet",
default: _.get(presets, "walletUrl", "http://localhost:7474"),
type: "string",
})
.options("wallet-user", {
describe: "The username of the remote ECOin wallet",
default: _.get(presets, "walletUser", "ecoinrpc"),
type: "string",
})
.options("wallet-pass", {
describe: "The password of the remote ECOin wallet",
default: _.get(presets, "walletPass", "ecoinrpc"),
type: "string",
})
.options("wallet-fee", {
describe: "The fee to pay for ECOin transactions",
default: _.get(presets, "walletFee", "0.01"),
type: "string",
})
.epilog(`The defaults can be configured in ${defaultConfigFile}.`).argv;
module.exports = { cli };

View file

@ -0,0 +1,115 @@
ECOin - Copyright (c) - 2014/2025 - GPLv3 - epsylon@riseup.net (https://ecoin.03c8.net)
===========================================
# SOURCES for P2P Crypto-Currency (ECOin) #
===========================================
Testing machine is: Debian GNU/Linux 12 (bookworm) (x86_64).
All of the commands should be executed in a shell.
------------------------------
(0.) Clone the github tree to get the source code:
+ Official:
git clone http://code.03c8.net/epsylon/ecoin
+ Mirror:
git clone https://github.com/epsylon/ecoin
------------------------------
===================================================
# SERVER -ecoind- for P2P Crypto-Currency (ECOin) #
===================================================
(0.) Version libraries:
- Libboost -> source code 1.68 provided at: src/boost_1_68_0
(1.) Install dependencies:
sudo apt-get install build-essential libssl-dev libssl3 libdb5.3-dev libdb5.3++-dev libleveldb-dev miniupnpc libminiupnpc-dev
+ Optionally install qrencode (and set USE_QRCODE=1):
sudo apt-get install libqrencode-dev
(2.) Now you should be able to build ecoind:
cd src/
make -f makefile.linux USE_UPNP=- USE_IPV6=-
strip ecoind
An executable named 'ecoind' will be built.
Now you can launch: ./ecoind to run your ECOin server.
------------------------------
============================================
# WALLET for P2P Crypto-Currency (ECOin) #
============================================
(0.) Version libraries:
- Libboost -> source code 1.68 provided at: src/boost_1_68_0
(1.) First, make sure that the required packages for Qt5 development (an the others required for building the daemon) are installed:
sudo apt-get install qt5-qmake qtbase5-dev build-essential libssl-dev libssl3 libdb5.3-dev libdb5.3++-dev libleveldb-dev miniupnpc libminiupnpc-dev
+ Optionally install qrencode (and set USE_QRCODE=1):
sudo apt-get install libqrencode-dev
(2.) Then execute the following:
qmake USE_UPNP=- USE_IPV6=-
make
An executable named 'ecoin-qt' will be built.
Now you can launch: ./ecoin-qt to run your ECOin wallet/GUI.
------------------------------
========================================
+ CPU MINER for ECOin (Unix/GNU-Linux) #
========================================
See doc/MINING.txt for detailed instructions on running /ecoin-miner/ on different platforms.
+ GNU/Linux:
cd miner/
sh build.sh
An executable named 'cpuminer' will be built.
Now you can launch: ./cpuminer to run your ECOin PoW miner.
======================================
For a list of command-line options:
./ecoind --help
To start the ECOin daemon:
./ecoind -daemon
For ECOin-QT Wallet
./ecoin-qt
For debugging:
tail -f /home/$USER/.ecoin/debug.log
======================================

View file

@ -0,0 +1,76 @@
document.addEventListener('DOMContentLoaded', () => {
if (typeof pdfjsLib === 'undefined') return;
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.mjs';
document.querySelectorAll('.pdf-viewer-container').forEach(async container => {
const pdfUrl = container.getAttribute('data-pdf-url');
if (!pdfUrl) return;
const pdf = await pdfjsLib.getDocument(pdfUrl).promise;
let currentPage = 1;
let scale = 1.5;
let rotation = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
container.innerHTML = '';
container.appendChild(canvas);
const controls = document.createElement('div');
controls.className = 'pdf-controls';
controls.innerHTML = `
<button id="prev"></button>
<button id="next"></button>
<button id="zoomIn">🔍+</button>
<button id="zoomOut">🔍</button>
<button id="rotate"></button>
<button id="download"></button>
<button id="fullscreen">🔲</button>
<button id="metadata"></button>
`;
container.appendChild(controls);
const renderPage = async (num) => {
const page = await pdf.getPage(num);
const viewport = page.getViewport({ scale, rotation });
canvas.width = viewport.width;
canvas.height = viewport.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport }).promise;
};
const goToPage = (delta) => {
const newPage = currentPage + delta;
if (newPage >= 1 && newPage <= pdf.numPages) {
currentPage = newPage;
renderPage(currentPage);
}
};
renderPage(currentPage);
controls.querySelector('#prev').onclick = () => goToPage(-1);
controls.querySelector('#next').onclick = () => goToPage(1);
controls.querySelector('#zoomIn').onclick = () => { scale += 0.2; renderPage(currentPage); };
controls.querySelector('#zoomOut').onclick = () => { scale = Math.max(0.5, scale - 0.2); renderPage(currentPage); };
controls.querySelector('#rotate').onclick = () => { rotation = (rotation + 90) % 360; renderPage(currentPage); };
controls.querySelector('#download').onclick = () => {
const a = document.createElement('a');
a.href = pdfUrl;
a.download = 'document.pdf';
a.click();
};
controls.querySelector('#fullscreen').onclick = () => {
if (canvas.requestFullscreen) canvas.requestFullscreen();
else if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen();
else if (canvas.mozRequestFullScreen) canvas.mozRequestFullScreen();
else if (canvas.msRequestFullscreen) canvas.msRequestFullscreen();
};
controls.querySelector('#metadata').onclick = async () => {
const info = await pdf.getMetadata();
alert(`Title: ${info.info.Title || 'N/A'}\nAuthor: ${info.info.Author || 'N/A'}\nPDF Producer: ${info.info.Producer || 'N/A'}`);
};
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1,3 @@
{
"discardedItems": []
}

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1,4 @@
{
"cycle": 4,
"url": "https://laplaza.solarnethub.com"
}

View file

@ -0,0 +1,84 @@
const fs = require('fs');
const path = require('path');
const configFilePath = path.join(__dirname, 'oasis-config.json');
if (!fs.existsSync(configFilePath)) {
const defaultConfig = {
"themes": {
"current": "OasisMobile"
},
"modules": {
"popularMod": "on",
"topicsMod": "off",
"summariesMod": "off",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"invitesMod": "on",
"walletMod": "off",
"legacyMod": "off",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "off",
"docsMod": "off",
"audiosMod": "off",
"tagsMod": "on",
"imagesMod": "on",
"trendingMod": "on",
"eventsMod": "on",
"tasksMod": "off",
"marketMod": "off",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "off",
"opinionsMod": "on",
"transfersMod": "off",
"feedMod": "on",
"pixeliaMod": "off",
"agendaMod": "on",
"aiMod": "off",
"forumMod": "off",
"jobsMod": "off",
"projectsMod": "off",
"bankingMod": "off",
"parliamentMod": "off",
"courtsMod": "off",
"favoritesMod": "off"
},
"wallet": {
"url": "http://localhost:7474",
"user": "",
"pass": "",
"fee": "5"
},
"walletPub": {
"url": "",
"user": "",
"pass": ""
},
"ai": {
"prompt": "Provide an informative and precise response."
},
"ssbLogStream": {
"limit": 2000
},
"homePage": "activity",
"language": "en"
};
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
}
const getConfig = () => {
const configData = fs.readFileSync(configFilePath);
return JSON.parse(configData);
};
const saveConfig = (newConfig) => {
fs.writeFileSync(configFilePath, JSON.stringify(newConfig, null, 2));
};
module.exports = {
getConfig,
saveConfig,
};

View file

@ -0,0 +1,7 @@
{
"audios": [],
"bookmarks": [],
"documents": [],
"images": [],
"videos": []
}

View file

@ -0,0 +1,62 @@
{
"themes": {
"current": "OasisMobile"
},
"modules": {
"popularMod": "on",
"topicsMod": "off",
"summariesMod": "off",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"invitesMod": "on",
"walletMod": "off",
"legacyMod": "off",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "off",
"docsMod": "off",
"audiosMod": "off",
"tagsMod": "on",
"imagesMod": "on",
"trendingMod": "on",
"eventsMod": "on",
"tasksMod": "off",
"marketMod": "off",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "off",
"opinionsMod": "on",
"transfersMod": "off",
"feedMod": "on",
"pixeliaMod": "off",
"agendaMod": "on",
"aiMod": "off",
"forumMod": "off",
"jobsMod": "off",
"projectsMod": "off",
"bankingMod": "off",
"parliamentMod": "off",
"courtsMod": "off",
"favoritesMod": "off"
},
"wallet": {
"url": "http://localhost:7474",
"user": "",
"pass": "",
"fee": "5"
},
"walletPub": {
"url": "",
"user": "",
"pass": ""
},
"ai": {
"prompt": "Provide an informative and precise response."
},
"ssbLogStream": {
"limit": 2000
},
"homePage": "activity",
"language": "en"
}

View file

@ -0,0 +1,63 @@
{
"logging": {
"level": "notice"
},
"caps": {
"shs": "1BIWr6Hu+MgtNkkClvg2GAi+0HiAikGOOTd/pIUcH54="
},
"pub": false,
"local": true,
"friends": {
"dunbar": 300,
"hops": 1
},
"gossip": {
"connections": 20,
"local": true,
"friends": true,
"seed": true,
"global": true
},
"replicationScheduler": {
"autostart": true,
"partialReplication": null
},
"autofollow": {
"enabled": false,
"feeds": []
},
"connections": {
"seeds": [
"net:solarnethub.com:8008~shs:zGfPCNPFas4gHUfib08/oQ4rsWo/tnEfQ5iTkoTiBaI=.ed25519"
],
"incoming": {
"net": [
{
"scope": ["device", "local"],
"transform": "shs",
"port": 8008
}
],
"unix": [
{
"scope": [
"device",
"local",
"private"
],
"transform": "noauth"
}
]
},
"outgoing": {
"net": [
{
"transform": "shs"
}
],
"tunnel": [],
"onion": [],
"ws": []
}
}
}

View file

@ -0,0 +1,14 @@
let _inboxCount = 0;
let _carbonHcT = 0;
let _carbonHcH = 0;
let _lastRefresh = 0;
module.exports = {
getInboxCount: () => _inboxCount,
setInboxCount: (n) => { _inboxCount = n; },
getCarbonHcT: () => _carbonHcT,
setCarbonHcT: (n) => { _carbonHcT = n; },
getCarbonHcH: () => _carbonHcH,
setCarbonHcH: (n) => { _carbonHcH = n; },
getLastRefresh: () => _lastRefresh,
setLastRefresh: (t) => { _lastRefresh = t; }
};

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,520 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
const SCORE_MARKET = s => { const i = ORDER_MARKET.indexOf(N(s)); return i < 0 ? -1 : i };
const SCORE_PROJECT = s => { const i = ORDER_PROJECT.indexOf(N(s)); return i < 0 ? -1 : i };
function inferType(c = {}) {
if (c.type === 'wallet' && c.coin === 'ECO' && typeof c.address === 'string') return 'bankWallet';
if (c.type === 'bankClaim') return 'bankClaim';
if (c.type === 'karmaScore') return 'karmaScore';
if (c.type === 'courts_case') return 'courtsCase';
if (c.type === 'courts_evidence') return 'courtsEvidence';
if (c.type === 'courts_answer') return 'courtsAnswer';
if (c.type === 'courts_verdict') return 'courtsVerdict';
if (c.type === 'courts_settlement') return 'courtsSettlement';
if (c.type === 'courts_nomination') return 'courtsNomination';
if (c.type === 'courts_nom_vote') return 'courtsNominationVote';
if (c.type === 'courts_public_pref') return 'courtsPublicPref';
if (c.type === 'courts_mediators') return 'courtsMediators';
if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') {
const br = Array.isArray(c.branch) ? c.branch : [];
if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
}
return c.type || '';
}
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg)));
const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const stripHtml = (s) => normNL(s)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p\s*>/gi, '\n\n')
.replace(/<[^>]*>/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
const excerpt = (s, max = 900) => {
const t = stripHtml(s);
if (!t) return '';
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
return {
async listFeed(filter = 'all') {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const results = await new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
const tombstoned = new Set();
const parentOf = new Map();
const idToAction = new Map();
const rawById = new Map();
for (const msg of results) {
const k = msg.key;
const v = msg.value;
const c = v?.content;
if (!c?.type) continue;
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue }
const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0;
idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c });
rawById.set(k, msg);
if (c.replaces) parentOf.set(k, c.replaces);
}
const replacedIds = new Set(parentOf.values());
const spreadVoteState = new Map();
for (const a of idToAction.values()) {
const c = a.content || {};
if (c.type !== 'vote' || !c.vote || typeof c.vote.link !== 'string') continue;
const link = c.vote.link;
const br = Array.isArray(c.branch) ? c.branch : [];
if (!br.includes(link)) continue;
if (tombstoned.has(a.id)) continue;
if (replacedIds.has(a.id)) continue;
if (tombstoned.has(link)) continue;
const author = a.author;
if (!author) continue;
const value = Number(c.vote.value);
const key = `${link}:${author}`;
const prev = spreadVoteState.get(key);
const curTs = a.ts || 0;
if (!prev || curTs > prev.ts || (curTs === prev.ts && String(a.id || '').localeCompare(String(prev.id || '')) > 0)) {
spreadVoteState.set(key, { ts: curTs, id: a.id, value, link });
}
}
const spreadCountByTarget = new Map();
for (const v of spreadVoteState.values()) {
if (Number(v.value) !== 1) continue;
spreadCountByTarget.set(v.link, (spreadCountByTarget.get(v.link) || 0) + 1);
}
const fetchedTargetCache = new Map();
for (const a of idToAction.values()) {
if (a.type !== 'spread') continue;
const c = a.content || {};
const link = c.vote?.link || '';
const totalSpreads = link ? (spreadCountByTarget.get(link) || 0) : 0;
let targetMsg = link ? rawById.get(link) : null;
if (!targetMsg && link) {
if (fetchedTargetCache.has(link)) targetMsg = fetchedTargetCache.get(link);
else {
const got = await getMsg(ssbClient, link);
if (got) {
const wrapped = { key: link, value: got };
fetchedTargetCache.set(link, wrapped);
targetMsg = wrapped;
} else {
fetchedTargetCache.set(link, null);
targetMsg = null;
}
}
}
const targetContent = targetMsg?.value?.content || null;
const title =
(typeof targetContent?.title === 'string' && targetContent.title.trim())
? targetContent.title.trim()
: (typeof targetContent?.name === 'string' && targetContent.name.trim())
? targetContent.name.trim()
: '';
const rawText =
(typeof targetContent?.text === 'string' && targetContent.text.trim())
? targetContent.text
: (typeof targetContent?.description === 'string' && targetContent.description.trim())
? targetContent.description
: '';
const text = rawText ? excerpt(rawText, 700) : '';
const cw =
(typeof targetContent?.contentWarning === 'string' && targetContent.contentWarning.trim())
? targetContent.contentWarning
: '';
a.content = {
...c,
spreadTargetId: link,
spreadTotalSpreads: totalSpreads,
spreadOriginalAuthor: targetMsg?.value?.author || '',
spreadTitle: title,
spreadContentWarning: cw,
spreadText: text
};
}
const rootOf = (id) => { let cur = id; while (parentOf.has(cur)) cur = parentOf.get(cur); return cur };
const groups = new Map();
for (const [id, action] of idToAction.entries()) {
const root = rootOf(id);
if (!groups.has(root)) groups.set(root, []);
groups.get(root).push(action);
}
const idToTipId = new Map();
for (const [root, arr] of groups.entries()) {
if (!arr.length) continue;
const type = arr[0].type;
if (type !== 'project') {
const tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]);
for (const a of arr) idToTipId.set(a.id, tip.id);
if (type === 'task' && tip && tip.content && tip.content.isPublic !== 'PRIVATE') {
const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
const sorted = arr
.filter(a => a.type === 'task' && a.content && typeof a.content === 'object')
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
let prev = null;
for (const ev of sorted) {
const cur = uniq(ev.content.assignees);
if (prev) {
const prevSet = new Set(prev);
const curSet = new Set(cur);
const added = cur.filter(x => !prevSet.has(x));
const removed = prev.filter(x => !curSet.has(x));
if (added.length || removed.length) {
const overlayId = `${ev.id}:assignees:${added.join(',')}:${removed.join(',')}`;
idToAction.set(overlayId, {
id: overlayId,
author: ev.author,
ts: ev.ts,
type: 'taskAssignment',
content: {
taskId: tip.id,
title: tip.content.title || ev.content.title || '',
added,
removed,
isPublic: tip.content.isPublic
}
});
idToTipId.set(overlayId, overlayId);
}
}
prev = cur;
}
}
if (type === 'tribe') {
const baseId = tip.id;
const baseTitle = (tip.content && tip.content.title) || '';
const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
const toSet = (xs) => new Set(uniq(xs));
const excerpt2 = (s, max = 220) => {
const t = String(s || '').replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
const feedMap = (feed) => {
const m = new Map();
for (const it of (Array.isArray(feed) ? feed : [])) {
if (!it || typeof it !== 'object') continue;
const id = typeof it.id === 'string' || typeof it.id === 'number' ? String(it.id) : '';
if (!id) continue;
m.set(id, it);
}
return m;
};
const sorted = arr
.filter(a => a.type === 'tribe' && a.content && typeof a.content === 'object')
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
let prev = null;
for (const ev of sorted) {
if (!prev) { prev = ev; continue; }
const prevMembers = toSet(prev.content.members);
const curMembers = toSet(ev.content.members);
const added = Array.from(curMembers).filter(x => !prevMembers.has(x));
const removed = Array.from(prevMembers).filter(x => !curMembers.has(x));
for (const member of added) {
const overlayId = `${ev.id}:tribeJoin:${member}`;
idToAction.set(overlayId, {
id: overlayId,
author: member,
ts: ev.ts,
type: 'tribeJoin',
content: { type: 'tribeJoin', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
});
idToTipId.set(overlayId, overlayId);
}
for (const member of removed) {
const overlayId = `${ev.id}:tribeLeave:${member}`;
idToAction.set(overlayId, {
id: overlayId,
author: member,
ts: ev.ts,
type: 'tribeLeave',
content: { type: 'tribeLeave', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
});
idToTipId.set(overlayId, overlayId);
}
const prevFeed = feedMap(prev.content.feed);
const curFeed = feedMap(ev.content.feed);
for (const [fid, item] of curFeed.entries()) {
if (prevFeed.has(fid)) continue;
const feedAuthor = (item && typeof item.author === 'string' && item.author.trim().length) ? item.author : ev.author;
const overlayId = `${ev.id}:tribeFeedPost:${fid}:${feedAuthor}`;
idToAction.set(overlayId, {
id: overlayId,
author: feedAuthor,
ts: ev.ts,
type: 'tribeFeedPost',
content: {
type: 'tribeFeedPost',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
date: item.date || ev.ts,
text: excerpt2(item.message || '')
}
});
idToTipId.set(overlayId, overlayId);
}
for (const [fid, curItem] of curFeed.entries()) {
const prevItem = prevFeed.get(fid);
if (!prevItem) continue;
const pInh = toSet(prevItem.refeeds_inhabitants);
const cInh = toSet(curItem.refeeds_inhabitants);
const newInh = Array.from(cInh).filter(x => !pInh.has(x));
const curRefeeds = Number(curItem.refeeds || 0);
const prevRefeeds = Number(prevItem.refeeds || 0);
const postText = excerpt2(curItem.message || '');
if (newInh.length) {
for (const who of newInh) {
const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
idToAction.set(overlayId, {
id: overlayId,
author: who,
ts: ev.ts,
type: 'tribeFeedRefeed',
content: {
type: 'tribeFeedRefeed',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
text: postText
}
});
idToTipId.set(overlayId, overlayId);
}
} else if (curRefeeds > prevRefeeds && ev.author) {
const who = ev.author;
const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
idToAction.set(overlayId, {
id: overlayId,
author: who,
ts: ev.ts,
type: 'tribeFeedRefeed',
content: {
type: 'tribeFeedRefeed',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
text: postText
}
});
idToTipId.set(overlayId, overlayId);
}
}
prev = ev;
}
}
continue;
}
let tip = arr[0];
let bestScore = SCORE_PROJECT(tip.content.status);
for (const a of arr) {
const s = SCORE_PROJECT(a.content.status);
if (s > bestScore || (s === bestScore && a.ts > tip.ts)) { tip = a; bestScore = s }
}
for (const a of arr) idToTipId.set(a.id, tip.id);
const baseTitle = (tip.content && tip.content.title) || '';
const overlays = arr
.filter(a => a.type === 'project' && (a.content.followersOp || a.content.backerPledge))
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
for (const ev of overlays) {
if (tombstoned.has(ev.id)) continue;
let kind = null;
let amount = null;
if (ev.content.followersOp === 'follow') kind = 'follow';
else if (ev.content.followersOp === 'unfollow') kind = 'unfollow';
if (ev.content.backerPledge && typeof ev.content.backerPledge.amount !== 'undefined') {
const amt = Math.max(0, parseFloat(ev.content.backerPledge.amount || 0) || 0);
if (amt > 0) { kind = kind || 'pledge'; amount = amt }
}
if (!kind) continue;
const augmented = {
...ev,
type: 'project',
content: {
...ev.content,
title: baseTitle,
projectId: tip.id,
activity: { kind, amount },
activityActor: ev.author
}
};
idToAction.set(ev.id, augmented);
idToTipId.set(ev.id, ev.id);
}
}
const latest = [];
for (const a of idToAction.values()) {
if (tombstoned.has(a.id)) continue;
if (a.type === 'tribe' && parentOf.has(a.id)) continue;
const c = a.content || {};
if (c.root && tombstoned.has(c.root)) continue;
if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue;
if (c.key && tombstoned.has(c.key)) continue;
if (c.branch && tombstoned.has(c.branch)) continue;
if (c.target && tombstoned.has(c.target)) continue;
if (a.type === 'document') {
const url = c.url;
const ok = await hasBlob(ssbClient, url);
if (!ok) continue;
}
if (a.type === 'forum' && c.root) {
const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || '');
const rootAction = idToAction.get(rootId);
a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || '';
a.content.rootKey = rootId || a.content.rootKey || '';
}
latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
}
let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));
const mediaTypes = new Set(['image','video','audio','document','bookmark']);
const perAuthorUnique = new Set(['karmaScore']);
const byKey = new Map();
const norm = s => String(s || '').trim().toLowerCase();
for (const a of deduped) {
const c = a.content || {};
const effTs =
(c.updatedAt && Date.parse(c.updatedAt)) ||
(c.createdAt && Date.parse(c.createdAt)) ||
(a.ts || 0);
if (mediaTypes.has(a.type)) {
const u = c.url || c.title || `${a.type}:${a.id}`;
const key = `${a.type}:${u}`;
const prev = byKey.get(key);
if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
} else if (perAuthorUnique.has(a.type)) {
const key = `${a.type}:${a.author}`;
const prev = byKey.get(key);
if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
} else if (a.type === 'about') {
const target = c.about || a.author;
const key = `about:${target}`;
const prev = byKey.get(key);
const prevContent = prev && (prev.content || {});
const prevHasImage = !!(prevContent && prevContent.image);
const newHasImage = !!c.image;
if (!prev) {
byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
} else if (!prevHasImage && newHasImage) {
byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
} else if (prevHasImage === newHasImage && effTs > prev.__effTs) {
byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
}
} else if (a.type === 'tribe') {
const t = norm(c.title);
if (t) {
const key = `tribe:${t}::${a.author}`;
const prev = byKey.get(key);
if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
} else {
const key = `id:${a.id}`;
byKey.set(key, { ...a, __effTs: effTs });
}
} else {
const key = `id:${a.id}`;
byKey.set(key, { ...a, __effTs: effTs });
}
}
deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
let out;
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a));
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a)) }
else if (filter === 'all') out = deduped.filter(isAllowedTribeActivity);
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
else if (filter === 'parliament')
out = deduped.filter(a =>
['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type)
);
else if (filter === 'courts')
out = deduped.filter(a => {
const t = String(a.type || '').toLowerCase();
return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote';
});
else if (filter === 'task')
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
else out = deduped.filter(a => a.type === filter);
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
return out;
}
};
};

View file

@ -0,0 +1,236 @@
const fs = require('fs');
const path = require('path');
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
function readAgendaConfig() {
if (!fs.existsSync(agendaConfigPath)) {
fs.writeFileSync(agendaConfigPath, JSON.stringify({ discardedItems: [] }));
}
return JSON.parse(fs.readFileSync(agendaConfigPath));
}
function writeAgendaConfig(cfg) {
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
}
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const STATUS_ORDER = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => STATUS_ORDER.indexOf(String(s || '').toUpperCase());
const fetchItems = (targetType) =>
new Promise((resolve, reject) => {
openSsb().then((ssbClient) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
for (const m of msgs) {
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 !== targetType) continue;
nodes.set(k, { key: k, ts: v.timestamp || 0, content: c });
if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
}
const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
const groups = new Map();
for (const id of nodes.keys()) {
const r = rootOf(id);
if (!groups.has(r)) groups.set(r, new Set());
groups.get(r).add(id);
}
const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
const out = [];
for (const [root, ids] of groups.entries()) {
const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key));
if (!items.length) continue;
let tipId = Array.from(ids).find(id => !child.has(id));
let tip = tipId ? nodes.get(tipId) : items.reduce((a, b) => a.ts > b.ts ? a : b);
if (targetType === 'market') {
let chosen = items[0];
for (const n of items) {
const a = sIdx(n.content.status);
const b = sIdx(chosen.content.status);
if (a > b || (a === b && n.ts > chosen.ts)) chosen = n;
}
const c = chosen.content;
let status = c.status;
if (c.deadline) {
const dl = moment(c.deadline);
if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED';
}
if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
out.push({
...c,
status,
id: chosen.key,
tipId: chosen.key,
createdAt: c.createdAt || chosen.ts
});
continue;
}
if (targetType === 'job') {
const latest = items.sort((a, b) => b.ts - a.ts)[0];
const withSubsNode = items
.filter(n => Array.isArray(n.content.subscribers))
.sort((a, b) => b.ts - a.ts)[0];
const subscribers = withSubsNode ? withSubsNode.content.subscribers : [];
const latestWithStatus = items
.filter(n => typeof n.content.status !== 'undefined')
.sort((a, b) => b.ts - a.ts)[0];
const resolvedStatus = latestWithStatus
? latestWithStatus.content.status
: latest.content.status;
const c = { ...latest.content, status: resolvedStatus, subscribers };
out.push({
...c,
id: latest.key,
tipId: latest.key,
createdAt: c.createdAt || latest.ts
});
continue;
}
out.push({
...tip.content,
id: tip.key,
tipId: tip.key,
createdAt: tip.content.createdAt || tip.ts
});
}
resolve(out);
})
);
}).catch(reject);
});
return {
async listAgenda(filter = 'all') {
const agendaConfig = readAgendaConfig();
const discardedItems = agendaConfig.discardedItems || [];
const ssbClient = await openSsb();
const userId = ssbClient.id;
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll] = await Promise.all([
fetchItems('task'),
fetchItems('event'),
fetchItems('transfer'),
fetchItems('tribe'),
fetchItems('market'),
fetchItems('report'),
fetchItems('job'),
fetchItems('project')
]);
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
const events = eventsAll.filter(c => Array.isArray(c.attendees) && c.attendees.includes(userId)).map(e => ({ ...e, type: 'event' }));
const transfers = transfersAll.filter(c => c.from === userId || c.to === userId).map(tr => ({ ...tr, type: 'transfer' }));
const tribes = tribesAll.filter(c => Array.isArray(c.members) && c.members.includes(userId)).map(t => ({ ...t, type: 'tribe', title: t.title }));
const marketItems = marketAll.filter(c =>
c.seller === userId || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => String(b).split(':')[0] === userId))
).map(m => ({ ...m, type: 'market' }));
const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
let combined = [
...tasks,
...events,
...transfers,
...tribes,
...marketItems,
...reports,
...jobs,
...projects
];
let filtered;
if (filter === 'discarded') {
filtered = combined.filter(i => discardedItems.includes(i.id));
} else {
filtered = combined.filter(i => !discardedItems.includes(i.id));
if (filter === 'tasks') filtered = filtered.filter(i => i.type === 'task');
else if (filter === 'events') filtered = filtered.filter(i => i.type === 'event');
else if (filter === 'transfers') filtered = filtered.filter(i => i.type === 'transfer');
else if (filter === 'tribes') filtered = filtered.filter(i => i.type === 'tribe');
else if (filter === 'market') filtered = filtered.filter(i => i.type === 'market');
else if (filter === 'reports') filtered = filtered.filter(i => i.type === 'report');
else if (filter === 'open') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'OPEN');
else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
}
filtered.sort((a, b) => {
const dateA = a.startTime || a.date || a.deadline || a.createdAt || 0;
const dateB = b.startTime || b.date || b.deadline || b.createdAt || 0;
return new Date(dateA) - new Date(dateB);
});
const mainItems = combined.filter(i => !discardedItems.includes(i.id));
const discarded = combined.filter(i => discardedItems.includes(i.id));
return {
items: filtered,
counts: {
all: mainItems.length,
open: mainItems.filter(i => String(i.status).toUpperCase() === 'OPEN').length,
closed: mainItems.filter(i => String(i.status).toUpperCase() === 'CLOSED').length,
tasks: mainItems.filter(i => i.type === 'task').length,
events: mainItems.filter(i => i.type === 'event').length,
transfers: mainItems.filter(i => i.type === 'transfer').length,
tribes: mainItems.filter(i => i.type === 'tribe').length,
market: mainItems.filter(i => i.type === 'market').length,
reports: mainItems.filter(i => i.type === 'report').length,
jobs: mainItems.filter(i => i.type === 'job').length,
projects: mainItems.filter(i => i.type === 'project').length,
discarded: discarded.length
}
};
},
async discardItem(itemId) {
const agendaConfig = readAgendaConfig();
if (!agendaConfig.discardedItems.includes(itemId)) {
agendaConfig.discardedItems.push(itemId);
writeAgendaConfig(agendaConfig);
}
},
async restoreItem(itemId) {
const agendaConfig = readAgendaConfig();
agendaConfig.discardedItems = agendaConfig.discardedItems.filter(id => id !== itemId);
writeAgendaConfig(agendaConfig);
}
};
};

View file

@ -0,0 +1,320 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s || null;
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
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 !== "audio") continue;
const ts = v.timestamp || m.timestamp || 0;
nodes.set(k, { key: k, ts, c });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
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;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, tipOf(r));
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
};
const buildAudio = (node, rootId, viewerId) => {
const c = node.c || {};
const voters = safeArr(c.opinions_inhabitants);
return {
key: node.key,
rootId,
url: 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 || "",
opinions: c.opinions || {},
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false
};
};
return {
type: "audio",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Audio not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Audio not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createAudio(blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const content = {
type: "audio",
url: blobId,
createdAt: now,
updatedAt: null,
author: ssbClient.id,
tags,
title: title || "",
description: description || "",
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateAudioById(id, blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "audio") throw new Error("Audio not found");
if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit audio after it has received opinions.");
if (oldMsg.content.author !== userId) throw new Error("Not the author");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "",
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
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)));
});
},
async deleteAudioById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found");
if (msg.content.author !== userId) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const sort = String(opts.sort || "recent").trim();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildAudio(node, rootId, viewerId));
}
let list = items;
const now = Date.now();
if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId));
else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
else if (filter === "top") {
list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
}
if (q) {
list = list.filter((a) => {
const title = String(a.title || "").toLowerCase();
const desc = String(a.description || "").toLowerCase();
const tags = safeArr(a.tags).join(" ").toLowerCase();
const author = String(a.author || "").toLowerCase();
return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
});
}
if (sort === "top") {
list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getAudioById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Audio not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
const node = idx.nodes.get(tip);
if (node) return buildAudio(node, root, viewer);
const msg = await getMsg(ssbClient, tip);
if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found");
return buildAudio({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
},
async createOpinion(id, category) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
if (!categories.includes(category)) throw new Error("Invalid voting category");
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found");
const voters = safeArr(msg.content.opinions_inhabitants);
if (voters.includes(userId)) throw new Error("Already voted");
const now = new Date().toISOString();
const updated = {
...msg.content,
replaces: tipId,
opinions: {
...msg.content.opinions,
[category]: (msg.content.opinions?.[category] || 0) + 1
},
opinions_inhabitants: voters.concat(userId),
updatedAt: now
};
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)));
});
}
};
};

View file

@ -0,0 +1,842 @@
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const { config } = require("../server/SSB_server.js");
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
const DEFAULT_RULES = {
epochKind: "WEEKLY",
alpha: 0.2,
reserveMin: 500,
capPerEpoch: 2000,
caps: { M_max: 3, T_max: 1.5, P_max: 2, cap_user_epoch: 50, w_min: 0.2, w_max: 6 },
coeffs: { a1: 0.6, a2: 0.4, a3: 0.3, a4: 0.5, b1: 0.5, b2: 1.0 },
graceDays: 14
};
const STORAGE_DIR = path.join(__dirname, "..", "configs");
const EPOCHS_PATH = path.join(STORAGE_DIR, "banking-epochs.json");
const TRANSFERS_PATH = path.join(STORAGE_DIR, "banking-allocations.json");
const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
function ensureStoreFiles() {
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
if (!fs.existsSync(EPOCHS_PATH)) fs.writeFileSync(EPOCHS_PATH, "[]");
if (!fs.existsSync(TRANSFERS_PATH)) fs.writeFileSync(TRANSFERS_PATH, "[]");
if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
}
function epochIdNow() {
const d = new Date();
const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = tmp.getUTCDay() || 7;
tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((tmp - yearStart) / 86400000) + 1) / 7);
const yyyy = tmp.getUTCFullYear();
return `${yyyy}-${String(weekNo).padStart(2, "0")}`;
}
async function getAnyWalletAddress() {
const tryOne = async (method, params = []) => {
const r = await rpcCall(method, params, "user");
if (!r) return null;
if (typeof r === "string" && isValidEcoinAddress(r)) return r;
if (Array.isArray(r) && r.length && isValidEcoinAddress(r[0])) return r[0];
if (r && typeof r === "object") {
const keys = Object.keys(r);
if (keys.length && isValidEcoinAddress(keys[0])) return keys[0];
if (r.address && isValidEcoinAddress(r.address)) return r.address;
}
return null;
};
return await tryOne("getnewaddress")
|| await tryOne("getaddress")
|| await tryOne("getaccountaddress", [""])
|| await tryOne("getaddressesbyaccount", [""])
|| await tryOne("getaddressesbylabel", [""])
|| await tryOne("getaddressesbylabel", ["default"]);
}
async function ensureSelfAddressPublished() {
const me = config.keys.id;
const local = readAddrMap();
const current = typeof local[me] === "string" ? local[me] : (local[me] && local[me].address) || null;
if (current && isValidEcoinAddress(current)) return { status: "present", address: current };
const cfg = getWalletCfg("user") || {};
if (!cfg.url) return { status: "skipped" };
const addr = await getAnyWalletAddress();
if (addr && isValidEcoinAddress(addr)) {
const m = readAddrMap();
m[me] = addr;
writeAddrMap(m);
let ssb = null;
try {
if (services?.cooler?.open) ssb = await services.cooler.open();
else if (global.ssb) ssb = global.ssb;
else {
try {
const srv = require("../server/SSB_server.js");
ssb = srv?.ssb || srv?.server || srv?.default || null;
} catch (_) {}
}
} catch (_) {}
if (ssb && ssb.publish) {
await new Promise((resolve, reject) =>
ssb.publish(
{ type: "wallet", coin: "ECO", address: addr, timestamp: Date.now(), updatedAt: new Date().toISOString() },
(err) => err ? reject(err) : resolve()
)
);
}
return { status: "published", address: addr };
}
return { status: "error" };
}
function readJson(p, d) {
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return d; }
}
function writeJson(p, v) {
fs.writeFileSync(p, JSON.stringify(v, null, 2));
}
async function rpcCall(method, params, kind = "user") {
const cfg = getWalletCfg(kind);
if (!cfg?.url) {
return null;
}
const headers = {
"Content-Type": "application/json",
};
if (cfg.user || cfg.pass) {
headers.authorization = "Basic " + Buffer.from(`${cfg.user}:${cfg.pass}`).toString("base64");
}
try {
const res = await fetch(cfg.url, {
method: "POST",
headers: headers,
body: JSON.stringify({
jsonrpc: "1.0",
id: "oasis",
method: method,
params: params,
}),
});
if (!res.ok) {
return null;
}
const data = await res.json();
if (data.error) {
return null;
}
return data.result;
} catch (err) {
return null;
}
}
async function safeGetBalance(kind = "user") {
try {
const r = await rpcCall("getbalance", [], kind);
return Number(r) || 0;
} catch {
return 0;
}
}
function readAddrMap() {
ensureStoreFiles();
const raw = readJson(ADDR_PATH, {});
return raw && typeof raw === "object" ? raw : {};
}
function writeAddrMap(m) {
ensureStoreFiles();
writeJson(ADDR_PATH, m || {});
}
function getLogLimit() {
return getConfig().ssbLogStream?.limit || 1000;
}
function isValidEcoinAddress(addr) {
return typeof addr === "string" && /^[A-Za-z0-9]{20,64}$/.test(addr);
}
function getWalletCfg(kind) {
const cfg = getConfig() || {};
if (kind === "pub") {
return cfg.walletPub || cfg.pubWallet || (cfg.pub && cfg.pub.wallet) || null;
}
return cfg.wallet || null;
}
function resolveUserId(maybeId) {
const s = String(maybeId || "").trim();
if (s) return s;
return config?.keys?.id || "";
}
let FEED_SRC = "none";
module.exports = ({ services } = {}) => {
const transfersRepo = {
listAll: async () => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []); },
listByTag: async (tag) => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []).filter(t => (t.tags || []).includes(tag)); },
findById: async (id) => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []).find(t => t.id === id) || null; },
create: async (t) => { ensureStoreFiles(); const all = readJson(TRANSFERS_PATH, []); all.push(t); writeJson(TRANSFERS_PATH, all); },
markClosed: async (id, txid) => { ensureStoreFiles(); const all = readJson(TRANSFERS_PATH, []); const i = all.findIndex(x => x.id === id); if (i >= 0) { all[i].status = "CLOSED"; all[i].txid = txid; writeJson(TRANSFERS_PATH, all); } }
};
const epochsRepo = {
list: async () => { ensureStoreFiles(); return readJson(EPOCHS_PATH, []); },
save: async (epoch) => { ensureStoreFiles(); const all = readJson(EPOCHS_PATH, []); const i = all.findIndex(e => e.id === epoch.id); if (i >= 0) all[i] = epoch; else all.push(epoch); writeJson(EPOCHS_PATH, all); },
get: async (id) => { ensureStoreFiles(); return readJson(EPOCHS_PATH, []).find(e => e.id === id) || null; }
};
let ssbInstance;
async function openSsb() {
if (ssbInstance) return ssbInstance;
if (services?.cooler?.open) ssbInstance = await services.cooler.open();
else if (cooler?.open) ssbInstance = await cooler.open();
else if (global.ssb) ssbInstance = global.ssb;
else {
try {
const srv = require("../server/SSB_server.js");
ssbInstance = srv?.ssb || srv?.server || srv?.default || null;
} catch (_) {
ssbInstance = null;
}
}
return ssbInstance;
}
async function getWalletFromSSB(userId) {
const ssb = await openSsb();
if (!ssb) return null;
const msgs = await new Promise((resolve, reject) =>
pull(
ssb.createLogStream({ limit: getLogLimit() }),
pull.collect((err, arr) => err ? reject(err) : resolve(arr))
)
);
for (let i = msgs.length - 1; i >= 0; i--) {
const v = msgs[i].value || {};
const c = v.content || {};
if (v.author === userId && c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") {
return c.address;
}
}
return null;
}
async function scanAllWalletsSSB() {
const ssb = await openSsb();
if (!ssb) return {};
const latest = {};
const msgs = await new Promise((resolve, reject) =>
pull(
ssb.createLogStream({ limit: getLogLimit() }),
pull.collect((err, arr) => err ? reject(err) : resolve(arr))
)
);
for (let i = msgs.length - 1; i >= 0; i--) {
const v = msgs[i].value || {};
const c = v.content || {};
if (c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") {
if (!latest[v.author]) latest[v.author] = c.address;
}
}
return latest;
}
async function publishSelfAddress(address) {
const ssb = await openSsb();
if (!ssb) return false;
const msg = { type: "wallet", coin: "ECO", address, updatedAt: new Date().toISOString() };
await new Promise((resolve, reject) => ssb.publish(msg, (err, val) => err ? reject(err) : resolve(val)));
return true;
}
async function listUsers() {
const addrLocal = readAddrMap();
const ids = Object.keys(addrLocal);
if (ids.length > 0) return ids.map(id => ({ id }));
return [{ id: config.keys.id }];
}
async function getUserAddress(userId) {
const v = readAddrMap()[userId];
if (v === "__removed__") return null;
const local = typeof v === "string" ? v : (v && v.address) || null;
if (local) return local;
const ssbAddr = await getWalletFromSSB(userId);
return ssbAddr;
}
async function setUserAddress(userId, address, publishIfSelf) {
const m = readAddrMap();
m[userId] = address;
writeAddrMap(m);
if (publishIfSelf && idsEqual(userId, config.keys.id)) await publishSelfAddress(address);
return true;
}
async function addAddress({ userId, address }) {
if (!userId || !address || !isValidEcoinAddress(address)) return { status: "invalid" };
const m = readAddrMap();
const prev = m[userId];
m[userId] = address;
writeAddrMap(m);
if (idsEqual(userId, config.keys.id)) await publishSelfAddress(address);
return { status: prev ? (prev === address || (prev && prev.address === address) ? "exists" : "updated") : "added" };
}
async function removeAddress({ userId }) {
if (!userId) return { status: "invalid" };
const m = readAddrMap();
if (m[userId]) {
delete m[userId];
writeAddrMap(m);
return { status: "deleted" };
}
const ssbAll = await scanAllWalletsSSB();
if (!ssbAll[userId]) return { status: "not_found" };
m[userId] = "__removed__";
writeAddrMap(m);
return { status: "deleted" };
}
async function listAddressesMerged() {
const local = readAddrMap();
const ssbAll = await scanAllWalletsSSB();
const keys = new Set([...Object.keys(local), ...Object.keys(ssbAll)]);
const out = [];
for (const id of keys) {
if (local[id] === "__removed__") continue;
if (local[id]) out.push({ id, address: typeof local[id] === "string" ? local[id] : local[id].address, source: "local" });
else if (ssbAll[id]) out.push({ id, address: ssbAll[id], source: "ssb" });
}
return out;
}
function idsEqual(a, b) {
if (!a || !b) return false;
const A = String(a).trim();
const B = String(b).trim();
if (A === B) return true;
const strip = s => s.replace(/^@/, "").replace(/\.ed25519$/, "");
return strip(A) === strip(B);
}
function inferType(c = {}) {
if (c.vote) return "vote";
if (c.votes) return "votes";
if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet";
if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim";
if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market";
if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project";
if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe";
if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event";
if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task";
if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report";
if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job";
if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio";
if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video";
if (typeof c.url !== "undefined" && c.title && c.key) return "document";
if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed";
if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post";
if (typeof c.contact !== "undefined") return "contact";
if (typeof c.about !== "undefined") return "about";
if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer";
return "";
}
function normalizeType(a) {
const t = a.type || a.content?.type || inferType(a.content) || "";
return String(t).toLowerCase();
}
function priorityBump(p) {
const s = String(p || "").toUpperCase();
if (s === "HIGH") return 3;
if (s === "MEDIUM") return 1;
return 0;
}
function severityBump(s) {
const x = String(s || "").toUpperCase();
if (x === "CRITICAL") return 6;
if (x === "HIGH") return 4;
if (x === "MEDIUM") return 2;
return 0;
}
function scoreMarket(c) {
const st = String(c.status || "").toUpperCase();
let s = 5;
if (st === "SOLD") s += 8;
else if (st === "ACTIVE") s += 3;
const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
s += Math.min(10, bids);
return s;
}
function scoreProject(c) {
const st = String(c.status || "ACTIVE").toUpperCase();
const prog = Number(c.progress || 0);
let s = 8 + Math.min(10, prog / 10);
if (st === "FUNDED") s += 10;
return s;
}
function calculateOpinionScore(content) {
const cats = content?.opinions || {};
let s = 0;
for (const k in cats) {
if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
if (k === "interesting" || k === "inspiring") s += 5;
else if (k === "boring" || k === "spam" || k === "propaganda") s -= 3;
else s += 1;
}
return s;
}
async function listAllActions() {
if (services?.feed?.listAll) {
const arr = await services.feed.listAll();
FEED_SRC = "services.feed.listAll";
return normalizeFeedArray(arr);
}
if (services?.activity?.list) {
const arr = await services.activity.list();
FEED_SRC = "services.activity.list";
return normalizeFeedArray(arr);
}
if (typeof global.listFeed === "function") {
const arr = await global.listFeed("all");
FEED_SRC = "global.listFeed('all')";
return normalizeFeedArray(arr);
}
const ssb = await openSsb();
if (!ssb || !ssb.createLogStream) {
FEED_SRC = "none";
return [];
}
const msgs = await new Promise((resolve, reject) =>
pull(
ssb.createLogStream({ limit: getLogLimit() }),
pull.collect((err, arr) => err ? reject(err) : resolve(arr))
)
);
FEED_SRC = "ssb.createLogStream";
return msgs.map(m => {
const v = m.value || {};
const c = v.content || {};
return {
id: v.key || m.key,
author: v.author,
type: (c.type || "").toLowerCase(),
value: v,
content: c
};
});
}
function normalizeFeedArray(arr) {
if (!Array.isArray(arr)) return [];
return arr.map(x => {
const value = x.value || {};
const content = x.content || value.content || {};
const author = x.author || value.author || content.author || null;
const type = (content.type || "").toLowerCase();
return { id: x.id || value.key || x.key, author, type, value, content };
});
}
async function publishKarmaScore(userId, karmaScore) {
const ssb = await openSsb();
if (!ssb || !ssb.publish) return false;
const timestamp = new Date().toISOString();
const content = { type: "karmaScore", karmaScore, userId, timestamp };
return new Promise((resolve, reject) => {
ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
});
}
async function fetchUserActions(userId) {
const me = resolveUserId(userId);
const actions = await listAllActions();
const authored = actions.filter(a =>
(a.author && a.author === me) || (a.value?.author && a.value.author === me)
);
if (authored.length) return authored;
return actions.filter(a => {
const c = a.content || {};
const fields = [c.author, c.organizer, c.seller, c.about, c.contact];
return fields.some(f => f && f === me);
});
}
// karma scoring table
function scoreFromActions(actions) {
let score = 0;
for (const action of actions) {
const t = normalizeType(action);
const c = action.content || {};
const rawType = String(c.type || "").toLowerCase();
if (t === "post") score += 10;
else if (t === "comment") score += 5;
else if (t === "like") score += 2;
else if (t === "image") score += 8;
else if (t === "video") score += 12;
else if (t === "audio") score += 8;
else if (t === "document") score += 6;
else if (t === "bookmark") score += 2;
else if (t === "feed") score += 6;
else if (t === "forum") score += c.root ? 5 : 10;
else if (t === "vote") score += 3 + calculateOpinionScore(c);
else if (t === "votes") score += Math.min(10, Number(c.totalVotes || 0));
else if (t === "market") score += scoreMarket(c);
else if (t === "project") score += scoreProject(c);
else if (t === "tribe") score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0);
else if (t === "event") score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0);
else if (t === "task") score += 3 + priorityBump(c.priority);
else if (t === "report") score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity);
else if (t === "curriculum") score += 5;
else if (t === "aiexchange") score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0;
else if (t === "job") score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0);
else if (t === "bankclaim") score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5);
else if (t === "bankwallet") score += 2;
else if (t === "transfer") score += 1;
else if (t === "about") score += 1;
else if (t === "contact") score += 1;
else if (t === "pub") score += 1;
else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") score += 12;
else if (t === "parliamentterm" || rawType === "parliamentterm") score += 25;
else if (t === "parliamentproposal" || rawType === "parliamentproposal") score += 8;
else if (t === "parliamentlaw" || rawType === "parliamentlaw") score += 16;
else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") score += 10;
else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") score += 4;
else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") score += 3;
else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") score += 4;
else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") score += 10;
else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") score += 8;
else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") score += 6;
else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") score += 3;
else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") score += 1;
else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") score += 6;
else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") score += 2;
else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") score += 3;
else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") score += 5;
}
return Math.max(0, Math.round(score));
}
async function getUserEngagementScore(userId) {
const ssb = await openSsb();
const uid = resolveUserId(userId);
const actions = await fetchUserActions(uid);
const karmaScore = scoreFromActions(actions);
const prev = await getLastKarmaScore(uid);
const lastPublishedTimestamp = await getLastPublishedTimestamp(uid);
const isSelf = idsEqual(uid, ssb.id);
const hasSSB = !!(ssb && ssb.publish);
const changed = (prev === null) || (karmaScore !== prev);
const nowMs = Date.now();
const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0;
const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000;
if (isSelf && hasSSB && changed && cooldownOk) {
await publishKarmaScore(uid, karmaScore);
}
return karmaScore;
}
async function getLastKarmaScore(userId) {
const ssb = await openSsb();
if (!ssb) return null;
return new Promise((resolve) => {
const source = ssb.messagesByType
? ssb.messagesByType({ type: "karmaScore", reverse: true })
: ssb.createLogStream && ssb.createLogStream({ reverse: true });
if (!source) return resolve(null);
pull(
source,
pull.filter(msg => {
const v = msg.value || msg;
const c = v.content || {};
return c && c.type === "karmaScore" && c.userId === userId;
}),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(null);
const v = arr[0].value || arr[0];
const c = v.content || {};
resolve(Number(c.karmaScore) || 0);
})
);
});
}
async function getLastPublishedTimestamp(userId) {
const ssb = await openSsb();
if (!ssb) return new Date(0).toISOString();
const fallback = new Date(0).toISOString();
return new Promise((resolve) => {
const source = ssb.messagesByType
? ssb.messagesByType({ type: "karmaScore", reverse: true })
: ssb.createLogStream && ssb.createLogStream({ reverse: true });
if (!source) return resolve(fallback);
pull(
source,
pull.filter(msg => {
const v = msg.value || msg;
const c = v.content || {};
return c && c.type === "karmaScore" && c.userId === userId;
}),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(fallback);
const v = arr[0].value || arr[0];
const c = v.content || {};
resolve(c.timestamp || fallback);
})
);
});
}
function computePoolVars(pubBal, rules) {
const alphaCap = (rules.alpha || DEFAULT_RULES.alpha) * pubBal;
const available = Math.max(0, pubBal - (rules.reserveMin || DEFAULT_RULES.reserveMin));
const rawMin = Math.min(available, (rules.capPerEpoch || DEFAULT_RULES.capPerEpoch), alphaCap);
const pool = clamp(rawMin, 0, Number.MAX_SAFE_INTEGER);
return { pubBal, alphaCap, available, rawMin, pool };
}
async function computeEpoch({ epochId, userId, rules = DEFAULT_RULES }) {
const pubBal = await safeGetBalance("pub");
const pv = computePoolVars(pubBal, rules);
const engagementScore = await getUserEngagementScore(userId);
const userWeight = 1 + engagementScore / 100;
const weights = [{ user: userId, w: userWeight }];
const W = weights.reduce((acc, x) => acc + x.w, 0) || 1;
const capUser = (rules.caps && rules.caps.cap_user_epoch) || DEFAULT_RULES.caps.cap_user_epoch;
const allocations = weights.map(({ user, w }) => {
const amount = Math.min(pv.pool * w / W, capUser);
return {
id: `alloc:${epochId}:${user}`,
epoch: epochId,
user,
weight: Number(w.toFixed(6)),
amount: Number(amount.toFixed(6))
};
});
const snapshot = JSON.stringify({ epochId, pool: pv.pool, weights, allocations, rules }, null, 2);
const hash = crypto.createHash("sha256").update(snapshot).digest("hex");
return { epoch: { id: epochId, pool: Number(pv.pool.toFixed(6)), weightsSum: Number(W.toFixed(6)), rules, hash }, allocations };
}
async function executeEpoch({ epochId, rules = DEFAULT_RULES }) {
const { epoch, allocations } = await computeEpoch({ epochId, userId: config.keys.id, rules });
await epochsRepo.save(epoch);
for (const a of allocations) {
if (a.amount <= 0) continue;
await transfersRepo.create({
id: a.id,
from: "PUB",
to: a.user,
amount: a.amount,
concept: `UBI ${epochId}`,
status: "UNCONFIRMED",
createdAt: new Date().toISOString(),
deadline: new Date(Date.now() + DEFAULT_RULES.graceDays * 86400000).toISOString(),
tags: ["UBI", `epoch:${epochId}`],
opinions: {}
});
}
return { epoch, allocations };
}
async function publishBankClaim({ amount, epochId, allocationId, txid }) {
const ssbClient = await openSsb();
const content = { type: "bankClaim", amount, epochId, allocationId, txid, timestamp: Date.now() };
return new Promise((resolve, reject) => ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res)));
}
async function claimAllocation({ transferId, claimerId, pubWalletUrl, pubWalletUser, pubWalletPass }) {
const allocation = await transfersRepo.findById(transferId);
if (!allocation || allocation.status !== "UNCONFIRMED") throw new Error("Invalid allocation or already confirmed.");
if (allocation.to !== claimerId) throw new Error("This allocation is not for you.");
const txid = await rpcCall("sendtoaddress", [pubWalletUrl, allocation.amount, "UBI claim", pubWalletUser, pubWalletPass]);
return { txid };
}
async function updateAllocationStatus(allocationId, status, txid) {
const all = await transfersRepo.listAll();
const idx = all.findIndex(t => t.id === allocationId);
if (idx >= 0) {
all[idx].status = status;
all[idx].txid = txid;
await transfersRepo.create(all[idx]);
}
}
async function listBanking(filter = "overview", userId) {
const uid = resolveUserId(userId);
const epochId = epochIdNow();
const pubBalance = await safeGetBalance("pub");
const userBalance = await safeGetBalance("user");
const epochs = await epochsRepo.list();
const all = await transfersRepo.listByTag("UBI");
const allocations = all.map(t => ({
id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status,
createdAt: t.createdAt || t.deadline || new Date().toISOString(), txid: t.txid
}));
let computed = null;
try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {}
const pv = computePoolVars(pubBalance, DEFAULT_RULES);
const actions = await fetchUserActions(uid);
const engagementScore = scoreFromActions(actions);
const poolForEpoch = computed?.epoch?.pool || pv.pool || 0;
const futureUBI = Number(((engagementScore / 100) * poolForEpoch).toFixed(6));
const addresses = await listAddressesMerged();
const summary = {
userBalance,
pubBalance,
epochId,
pool: poolForEpoch,
weightsSum: computed?.epoch?.weightsSum || 0,
userEngagementScore: engagementScore,
futureUBI
};
const exchange = await calculateEcoinValue();
return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses, exchange };
}
async function getAllocationById(id) {
const t = await transfersRepo.findById(id);
if (!t) return null;
return { id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid };
}
async function getEpochById(id) {
const existing = await epochsRepo.get(id);
if (existing) return existing;
const all = await transfersRepo.listAll();
const filtered = all.filter(t => (t.tags || []).includes(`epoch:${id}`));
const pool = filtered.reduce((s, t) => s + Number(t.amount || 0), 0);
return { id, pool, weightsSum: 0, rules: DEFAULT_RULES, hash: "-" };
}
async function listEpochAllocations(id) {
const all = await transfersRepo.listAll();
return all.filter(t => (t.tags || []).includes(`epoch:${id}`)).map(t => ({
id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid
}));
}
async function calculateEcoinValue() {
let isSynced = false;
let circulatingSupply = 0;
try {
circulatingSupply = await getCirculatingSupply();
isSynced = circulatingSupply > 0;
} catch (error) {
circulatingSupply = 0;
isSynced = false;
}
const totalSupply = 25500000;
const ecoValuePerHour = await calculateEcoValuePerHour(circulatingSupply);
const ecoInHours = calculateEcoinHours(circulatingSupply, ecoValuePerHour);
const inflationFactor = await calculateInflationFactor(circulatingSupply, totalSupply);
return {
ecoValue: ecoValuePerHour,
ecoInHours: Number(ecoInHours.toFixed(2)),
totalSupply: totalSupply,
inflationFactor: inflationFactor ? Number(inflationFactor.toFixed(2)) : 0,
currentSupply: circulatingSupply,
isSynced: isSynced
};
}
async function calculateEcoValuePerHour(circulatingSupply) {
const issuanceRate = await getIssuanceRate();
const inflation = await calculateInflationFactor(circulatingSupply, 25500000);
const ecoValuePerHour = (circulatingSupply / 100000) * (1 + inflation / 100);
return ecoValuePerHour;
}
function calculateEcoinHours(circulatingSupply, ecoValuePerHour) {
const ecoInHours = circulatingSupply / ecoValuePerHour;
return ecoInHours;
}
async function calculateInflationFactor(circulatingSupply, totalSupply) {
const issuanceRate = await getIssuanceRate();
if (circulatingSupply > 0) {
const inflationRate = (issuanceRate / circulatingSupply) * 100;
return inflationRate;
}
return 0;
}
async function getIssuanceRate() {
try {
const result = await rpcCall("getmininginfo", []);
const blockValue = result?.blockvalue || 0;
const blocks = result?.blocks || 0;
return (blockValue / 1e8) * blocks;
} catch (error) {
return 0.02;
}
}
async function getCirculatingSupply() {
try {
const result = await rpcCall("getinfo", []);
return result?.moneysupply || 0;
} catch (error) {
return 0;
}
}
async function getBankingData(userId) {
const ecoValue = await calculateEcoinValue();
const karmaScore = await getUserEngagementScore(userId);
return {
ecoValue,
karmaScore,
};
}
return {
DEFAULT_RULES,
computeEpoch,
executeEpoch,
getUserEngagementScore,
publishBankClaim,
claimAllocation,
listBanking,
getAllocationById,
getEpochById,
listEpochAllocations,
addAddress,
removeAddress,
ensureSelfAddressPublished,
getUserAddress,
setUserAddress,
listAddressesMerged,
calculateEcoinValue,
getBankingData
};
};

View file

@ -0,0 +1,328 @@
const pull = require('../server/node_modules/pull-stream');
const { config } = require('../server/SSB_server.js');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const hasBlob = async (ssbClient, url) =>
new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
const isClosedSold = s =>
String(s || '').toUpperCase() === 'SOLD' || String(s || '').toUpperCase() === 'CLOSED';
const projectRank = (status) => {
const S = String(status || '').toUpperCase();
if (S === 'COMPLETED') return 3;
if (S === 'ACTIVE') return 2;
if (S === 'PAUSED') return 1;
if (S === 'CANCELLED') return 0;
return -1;
};
const safeDecode = (s) => {
try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
};
const parseTs = (s) => {
const raw = String(s || '').trim();
if (!raw) return null;
const ts = new Date(raw).getTime();
return Number.isFinite(ts) ? ts : null;
};
const matchBlockId = (blockId, q) => {
const a = String(blockId || '');
const b = String(q || '');
if (!a || !b) return true;
const al = a.toLowerCase();
const bl = b.toLowerCase();
if (al.includes(bl)) return true;
const ad = safeDecode(a).toLowerCase();
const bd = safeDecode(b).toLowerCase();
return ad.includes(bd) || ad.includes(bl) || al.includes(bd);
};
const matchAuthorOrName = (authorId, authorName, query) => {
const q0 = String(query || '').trim().toLowerCase();
if (!q0) return true;
const qNoAt = q0.replace(/^@/, '');
const aid = String(authorId || '').toLowerCase();
if (aid.includes(q0)) return true;
if (qNoAt && aid.includes('@' + qNoAt)) return true;
const nm0 = String(authorName || '').trim().toLowerCase();
const nmNoAt = nm0.replace(/^@/, '');
if (!nmNoAt) return false;
if (nm0.includes(q0)) return true;
if (qNoAt && nmNoAt.includes(qNoAt)) return true;
return false;
};
const buildNameIndexFromAbout = async (ssbClient, minLimit = 5000) => {
const nameByFeedId = new Map();
if (!ssbClient?.query?.read) return nameByFeedId;
const limit = Math.max(minLimit, logLimit);
const source = await ssbClient.query.read({
query: [{ $filter: { value: { content: { type: 'about' } } } }],
reverse: true,
limit
});
const aboutMsgs = await new Promise((resolve, reject) => {
pull(
source,
pull.take(limit),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs || [])))
);
});
for (const msg of aboutMsgs) {
const c = msg?.value?.content;
if (!c || c.type !== 'about') continue;
const aboutId = String(c.about || msg?.value?.author || '').trim();
const nm = typeof c.name === 'string' ? c.name.trim() : '';
if (!aboutId || !nm) continue;
if (!nameByFeedId.has(aboutId)) nameByFeedId.set(aboutId, nm);
}
return nameByFeedId;
};
return {
async listBlockchain(filter = 'all', userId, search = {}) {
const ssbClient = await openSsb();
const results = await new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
);
const tombstoned = new Set();
const idToBlock = new Map();
const referencedAsReplaces = new Set();
const nameByFeedId = new Map();
for (const msg of results) {
const k = msg.key;
const c = msg.value?.content;
const author = msg.value?.author;
if (!c?.type) continue;
if (c.type === 'about') {
const aboutId = String(c.about || author || '').trim();
const nm = typeof c.name === 'string' ? c.name.trim() : '';
if (aboutId && nm && !nameByFeedId.has(aboutId)) nameByFeedId.set(aboutId, nm);
}
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
continue;
}
if (c.replaces) referencedAsReplaces.add(c.replaces);
idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
}
const tipBlocks = [];
for (const [id, block] of idToBlock.entries()) {
if (!referencedAsReplaces.has(id) && block.content.replaces) tipBlocks.push(block);
}
for (const [id, block] of idToBlock.entries()) {
if (!block.content.replaces && !referencedAsReplaces.has(id)) tipBlocks.push(block);
}
const groups = {};
for (const block of tipBlocks) {
const ancestor = block.content.replaces || block.id;
if (!groups[ancestor]) groups[ancestor] = [];
groups[ancestor].push(block);
}
const liveTipIds = new Set();
for (const groupBlocks of Object.values(groups)) {
let best = groupBlocks[0];
for (const block of groupBlocks) {
if (block.type === 'market') {
if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
best = block;
} else if ((block.content.status === best.content.status) && block.ts > best.ts) {
best = block;
}
} else if (block.type === 'project') {
const br = projectRank(best.content.status);
const cr = projectRank(block.content.status);
if (cr > br || (cr === br && block.ts > best.ts)) best = block;
} else if (block.type === 'job' || block.type === 'forum') {
if (block.ts > best.ts) best = block;
} else {
if (block.ts > best.ts) best = block;
}
}
liveTipIds.add(best.id);
}
const blockData = Array.from(idToBlock.values()).map(block => {
const c = block.content;
const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root);
return {
...block,
isTombstoned: tombstoned.has(block.id),
isReplaced: c.replaces
? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
: referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted
};
});
let filtered = blockData;
if (filter === 'RECENT' || filter === 'recent') {
const now = Date.now();
filtered = filtered.filter(b => b && now - b.ts <= 24 * 60 * 60 * 1000);
}
if (filter === 'MINE' || filter === 'mine') {
const me = userId || config.keys.id;
filtered = filtered.filter(b => b && b.author === me);
}
if (filter === 'PARLIAMENT' || filter === 'parliament') {
const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']);
filtered = filtered.filter(b => b && pset.has(b.type));
}
if (filter === 'COURTS' || filter === 'courts') {
const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']);
filtered = filtered.filter(b => b && cset.has(b.type));
}
const s = search || {};
const authorQ = String(s.author || '').trim();
const idQ = String(s.id || '').trim();
const fromTs = parseTs(s.from);
const toTs = parseTs(s.to);
let aboutIndex = null;
const needsNameSearch = !!authorQ && !authorQ.toLowerCase().includes('.ed25519');
if (needsNameSearch) {
aboutIndex = await buildNameIndexFromAbout(ssbClient, 10000);
for (const [fid, nm] of aboutIndex.entries()) {
if (!nameByFeedId.has(fid)) nameByFeedId.set(fid, nm);
}
}
filtered = filtered.filter(b => {
if (!b) return false;
if (fromTs != null && b.ts < fromTs) return false;
if (toTs != null && b.ts > toTs) return false;
if (authorQ) {
const nm = nameByFeedId.get(b.author) || '';
if (!matchAuthorOrName(b.author, nm, authorQ)) return false;
}
if (idQ && !matchBlockId(b.id, idQ)) return false;
return true;
});
return filtered.filter(Boolean);
},
async getBlockById(id) {
const ssbClient = await openSsb();
const results = await new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
);
const tombstoned = new Set();
const idToBlock = new Map();
const referencedAsReplaces = new Set();
for (const msg of results) {
const k = msg.key;
const c = msg.value?.content;
const author = msg.value?.author;
if (!c?.type) continue;
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
continue;
}
if (c.replaces) referencedAsReplaces.add(c.replaces);
idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
}
const tipBlocks = [];
for (const [bid, block] of idToBlock.entries()) {
if (!referencedAsReplaces.has(bid) && block.content.replaces) tipBlocks.push(block);
}
for (const [bid, block] of idToBlock.entries()) {
if (!block.content.replaces && !referencedAsReplaces.has(bid)) tipBlocks.push(block);
}
const groups = {};
for (const block of tipBlocks) {
const ancestor = block.content.replaces || block.id;
if (!groups[ancestor]) groups[ancestor] = [];
groups[ancestor].push(block);
}
const liveTipIds = new Set();
for (const groupBlocks of Object.values(groups)) {
let best = groupBlocks[0];
for (const block of groupBlocks) {
if (block.type === 'market') {
if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
best = block;
} else if ((block.content.status === best.content.status) && block.ts > best.ts) {
best = block;
}
} else if (block.type === 'project') {
const br = projectRank(best.content.status);
const cr = projectRank(block.content.status);
if (cr > br || (cr === br && block.ts > best.ts)) best = block;
} else if (block.type === 'job' || block.type === 'forum') {
if (block.ts > best.ts) best = block;
} else {
if (block.ts > best.ts) best = block;
}
}
liveTipIds.add(best.id);
}
const block = idToBlock.get(id);
if (!block) return null;
if (block.type === 'document') {
const valid = await hasBlob(ssbClient, block.content.url);
if (!valid) return null;
}
const c = block.content;
const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root);
const isTombstoned = tombstoned.has(block.id);
const isReplaced = c.replaces
? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
: referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted;
return { ...block, isTombstoned, isReplaced };
}
};
};

View file

@ -0,0 +1,332 @@
const pull = require("../server/node_modules/pull-stream");
const moment = require("../server/node_modules/moment");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const safeText = (v) => String(v || "").trim();
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return [];
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const coerceLastVisit = (lastVisit) => {
const now = moment();
if (!lastVisit) return now.toISOString();
const m = moment(lastVisit, moment.ISO_8601, true);
if (!m.isValid()) return now.toISOString();
if (m.isAfter(now)) return now.toISOString();
return m.toISOString();
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
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 !== "bookmark") continue;
const ts = v.timestamp || m.timestamp || 0;
nodes.set(k, { key: k, ts, c });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
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;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, tipOf(r));
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
};
const buildBookmark = (node, rootId, viewerId) => {
const c = node.c || {};
const voters = safeArr(c.opinions_inhabitants);
return {
id: node.key,
rootId,
url: c.url || "",
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
lastVisit: c.lastVisit || null,
tags: safeArr(c.tags),
category: c.category || "",
description: c.description || "",
opinions: c.opinions || {},
opinions_inhabitants: voters,
author: c.author,
hasVoted: viewerId ? voters.includes(viewerId) : false
};
};
return {
type: "bookmark",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Bookmark not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Bookmark not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createBookmark(url, tagsRaw, description, category, lastVisit) {
const ssbClient = await openSsb();
const now = new Date().toISOString();
const u = safeText(url);
if (!u) throw new Error("URL is required");
const content = {
type: "bookmark",
author: ssbClient.id,
url: u,
tags: normalizeTags(tagsRaw),
description: description || "",
category: category || "",
createdAt: now,
updatedAt: now,
lastVisit: coerceLastVisit(lastVisit),
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateBookmarkById(id, updatedData) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "bookmark") throw new Error("Bookmark not found");
if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit bookmark after it has received opinions.");
if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
const url = safeText(updatedData.url || oldMsg.content.url);
if (!url) throw new Error("URL is required");
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url,
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "",
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
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)));
});
},
async deleteBookmarkById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
if (String(msg.content.author) !== String(userId)) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = safeText(opts.q || "").toLowerCase();
const sort = safeText(opts.sort || "recent");
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildBookmark(node, rootId, viewerId));
}
let list = items;
const now = Date.now();
if (filter === "mine") list = list.filter((b) => String(b.author) === String(viewerId));
else if (filter === "recent") list = list.filter((b) => new Date(b.createdAt).getTime() >= now - 86400000);
else if (filter === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
}
if (q) {
list = list.filter((b) => {
const url = String(b.url || "").toLowerCase();
const cat = String(b.category || "").toLowerCase();
const desc = String(b.description || "").toLowerCase();
const tags = safeArr(b.tags).join(" ").toLowerCase();
const author = String(b.author || "").toLowerCase();
return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
});
}
if (sort === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getBookmarkById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const rootId = await this.resolveRootId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
return buildBookmark({ key: tipId, ts: msg.timestamp || 0, c: msg.content }, rootId, viewer);
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
const voters = safeArr(msg.content.opinions_inhabitants);
if (voters.includes(userId)) throw new Error("Already voted");
const now = new Date().toISOString();
const updated = {
...msg.content,
replaces: tipId,
opinions: {
...msg.content.opinions,
[category]: (msg.content.opinions?.[category] || 0) + 1
},
opinions_inhabitants: voters.concat(userId),
updatedAt: now
};
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)));
});
}
};
};

View file

@ -0,0 +1,51 @@
const crypto = require('crypto');
function generateKeyFromPassword(password, salt) {
return crypto.scryptSync(password, salt, 32);
}
function encryptText(text, password) {
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
const key = generateKeyFromPassword(password, salt);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encryptedText = cipher.update(text, 'utf-8', 'hex');
encryptedText += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
const ivHex = iv.toString('hex');
const saltHex = salt.toString('hex');
return { encryptedText, iv: ivHex, salt: saltHex, authTag };
}
function decryptText(encryptedText, password, ivHex, saltHex, authTagHex) {
const salt = Buffer.from(saltHex, 'hex');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const key = generateKeyFromPassword(password, salt);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decryptedText = decipher.update(encryptedText, 'hex', 'utf-8');
decryptedText += decipher.final('utf-8');
return decryptedText;
}
function extractComponents(encryptedText) {
const iv = encryptedText.slice(0, 24);
const salt = encryptedText.slice(24, 56);
const authTag = encryptedText.slice(56, 88);
const encrypted = encryptedText.slice(88);
return { iv, salt, authTag, encrypted };
}
module.exports = {
encryptData: (text, password) => {
const { encryptedText, iv, salt, authTag } = encryptText(text, password);
return { encryptedText: iv + salt + authTag + encryptedText, iv, salt, authTag };
},
decryptData: (encryptedText, password) => {
const { iv, salt, authTag, encrypted } = extractComponents(encryptedText);
const decryptedText = decryptText(encrypted, password, iv, salt, authTag);
return decryptedText;
},
extractComponents
};

View file

@ -0,0 +1,625 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const CASE_ANSWER_DAYS = 7;
const CASE_EVIDENCE_DAYS = 14;
const CASE_DECISION_DAYS = 21;
const POPULAR_DAYS = 14;
const FEED_ID_RE = /^@.+\.ed25519$/;
module.exports = ({ cooler, services = {} }) => {
let ssb;
let userId;
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
const nowISO = () => new Date().toISOString();
const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
async function readLog() {
const ssbClient = await openSsb();
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, arr) => (err ? reject(err) : resolve(arr)))
);
});
}
async function listByType(type) {
const msgs = await readLog();
const tomb = new Set();
const rep = new Map();
const map = new Map();
for (const m of msgs) {
const k = m.key || m.id;
const c = m.value?.content || m.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) tomb.add(c.target);
if (c.type === type) {
if (c.replaces) rep.set(c.replaces, k);
map.set(k, { id: k, ...c });
}
}
for (const oldId of rep.keys()) map.delete(oldId);
for (const tId of tomb) map.delete(tId);
return [...map.values()];
}
async function getCurrentUserId() {
await openSsb();
return userId;
}
async function resolveRespondent(candidateInput) {
const s = String(candidateInput || '').trim();
if (!s) return null;
if (FEED_ID_RE.test(s)) {
return { type: 'inhabitant', id: s };
}
if (services.tribes && services.tribes.getTribeById) {
try {
const t = await services.tribes.getTribeById(s);
if (t && t.id) return { type: 'tribe', id: t.id };
} catch {}
}
return null;
}
function computeDeadlines(openedAt) {
const answerBy = moment(openedAt).add(CASE_ANSWER_DAYS, 'days').toISOString();
const evidenceBy = moment(openedAt).add(CASE_EVIDENCE_DAYS, 'days').toISOString();
const decisionBy = moment(openedAt).add(CASE_DECISION_DAYS, 'days').toISOString();
return { answerBy, evidenceBy, decisionBy };
}
async function openCase({ titleBase, respondentInput, method }) {
const ssbClient = await openSsb();
const rawTitle = String(titleBase || '').trim();
if (!rawTitle) throw new Error('Title is required.');
const resp = await resolveRespondent(respondentInput);
if (!resp) throw new Error('Accused / Respondent not found.');
const m = String(method || '').trim().toUpperCase();
const ALLOWED = new Set(['JUDGE', 'DICTATOR', 'POPULAR', 'MEDIATION', 'KARMATOCRACY']);
if (!ALLOWED.has(m)) throw new Error('Invalid resolution method.');
if (m === 'DICTATOR' && services.parliament && services.parliament.getGovernmentCard) {
try {
const gov = await services.parliament.getGovernmentCard();
const gm = String(gov && gov.method ? gov.method : '').toUpperCase();
if (gm !== 'DICTATORSHIP') throw new Error('DICTATOR method requires DICTATORSHIP government.');
} catch (e) {
throw new Error('Unable to verify government method for DICTATOR.');
}
}
const openedAt = nowISO();
const prefix = moment(openedAt).format('MM/YYYY') + '_';
const title = prefix + rawTitle;
const { answerBy, evidenceBy, decisionBy } = computeDeadlines(openedAt);
const content = {
type: 'courtsCase',
title,
accuser: userId,
respondentType: resp.type,
respondentId: resp.id,
method: m,
status: 'OPEN',
openedAt,
answerBy,
evidenceBy,
decisionBy,
mediatorsAccuser: [],
mediatorsRespondent: [],
createdAt: openedAt
};
return await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function listCases(filter = 'open') {
const all = await listByType('courtsCase');
const sorted = all.sort((a, b) => {
const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
return tb - ta;
});
if (filter === 'open') {
return sorted.filter((c) => {
const s = String(c.status || '').toUpperCase();
return s !== 'DECIDED' && s !== 'CLOSED' && s !== 'SOLVED' && s !== 'UNSOLVED' && s !== 'DISCARDED';
});
}
if (filter === 'history') {
return sorted.filter((c) => {
const s = String(c.status || '').toUpperCase();
return s === 'DECIDED' || s === 'CLOSED' || s === 'SOLVED' || s === 'UNSOLVED' || s === 'DISCARDED';
});
}
return sorted;
}
async function listCasesForUser(uid) {
const all = await listByType('courtsCase');
const id = String(uid || userId || '');
const rows = [];
for (const c of all) {
const isAccuser = String(c.accuser || '') === id;
const isRespondent = String(c.respondentId || '') === id;
const ma = ensureArray(c.mediatorsAccuser || []);
const mr = ensureArray(c.mediatorsRespondent || []);
const isMediator = ma.includes(id) || mr.includes(id);
const isJudge = String(c.judgeId || '') === id;
const isDictator = false;
const mine = isAccuser || isRespondent || isMediator || isJudge || isDictator;
if (!mine) continue;
let myPublicPreference = null;
if (isAccuser && typeof c.publicPrefAccuser === 'boolean') {
myPublicPreference = c.publicPrefAccuser;
} else if (isRespondent && typeof c.publicPrefRespondent === 'boolean') {
myPublicPreference = c.publicPrefRespondent;
}
rows.push({
...c,
respondent: c.respondentId || c.respondent,
isAccuser,
isRespondent,
isMediator,
isJudge,
isDictator,
mine,
myPublicPreference
});
}
rows.sort((a, b) => {
const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
return tb - ta;
});
return rows;
}
async function getCaseById(caseId) {
const id = String(caseId || '').trim();
if (!id) return null;
const all = await listByType('courtsCase');
return all.find((c) => c.id === id) || null;
}
async function upsertCase(obj) {
const ssbClient = await openSsb();
const { id, ...rest } = obj;
const updated = {
...rest,
type: 'courtsCase',
replaces: id,
updatedAt: nowISO()
};
return await new Promise((resolve, reject) =>
ssbClient.publish(updated, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
function getCaseRole(caseObj, uid) {
const id = String(uid || '');
if (!id) return 'OTHER';
if (String(caseObj.accuser || '') === id) return 'ACCUSER';
if (String(caseObj.respondentId || '') === id) return 'DEFENCE';
const ma = ensureArray(caseObj.mediatorsAccuser || []);
const mr = ensureArray(caseObj.mediatorsRespondent || []);
if (ma.includes(id) || mr.includes(id)) return 'MEDIATOR';
if (String(caseObj.judgeId || '') === id) return 'JUDGE';
return 'OTHER';
}
async function setMediators({ caseId, side, mediators }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const role = side === 'accuser' ? 'ACCUSER' : side === 'respondent' ? 'DEFENCE' : null;
if (!role) throw new Error('Invalid side.');
const myRole = getCaseRole(c, userId);
if (role === 'ACCUSER' && myRole !== 'ACCUSER') throw new Error('Only accuser can set these mediators.');
if (role === 'DEFENCE' && myRole !== 'DEFENCE') throw new Error('Only defence can set these mediators.');
const list = Array.from(
new Set(
ensureArray(mediators || [])
.map((x) => String(x || '').trim())
.filter(Boolean)
)
);
const clean = list.filter((id) => id !== c.accuser && id !== c.respondentId);
if (side === 'accuser') c.mediatorsAccuser = clean;
else c.mediatorsRespondent = clean;
await upsertCase(c);
return c;
}
async function assignJudge({ caseId, judgeId }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const m = String(c.method || '').toUpperCase();
if (m !== 'JUDGE') throw new Error('This case does not use a judge.');
const myRole = getCaseRole(c, userId);
if (myRole !== 'ACCUSER' && myRole !== 'DEFENCE') throw new Error('Only parties can assign a judge.');
const id = String(judgeId || '').trim();
if (!id) throw new Error('Judge ID is required.');
if (!FEED_ID_RE.test(id)) throw new Error('Invalid judge ID.');
if (id === String(c.accuser || '') || id === String(c.respondentId || '')) {
throw new Error('Judge cannot be a party of the case.');
}
c.judgeId = id;
await upsertCase(c);
return c;
}
async function addEvidence({ caseId, text, link, imageMarkdown }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const role = getCaseRole(c, userId);
if (role === 'OTHER') throw new Error('You are not involved in this case.');
const t = String(text || '').trim();
const l = String(link || '').trim();
let imageUrl = null;
if (imageMarkdown) {
const match = imageMarkdown.match(/\(([^)]+)\)/);
imageUrl = match ? match[1] : imageMarkdown;
}
if (!t && !l && !imageUrl) throw new Error('Text, link or image is required.');
const ssbClient = await openSsb();
const content = {
type: 'courtsEvidence',
caseId: c.id,
author: userId,
role,
text: t,
link: l,
imageUrl,
createdAt: nowISO()
};
return await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function answerCase({ caseId, stance, text }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
if (String(c.respondentId || '') !== String(userId || '')) throw new Error('Only the respondent can answer.');
const s = String(stance || '').trim().toUpperCase();
const ALLOWED = new Set(['DENY', 'ADMIT', 'PARTIAL']);
if (!ALLOWED.has(s)) throw new Error('Invalid stance.');
const t = String(text || '').trim();
if (!t) throw new Error('Response text is required.');
const ssbClient = await openSsb();
const content = {
type: 'courtsAnswer',
caseId: c.id,
respondent: userId,
stance: s,
text: t,
createdAt: nowISO()
};
await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
c.status = 'IN_PROGRESS';
c.answeredAt = nowISO();
await upsertCase(c);
return c;
}
async function issueVerdict({ caseId, result, orders }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const involved =
String(c.accuser || '') === String(userId || '') ||
String(c.respondentId || '') === String(userId || '') ||
ensureArray(c.mediatorsAccuser || []).includes(userId) ||
ensureArray(c.mediatorsRespondent || []).includes(userId);
if (involved) throw new Error('You cannot be judge and party in the same case.');
const r = String(result || '').trim();
if (!r) throw new Error('Result is required.');
const o = String(orders || '').trim();
const ssbClient = await openSsb();
const content = {
type: 'courtsVerdict',
caseId: c.id,
judgeId: userId,
result: r,
orders: o,
createdAt: nowISO()
};
await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
c.status = 'DECIDED';
c.verdictAt = nowISO();
c.judgeId = userId;
await upsertCase(c);
return c;
}
async function proposeSettlement({ caseId, terms }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const role = getCaseRole(c, userId);
if (role === 'OTHER') throw new Error('You are not involved in this case.');
const t = String(terms || '').trim();
if (!t) throw new Error('Terms are required.');
const ssbClient = await openSsb();
const content = {
type: 'courtsSettlementProposal',
caseId: c.id,
proposer: userId,
terms: t,
createdAt: nowISO()
};
return await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function acceptSettlement({ caseId }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const role = getCaseRole(c, userId);
if (role !== 'ACCUSER' && role !== 'DEFENCE') throw new Error('Only parties can accept a settlement.');
const ssbClient = await openSsb();
const content = {
type: 'courtsSettlementAccepted',
caseId: c.id,
by: userId,
createdAt: nowISO()
};
await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
c.status = 'CLOSED';
c.closedAt = nowISO();
await upsertCase(c);
return c;
}
async function setPublicPreference({ caseId, preference }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const id = String(userId || '');
const pref = !!preference;
if (String(c.accuser || '') === id) {
c.publicPrefAccuser = pref;
} else if (String(c.respondentId || '') === id) {
c.publicPrefRespondent = pref;
} else {
throw new Error('Only parties can set visibility preference.');
}
await upsertCase(c);
return c;
}
async function openPopularVote({ caseId }) {
if (!services.votes || !services.votes.createVote) throw new Error('Votes service not available.');
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const m = String(c.method || '').toUpperCase();
if (m !== 'POPULAR' && m !== 'KARMATOCRACY') throw new Error('This case does not use public voting.');
if (c.voteId) throw new Error('Vote already opened.');
const question = c.title || `Case ${caseId}`;
const deadline = moment().add(POPULAR_DAYS, 'days').toISOString();
const voteMsg = await services.votes.createVote(
question,
deadline,
['YES', 'NO', 'ABSTENTION'],
[`courtsCase:${caseId}`, `courtsMethod:${m}`]
);
c.voteId = voteMsg.key || voteMsg.id;
await upsertCase(c);
return c;
}
async function getInhabitantKarma(feedId) {
if (services.banking && services.banking.getUserEngagementScore) {
try {
const v = await services.banking.getUserEngagementScore(feedId);
return Number(v || 0) || 0;
} catch {
return 0;
}
}
return 0;
}
async function getFirstUserTimestamp(feedId) {
const ssbClient = await openSsb();
return new Promise((resolve) => {
pull(
ssbClient.createUserStream({ id: feedId, reverse: false }),
pull.filter((m) => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(Date.now());
const m = arr[0];
const ts = (m.value && m.value.timestamp) || m.timestamp || Date.now();
resolve(ts < 1e12 ? ts * 1000 : ts);
})
);
});
}
async function nominateJudge({ judgeId }) {
const id = String(judgeId || '').trim();
if (!id) throw new Error('Judge ID is required.');
if (!FEED_ID_RE.test(id)) throw new Error('Invalid judge ID.');
const ssbClient = await openSsb();
const content = {
type: 'courtsNomination',
judgeId: id,
createdAt: nowISO()
};
return await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function voteNomination(nominationId) {
const id = String(nominationId || '').trim();
if (!id) throw new Error('Nomination not found.');
const nominations = await listByType('courtsNomination');
const nomination = nominations.find((n) => n.id === id);
if (!nomination) throw new Error('Nomination not found.');
if (String(nomination.judgeId || '') === String(userId || '')) {
throw new Error('You cannot vote for yourself.');
}
const votes = await listByType('courtsNominationVote');
const already = votes.find(
(v) =>
String(v.nominationId || '') === id &&
String(v.voter || '') === String(userId || '')
);
if (already) throw new Error('You have already voted.');
const ssbClient = await openSsb();
const content = {
type: 'courtsNominationVote',
nominationId: id,
voter: userId,
createdAt: nowISO()
};
return await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function listNominations() {
const nominations = await listByType('courtsNomination');
const votes = await listByType('courtsNominationVote');
const byId = new Map();
for (const n of nominations) {
byId.set(n.id, { ...n, supports: 0, karma: 0, profileSince: 0 });
}
for (const v of votes) {
const rec = byId.get(v.nominationId);
if (rec) rec.supports = (rec.supports || 0) + 1;
}
const rows = [];
for (const rec of byId.values()) {
const karma = await getInhabitantKarma(rec.judgeId);
const since = await getFirstUserTimestamp(rec.judgeId);
rows.push({ ...rec, karma, profileSince: since });
}
rows.sort((a, b) => {
if ((b.supports || 0) !== (a.supports || 0)) return (b.supports || 0) - (a.supports || 0);
if ((b.karma || 0) !== (a.karma || 0)) return (b.karma || 0) - (a.karma || 0);
if ((a.profileSince || 0) !== (b.profileSince || 0)) return (a.profileSince || 0) - (b.profileSince || 0);
const ta = new Date(a.createdAt || 0).getTime();
const tb = new Date(b.createdAt || 0).getTime();
if (ta !== tb) return ta - tb;
return String(a.judgeId || '').localeCompare(String(b.judgeId || ''));
});
return rows;
}
async function getCaseDetails({ caseId }) {
const id = String(caseId || '').trim();
if (!id) return null;
const base = await getCaseById(id);
if (!base) return null;
const currentUser = await getCurrentUserId();
const me = String(currentUser || '');
const accuserId = String(base.accuser || '');
const respondentId = String(base.respondentId || '');
const ma = ensureArray(base.mediatorsAccuser || []);
const mr = ensureArray(base.mediatorsRespondent || []);
const judgeId = String(base.judgeId || '');
const dictatorId = String(base.dictatorId || '');
const isAccuser = accuserId === me;
const isRespondent = respondentId === me;
const isMediator = ma.includes(me) || mr.includes(me);
const isJudge = judgeId === me;
const isDictator = dictatorId === me;
const mine = isAccuser || isRespondent || isMediator || isJudge || isDictator;
let myPublicPreference = null;
if (isAccuser && typeof base.publicPrefAccuser === 'boolean') {
myPublicPreference = base.publicPrefAccuser;
} else if (isRespondent && typeof base.publicPrefRespondent === 'boolean') {
myPublicPreference = base.publicPrefRespondent;
}
const publicDetails = base.publicPrefAccuser === true && base.publicPrefRespondent === true;
const evidencesAll = await listByType('courtsEvidence');
const answersAll = await listByType('courtsAnswer');
const settlementsAll = await listByType('courtsSettlementProposal');
const verdictsAll = await listByType('courtsVerdict');
const acceptedAll = await listByType('courtsSettlementAccepted');
const evidences = evidencesAll
.filter((e) => String(e.caseId || '') === id)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const answers = answersAll
.filter((a) => String(a.caseId || '') === id)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const settlements = settlementsAll
.filter((s) => String(s.caseId || '') === id)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const verdicts = verdictsAll
.filter((v) => String(v.caseId || '') === id)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const verdict = verdicts.length ? verdicts[verdicts.length - 1] : null;
const acceptedSettlements = acceptedAll
.filter((s) => String(s.caseId || '') === id)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const decidedAt =
base.verdictAt ||
base.closedAt ||
(verdict && verdict.createdAt) ||
base.decidedAt;
const hasVerdict = !!verdict;
const supportCount = typeof base.supportCount !== 'undefined' ? base.supportCount : 0;
return {
...base,
id,
respondent: base.respondentId || base.respondent,
evidences,
answers,
settlements,
acceptedSettlements,
verdict,
decidedAt,
isAccuser,
isRespondent,
isMediator,
isJudge,
isDictator,
mine,
publicDetails,
myPublicPreference,
supportCount,
hasVerdict
};
}
return {
getCurrentUserId,
openCase,
listCases,
listCasesForUser,
getCaseById,
setMediators,
assignJudge,
addEvidence,
answerCase,
issueVerdict,
proposeSettlement,
acceptSettlement,
setPublicPreference,
openPopularVote,
nominateJudge,
voteNomination,
listNominations,
getCaseDetails
};
};

View file

@ -0,0 +1,173 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const extractBlobId = str => {
if (!str || typeof str !== 'string') return null;
const match = str.match(/\(([^)]+\.sha256)\)/);
return match ? match[1] : str.trim();
};
const parseCSV = str => str
? str.split(',').map(s => s.trim()).filter(Boolean)
: [];
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
return {
type: 'curriculum',
async createCV(data, photoBlobId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const content = {
type: 'curriculum',
author: userId,
name: data.name,
description: data.description,
photo: extractBlobId(photoBlobId) || null,
contact: userId,
personalSkills: parseCSV(data.personalSkills),
personalExperiences: data.personalExperiences || '',
oasisExperiences: data.oasisExperiences || '',
oasisSkills: parseCSV(data.oasisSkills),
educationExperiences: data.educationExperiences || '',
educationalSkills: parseCSV(data.educationalSkills),
languages: data.languages || '',
professionalExperiences: data.professionalExperiences || '',
professionalSkills: parseCSV(data.professionalSkills),
location: data.location || 'UNKNOWN',
status: data.status || 'LOOKING FOR WORK',
preferences: data.preferences || 'REMOTE WORKING',
createdAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
});
},
async updateCV(id, data, photoBlobId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const old = await new Promise((res, rej) =>
ssbClient.get(id, (err, msg) =>
err || !msg?.content
? rej(err || new Error('CV not found'))
: res(msg)
)
);
if (old.content.author !== userId) {
throw new Error('Not the author');
}
const tombstone = {
type: 'tombstone',
target: id,
deletedAt: new Date().toISOString()
};
await new Promise((res, rej) =>
ssbClient.publish(tombstone, err => err ? rej(err) : res())
);
const content = {
type: 'curriculum',
author: userId,
name: data.name,
description: data.description,
photo: extractBlobId(photoBlobId) || null,
contact: userId,
personalSkills: parseCSV(data.personalSkills),
personalExperiences: data.personalExperiences || '',
oasisExperiences: data.oasisExperiences || '',
oasisSkills: parseCSV(data.oasisSkills),
educationExperiences: data.educationExperiences || '',
educationalSkills: parseCSV(data.educationalSkills),
languages: data.languages || '',
professionalExperiences: data.professionalExperiences || '',
professionalSkills: parseCSV(data.professionalSkills),
location: data.location || 'UNKNOWN',
status: data.status || 'LOOKING FOR WORK',
preferences: data.preferences || 'REMOTE WORKING',
createdAt: old.content.createdAt,
updatedAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
});
},
async deleteCVById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const msg = await new Promise((res, rej) =>
ssbClient.get(id, (err, msg) =>
err || !msg?.content
? rej(new Error('CV not found'))
: res(msg)
)
);
if (msg.content.author !== userId) {
throw new Error('Not the author');
}
const tombstone = {
type: 'tombstone',
target: id,
deletedAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, result) => err ? reject(err) : resolve(result));
});
},
async getCVByUserId(targetUserId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const authorId = targetUserId || userId;
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
const tombstoned = new Set(
msgs
.filter(m => m.value?.content?.type === 'tombstone' && m.value.content.target)
.map(m => m.value.content.target)
);
const cvMsgs = msgs
.filter(m =>
m.value?.content?.type === 'curriculum' &&
m.value.content.author === authorId &&
!tombstoned.has(m.key)
)
.sort((a, b) => b.value.timestamp - a.value.timestamp);
if (!cvMsgs.length) {
return resolve(null);
}
const latest = cvMsgs[0];
resolve({ id: latest.key, ...latest.value.content });
})
);
});
}
};
};

View file

@ -0,0 +1,350 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const mediaFavorites = require("../backend/media-favorites");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const safeText = (v) => String(v || "").trim();
const parseBlobId = (blobMarkdown) => {
if (!blobMarkdown) return null;
const s = String(blobMarkdown);
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s.trim();
};
const parseCSV = (str) =>
str === undefined || str === null ? undefined : String(str).split(",").map((s) => s.trim()).filter(Boolean);
const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
let userId;
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const buildIndex = (messages) => {
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
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 !== "document") continue;
const ts = v.timestamp || m.timestamp || 0;
nodes.set(k, { key: k, ts, c });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
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;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, tipOf(r));
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
};
const pickDoc = (node, rootId) => {
const c = node.c || {};
return {
key: node.key,
rootId,
url: c.url,
createdAt: c.createdAt,
updatedAt: c.updatedAt || null,
tags: safeArr(c.tags),
author: c.author,
title: c.title || "",
description: c.description || "",
opinions: c.opinions || {},
opinions_inhabitants: safeArr(c.opinions_inhabitants)
};
};
const hasBlob = (ssbClient, blobId) =>
new Promise((resolve) => {
if (!blobId) return resolve(false);
ssbClient.blobs.has(blobId, (err, has) => resolve(!err && !!has));
});
const favoritesSetForDocuments = async () => {
try {
return await mediaFavorites.getFavoriteSet("documents");
} catch {
return new Set();
}
};
return {
type: "document",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Document not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Document not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createDocument(blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
if (!blobId) throw new Error("Missing document blob");
const tags = parseCSV(tagsRaw) || [];
const content = {
type: "document",
url: blobId,
createdAt: new Date().toISOString(),
author: userId,
tags,
title: title || "",
description: description || "",
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateDocumentById(id, blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const tipId = await this.resolveCurrentId(id);
const oldMsg = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error("Document not found")) : res(msg)))
);
if (oldMsg.content?.type !== "document") throw new Error("Document not found");
if (Object.keys(oldMsg.content.opinions || {}).length > 0) {
throw new Error("Cannot edit document after it has received opinions.");
}
if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
const parsedTags = parseCSV(tagsRaw);
const tags = parsedTags !== undefined ? parsedTags : safeArr(oldMsg.content.tags);
const blobId = parseBlobId(blobMarkdown);
const updatedAt = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? (title || "") : oldMsg.content.title || "",
description: description !== undefined ? (description || "") : oldMsg.content.description || "",
updatedAt
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: updatedAt, author: userId };
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
});
},
async deleteDocumentById(id) {
const ssbClient = await openSsb();
const tipId = await this.resolveCurrentId(id);
const msg = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, m) => (err || !m ? rej(new Error("Document not found")) : res(m)))
);
if (msg.content?.type !== "document") throw new Error("Document not found");
if (String(msg.content.author) !== String(userId)) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
});
},
async listAll(arg1 = "all") {
const ssbClient = await openSsb();
const opts = typeof arg1 === "object" && arg1 !== null ? arg1 : { filter: arg1 };
const filter = safeText(opts.filter || "all");
const q = safeText(opts.q || "").toLowerCase();
const sort = safeText(opts.sort || "recent");
const favorites = await favoritesSetForDocuments();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(pickDoc(node, rootId));
}
let out = items;
const now = Date.now();
if (filter === "mine") out = out.filter((d) => String(d.author) === String(userId));
else if (filter === "recent") out = out.filter((d) => new Date(d.createdAt).getTime() >= now - 86400000);
else if (filter === "favorites") out = out.filter((d) => favorites.has(d.rootId || d.key));
if (q) {
out = out.filter((d) => {
const t = String(d.title || "").toLowerCase();
const desc = String(d.description || "").toLowerCase();
const u = String(d.url || "").toLowerCase();
const a = String(d.author || "").toLowerCase();
const tags = safeArr(d.tags).join(" ").toLowerCase();
return t.includes(q) || desc.includes(q) || u.includes(q) || a.includes(q) || tags.includes(q);
});
}
const effectiveSort = filter === "top" ? "top" : sort;
if (effectiveSort === "top") {
out = out
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (effectiveSort === "oldest") {
out = out.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
out = out.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
const checked = await Promise.all(out.map(async (d) => ((await hasBlob(ssbClient, d.url)) ? d : null)));
return checked
.filter(Boolean)
.map((d) => ({ ...d, isFavorite: favorites.has(d.rootId || d.key) }));
},
async getDocumentById(id) {
const ssbClient = await openSsb();
const tipId = await this.resolveCurrentId(id);
const rootId = await this.resolveRootId(id);
const favorites = await favoritesSetForDocuments();
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, msg) => {
if (err || !msg || msg.content?.type !== "document") return reject(new Error("Document not found"));
const c = msg.content;
resolve({
key: tipId,
rootId,
url: c.url,
createdAt: c.createdAt,
updatedAt: c.updatedAt || null,
tags: c.tags || [],
author: c.author,
title: c.title || "",
description: c.description || "",
opinions: c.opinions || {},
opinions_inhabitants: c.opinions_inhabitants || [],
isFavorite: favorites.has(rootId || tipId)
});
});
});
},
async createOpinion(id, category) {
if (!categories.includes(category)) return Promise.reject(new Error("Invalid voting category"));
const ssbClient = await openSsb();
const tipId = await this.resolveCurrentId(id);
return new Promise((resolve, reject) => {
ssbClient.get(tipId, async (err, msg) => {
if (err || !msg || msg.content?.type !== "document") return reject(new Error("Document not found"));
if (safeArr(msg.content.opinions_inhabitants).includes(userId)) return reject(new Error("Already voted"));
const now = new Date().toISOString();
const updated = {
...msg.content,
replaces: tipId,
opinions: { ...msg.content.opinions, [category]: (msg.content.opinions?.[category] || 0) + 1 },
opinions_inhabitants: safeArr(msg.content.opinions_inhabitants).concat(userId),
updatedAt: now
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
});
});
}
};
};

View file

@ -0,0 +1,240 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { config } = require('../server/SSB_server.js');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const userId = config.keys.id;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
const normalizePrivacy = (v) => {
const s = String(v || 'public').toLowerCase();
return s === 'private' ? 'private' : 'public';
};
const normalizePrice = (price) => {
let p = typeof price === 'string' ? parseFloat(price.replace(',', '.')) : price;
if (isNaN(p) || p < 0) p = 0;
return Number(p).toFixed(6);
};
const normalizeDate = (date) => {
const m = moment(date);
if (!m.isValid()) throw new Error("Invalid date format");
return m.toISOString();
};
const deriveStatus = (c) => {
const dateM = moment(c.date);
let status = String(c.status || 'OPEN').toUpperCase();
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
return status;
};
return {
type: 'event',
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic) {
const ssbClient = await openSsb();
const formattedDate = normalizeDate(date);
if (moment(formattedDate).isBefore(moment().startOf('minute'))) throw new Error("Cannot create an event in the past");
let attendeeList = attendees;
if (!Array.isArray(attendeeList)) attendeeList = String(attendeeList || '').split(',').map(s => s.trim()).filter(Boolean);
attendeeList = uniq([...attendeeList, userId]);
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(s => s.trim()).filter(Boolean);
const content = {
type: 'event',
title,
description,
date: formattedDate,
location,
price: normalizePrice(price),
url: url || '',
attendees: attendeeList,
tags,
createdAt: new Date().toISOString(),
organizer: userId,
status: 'OPEN',
isPublic: normalizePrivacy(isPublic)
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
});
},
async toggleAttendee(eventId) {
const ssbClient = await openSsb();
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
const c = ev.content;
const status = deriveStatus(c);
if (status === 'CLOSED') throw new Error("Cannot attend a closed event");
let attendees = uniq(c.attendees || []);
const idx = attendees.indexOf(userId);
if (idx !== -1) attendees.splice(idx, 1); else attendees.push(userId);
attendees = uniq(attendees);
const updated = {
...c,
attendees,
updatedAt: new Date().toISOString(),
replaces: eventId
};
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
});
},
async deleteEventById(eventId) {
const ssbClient = await openSsb();
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
if (ev.content.organizer !== userId) throw new Error("Only the organizer can delete this event");
const tombstone = { type: 'tombstone', target: eventId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, res) => err ? reject(err) : resolve(res));
});
},
async getEventById(eventId) {
const ssbClient = await openSsb();
const msg = await new Promise((res, rej) => ssbClient.get(eventId, (err, msg) => err || !msg || !msg.content ? rej(new Error("Error retrieving event")) : res(msg)));
const c = msg.content;
const status = deriveStatus(c);
return {
id: eventId,
title: c.title || '',
description: c.description || '',
date: c.date || '',
location: c.location || '',
price: c.price || 0,
url: c.url || '',
attendees: Array.isArray(c.attendees) ? c.attendees : [],
tags: Array.isArray(c.tags) ? c.tags : [],
createdAt: c.createdAt || new Date().toISOString(),
updatedAt: c.updatedAt || new Date().toISOString(),
organizer: c.organizer || '',
status,
isPublic: normalizePrivacy(c.isPublic)
};
},
async updateEventById(eventId, updatedData) {
const ssbClient = await openSsb();
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
if (ev.content.organizer !== userId) throw new Error("Only the organizer can update this event");
const c = ev.content;
const status = deriveStatus(c);
if (status === 'CLOSED') throw new Error("Cannot edit a closed event");
const tags = updatedData.tags !== undefined
? (Array.isArray(updatedData.tags)
? updatedData.tags.filter(Boolean)
: String(updatedData.tags || '').split(',').map(t => t.trim()).filter(Boolean))
: (Array.isArray(c.tags) ? c.tags : []);
const date = updatedData.date !== undefined && updatedData.date !== ''
? normalizeDate(updatedData.date)
: c.date;
if (moment(date).isBefore(moment().startOf('minute'))) throw new Error("Cannot set an event in the past");
const updated = {
...c,
title: updatedData.title ?? c.title,
description: updatedData.description ?? c.description,
date,
location: updatedData.location ?? c.location,
price: updatedData.price !== undefined ? normalizePrice(updatedData.price) : c.price,
url: updatedData.url ?? c.url,
tags,
isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
updatedAt: new Date().toISOString(),
replaces: eventId
};
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
});
},
async listAll(author = null, filter = 'all') {
const ssbClient = await openSsb();
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, results) => {
if (err) return reject(new Error("Error listing events: " + err.message));
const tombstoned = new Set();
const replaces = new Map();
const byId = new Map();
for (const r of results) {
const k = r.key;
const c = r.value && r.value.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
continue;
}
if (c.type === 'event') {
if (c.replaces) replaces.set(c.replaces, k);
if (author && c.organizer !== author) continue;
const status = deriveStatus(c);
byId.set(k, {
id: k,
title: c.title || '',
description: c.description || '',
date: c.date || '',
location: c.location || '',
price: c.price || 0,
url: c.url || '',
attendees: Array.isArray(c.attendees) ? uniq(c.attendees) : [],
tags: Array.isArray(c.tags) ? c.tags.filter(Boolean) : [],
createdAt: c.createdAt || new Date().toISOString(),
organizer: c.organizer || '',
status,
isPublic: normalizePrivacy(c.isPublic)
});
}
}
replaces.forEach((_, oldId) => byId.delete(oldId));
tombstoned.forEach(id => byId.delete(id));
let out = Array.from(byId.values());
if (filter === 'mine') out = out.filter(e => e.organizer === userId);
if (filter === 'open') out = out.filter(e => String(e.status).toUpperCase() === 'OPEN');
if (filter === 'closed') out = out.filter(e => String(e.status).toUpperCase() === 'CLOSED');
resolve(out);
})
);
});
}
};
};

View file

@ -0,0 +1,52 @@
const os = require('os');
const fs = require('fs');
const path = require('path');
const archiver = require('../server/node_modules/archiver');
module.exports = {
exportSSB: async (outputPath) => {
try {
const homeDir = os.homedir();
const ssbPath = path.join(homeDir, '.ssb');
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', {
zlib: { level: 9 }
});
archive.pipe(output);
const addDirectoryToArchive = (dirPath, archive) => {
const files = fs.readdirSync(dirPath);
let hasFiles = false;
files.forEach((file) => {
const filePath = path.join(dirPath, file);
const stat = fs.statSync(filePath);
if (file === 'secret') {
return;
}
if (stat.isDirectory()) {
addDirectoryToArchive(filePath, archive);
archive.directory(filePath, path.relative(ssbPath, filePath));
hasFiles = true;
} else {
archive.file(filePath, { name: path.relative(ssbPath, filePath) });
hasFiles = true;
}
});
if (!hasFiles) {
archive.directory(dirPath, path.relative(ssbPath, dirPath));
}
};
addDirectoryToArchive(ssbPath, archive);
await archive.finalize();
return outputPath;
} catch (error) {
throw new Error("Error exporting data: " + error.message);
}
}
};

View file

@ -0,0 +1,143 @@
const mediaFavorites = require("../backend/media-favorites");
const safeArr = (v) => (Array.isArray(v) ? v : []);
const safeText = (v) => String(v || "").trim();
const getFn = (obj, names) => {
for (const n of names) {
if (obj && typeof obj[n] === "function") return obj[n].bind(obj);
}
return null;
};
const toTs = (d) => {
const t = Date.parse(String(d || ""));
return Number.isFinite(t) ? t : 0;
};
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel }) => {
const kindConfig = {
audios: {
base: "/audios/",
getById: getFn(audiosModel, ["getAudioById", "getById"])
},
bookmarks: {
base: "/bookmarks/",
getById: getFn(bookmarksModel, ["getBookmarkById", "getById"])
},
documents: {
base: "/documents/",
getById: getFn(documentsModel, ["getDocumentById", "getById"])
},
images: {
base: "/images/",
getById: getFn(imagesModel, ["getImageById", "getById"])
},
videos: {
base: "/videos/",
getById: getFn(videosModel, ["getVideoById", "getById"])
}
};
const kindOrder = ["audios", "bookmarks", "documents", "images", "videos"];
const hydrateKind = async (kind, ids) => {
const cfg = kindConfig[kind];
if (!cfg?.getById) return [];
const out = await Promise.all(
safeArr(ids).map(async (favId) => {
const id = safeText(favId);
if (!id) return null;
try {
const obj = await cfg.getById(id);
const viewId = safeText(obj?.key || obj?.id || id);
return {
kind,
favId: id,
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
title: safeText(obj?.title) || safeText(obj?.name) || safeText(obj?.category) || safeText(obj?.url) || "",
description: safeText(obj?.description) || "",
tags: safeArr(obj?.tags),
author: safeText(obj?.author || obj?.organizer || obj?.seller || obj?.from || ""),
createdAt: obj?.createdAt || null,
updatedAt: obj?.updatedAt || null,
url: obj?.url || null,
category: obj?.category || null
};
} catch {
return null;
}
})
);
return out.filter(Boolean);
};
const loadAll = async () => {
const sets = await Promise.all(kindOrder.map((k) => mediaFavorites.getFavoriteSet(k)));
const idsByKind = {};
kindOrder.forEach((k, i) => {
idsByKind[k] = Array.from(sets[i] || []);
});
const hydrated = await Promise.all(kindOrder.map((k) => hydrateKind(k, idsByKind[k])));
const byKind = {};
kindOrder.forEach((k, i) => {
byKind[k] = hydrated[i] || [];
});
const flat = kindOrder.flatMap((k) => byKind[k]);
const counts = {
audios: byKind.audios.length,
bookmarks: byKind.bookmarks.length,
documents: byKind.documents.length,
images: byKind.images.length,
videos: byKind.videos.length,
all: flat.length
};
const recentFlat = flat
.slice()
.sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)));
return { byKind, flat, recentFlat, counts };
};
return {
async listAll(opts = {}) {
const filter = safeText(opts.filter || "all").toLowerCase();
const { byKind, recentFlat, counts } = await loadAll();
if (filter === "recent") {
return { items: recentFlat, counts };
}
if (kindOrder.includes(filter)) {
const items = byKind[filter] || [];
const sorted = items
.slice()
.sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)));
return { items: sorted, counts };
}
const grouped = kindOrder.flatMap((k) =>
(byKind[k] || [])
.slice()
.sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)))
);
return { items: grouped, counts };
},
async removeFavorite(kind, id) {
const k = safeText(kind);
const favId = safeText(id);
if (!k || !favId) return;
await mediaFavorites.removeFavorite(k, favId);
}
};
};

View file

@ -0,0 +1,330 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
const FEED_TEXT_MAX = Number(getConfig().feed?.maxLength ?? 280);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const cleanText = (t) => (typeof t === "string" ? t.trim() : "");
const isValidFeedText = (t) => {
const s = cleanText(t);
return s.length >= FEED_TEXT_MIN && s.length <= FEED_TEXT_MAX;
};
const getMsg = (ssbClient, id) =>
new Promise((resolve, reject) => {
ssbClient.get(id, (err, val) => (err ? reject(err) : resolve({ key: id, value: val })));
});
const getAllMessages = (ssbClient) =>
new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
});
const extractTags = (text) => {
const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase());
return Array.from(new Set(list));
};
const buildIndex = async (ssbClient) => {
const messages = await getAllMessages(ssbClient);
const forward = new Map();
const replacedIds = new Set();
const tombstoned = new Set();
const feedsById = new Map();
const actions = [];
for (const msg of messages) {
const c = msg?.value?.content;
const k = msg?.key;
if (!c || !k) continue;
if (c.type === "tombstone" && c.target) {
tombstoned.add(c.target);
continue;
}
if (c.type === "feed") {
feedsById.set(k, msg);
if (c.replaces) {
forward.set(c.replaces, k);
replacedIds.add(c.replaces);
}
continue;
}
if (c.type === "feed-action") {
actions.push(msg);
continue;
}
}
const resolve = (id) => {
let cur = id;
const seen = new Set();
while (forward.has(cur) && !seen.has(cur)) {
seen.add(cur);
cur = forward.get(cur);
}
return cur;
};
const actionsByRoot = new Map();
for (const a of actions) {
const c = a?.value?.content || {};
const target = c.root || c.target;
if (!target) continue;
const root = resolve(target);
if (!actionsByRoot.has(root)) actionsByRoot.set(root, []);
actionsByRoot.get(root).push(a);
}
return { resolve, tombstoned, feedsById, replacedIds, actionsByRoot };
};
const resolveCurrentId = async (id) => {
const ssbClient = await openSsb();
const idx = await buildIndex(ssbClient);
return idx.resolve(id);
};
const createFeed = async (text, mentions) => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
if (typeof text !== "string") throw new Error("Invalid text");
const cleaned = cleanText(text);
if (!isValidFeedText(cleaned)) {
if (cleaned.length < FEED_TEXT_MIN) throw new Error("Text too short");
if (cleaned.length > FEED_TEXT_MAX) throw new Error("Text too long");
throw new Error("Text required");
}
const content = {
type: "feed",
text: cleaned,
author: userId,
createdAt: new Date().toISOString(),
tags: extractTags(cleaned),
mentions: Array.isArray(mentions) && mentions.length > 0 ? mentions : undefined
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)));
});
};
const createRefeed = async (contentId) => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const idx = await buildIndex(ssbClient);
const tipId = idx.resolve(contentId);
let msg;
try {
msg = idx.feedsById.get(tipId) || (await getMsg(ssbClient, tipId));
} catch {
throw new Error("Invalid feed");
}
const c = msg?.value?.content;
if (!c || c.type !== "feed") throw new Error("Invalid feed");
if (!isValidFeedText(c.text)) throw new Error("Invalid feed");
const existing = idx.actionsByRoot.get(tipId) || [];
for (const a of existing) {
const ac = a?.value?.content || {};
if (ac.type === "feed-action" && ac.action === "refeed" && a.value?.author === userId) throw new Error("Already refeeded");
}
const action = {
type: "feed-action",
action: "refeed",
root: tipId,
createdAt: new Date().toISOString(),
author: userId
};
return new Promise((resolve, reject) => {
ssbClient.publish(action, (err, out) => (err ? reject(err) : resolve(out)));
});
};
const addOpinion = async (contentId, category) => {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const idx = await buildIndex(ssbClient);
const tipId = idx.resolve(contentId);
let msg;
try {
msg = idx.feedsById.get(tipId) || (await getMsg(ssbClient, tipId));
} catch {
throw new Error("Invalid feed");
}
const c = msg?.value?.content;
if (!c || c.type !== "feed") throw new Error("Invalid feed");
if (!isValidFeedText(c.text)) throw new Error("Invalid feed");
const existing = idx.actionsByRoot.get(tipId) || [];
for (const a of existing) {
const ac = a?.value?.content || {};
if (ac.type === "feed-action" && ac.action === "vote" && a.value?.author === userId) throw new Error("Already voted");
}
const action = {
type: "feed-action",
action: "vote",
category,
root: tipId,
createdAt: new Date().toISOString(),
author: userId
};
return new Promise((resolve, reject) => {
ssbClient.publish(action, (err, result) => (err ? reject(err) : resolve(result)));
});
};
const listFeeds = async (filterOrOpts = "ALL") => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const now = Date.now();
const opts = typeof filterOrOpts === "string" ? { filter: filterOrOpts } : (filterOrOpts || {});
const filter = String(opts.filter || "ALL").toUpperCase();
const q = typeof opts.q === "string" ? opts.q.trim().toLowerCase() : "";
const tag = typeof opts.tag === "string" ? opts.tag.trim().toLowerCase() : "";
const idx = await buildIndex(ssbClient);
const isValidFeedMsg = (m) => {
const c = m?.value?.content;
return !!c && c.type === "feed" && isValidFeedText(c.text);
};
let tips = Array.from(idx.feedsById.values()).filter(
(m) =>
!idx.replacedIds.has(m.key) &&
!idx.tombstoned.has(m.key) &&
isValidFeedMsg(m)
);
const textEditedEver = (m) => {
const seen = new Set();
let cur = m;
let lastText = cur?.value?.content?.text;
while (cur?.value?.content?.replaces) {
const prevId = cur.value.content.replaces;
if (!prevId || seen.has(prevId)) break;
seen.add(prevId);
const prev = idx.feedsById.get(prevId);
if (!prev) break;
const prevText = prev?.value?.content?.text;
if (typeof lastText === "string" && typeof prevText === "string" && lastText !== prevText) return true;
cur = prev;
lastText = prevText;
}
return false;
};
const materialize = (feedMsg) => {
const base = feedMsg || {};
const content = { ...(base.value?.content || {}) };
const root = base.key;
let refeeds = Number(content.refeeds || 0) || 0;
const refeedsInhabitants = new Set(Array.isArray(content.refeeds_inhabitants) ? content.refeeds_inhabitants : []);
const opinionsCounts = {};
const oldOpinions = content.opinions && typeof content.opinions === "object" ? content.opinions : {};
for (const [k, v] of Object.entries(oldOpinions)) opinionsCounts[k] = (Number(v) || 0);
const opinionsInhabitants = new Set(Array.isArray(content.opinions_inhabitants) ? content.opinions_inhabitants : []);
const actions = idx.actionsByRoot.get(root) || [];
for (const a of actions) {
const ac = a?.value?.content || {};
const author = a?.value?.author || ac.author;
if (!author) continue;
if (ac.action === "refeed") {
if (!refeedsInhabitants.has(author)) {
refeedsInhabitants.add(author);
refeeds += 1;
}
continue;
}
if (ac.action === "vote") {
if (!opinionsInhabitants.has(author)) {
opinionsInhabitants.add(author);
const cat = String(ac.category || "");
opinionsCounts[cat] = (Number(opinionsCounts[cat]) || 0) + 1;
}
continue;
}
}
content.refeeds = refeeds;
content.refeeds_inhabitants = Array.from(refeedsInhabitants);
content.opinions = opinionsCounts;
content.opinions_inhabitants = Array.from(opinionsInhabitants);
if (!Array.isArray(content.tags)) content.tags = extractTags(content.text);
content._textEdited = textEditedEver(base);
return { ...base, value: { ...base.value, content } };
};
let feeds = tips.map(materialize);
if (q) {
const terms = q.split(/\s+/).map((s) => s.trim()).filter(Boolean);
feeds = feeds.filter((m) => {
const t = String(m.value?.content?.text || "").toLowerCase();
return terms.every((term) => t.includes(term));
});
}
if (tag) feeds = feeds.filter((m) => Array.isArray(m.value?.content?.tags) && m.value.content.tags.includes(tag));
const getTs = (m) => m?.value?.timestamp || Date.parse(m?.value?.content?.createdAt || "") || 0;
const totalVotes = (m) => Object.values(m?.value?.content?.opinions || {}).reduce((s, x) => s + (Number(x) || 0), 0);
if (filter === "MINE") {
feeds = feeds.filter((m) => (m.value?.content?.author || m.value?.author) === userId);
} else if (filter === "TODAY") {
feeds = feeds.filter((m) => now - getTs(m) < 86400000);
}
if (filter === "TOP") {
feeds.sort(
(a, b) =>
totalVotes(b) - totalVotes(a) ||
(b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) ||
getTs(b) - getTs(a)
);
} else {
feeds.sort((a, b) => getTs(b) - getTs(a));
}
return feeds;
};
return { createFeed, createRefeed, addOpinion, listFeeds, resolveCurrentId };
};

View file

@ -0,0 +1,288 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb, userId;
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
async function collectTombstones(ssbClient) {
return new Promise((resolve, reject) => {
const tomb = new Set();
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.filter(m => m.value.content?.type === 'tombstone' && m.value.content.target),
pull.drain(m => tomb.add(m.value.content.target), err => err ? reject(err) : resolve(tomb))
);
});
}
async function findActiveVote(ssbClient, targetId, voter) {
const tombstoned = await collectTombstones(ssbClient);
return new Promise((resolve, reject) => {
pull(
ssbClient.links({ source: voter, dest: targetId, rel: 'vote', values: true, keys: true }),
pull.filter(link => !tombstoned.has(link.key)),
pull.collect((err, links) => err ? reject(err) : resolve(links))
);
});
}
async function aggregateVotes(ssbClient, targetId) {
const tombstoned = await collectTombstones(ssbClient);
return new Promise((resolve, reject) => {
let positives = 0, negatives = 0;
pull(
ssbClient.links({ source: null, dest: targetId, rel: 'vote', values: true, keys: true }),
pull.filter(link => link.value.content?.vote && !tombstoned.has(link.key)),
pull.drain(
link => link.value.content.vote.value > 0 ? positives++ : negatives++,
err => err ? reject(err) : resolve({ positives, negatives })
)
);
});
}
function nestReplies(flat) {
const lookup = new Map();
const roots = [];
for (const msg of flat) {
msg.children = [];
lookup.set(msg.key, msg);
}
for (const msg of flat) {
if (msg.parent && lookup.has(msg.parent)) {
lookup.get(msg.parent).children.push(msg);
} else {
roots.push(msg);
}
}
return roots;
}
async function getMessageById(id) {
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, data) => err ? rej(err) : res(data)))
);
const msg = msgs.find(m => m.key === id && m.value.content?.type === 'forum');
if (!msg) throw new Error('Message not found');
return { key: msg.key, ...msg.value.content, timestamp: msg.value.timestamp };
}
return {
createForum: async (category, title, text) => {
const ssbClient = await openSsb();
const content = {
type: 'forum',
category,
title,
text,
createdAt: new Date().toISOString(),
author: userId,
votes: { positives: 0, negatives: 0 },
votes_inhabitants: []
};
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, res) => err ? reject(err) : resolve({ key: res.key, ...content }))
);
},
addMessageToForum: async (forumId, message, parentId = null) => {
const ssbClient = await openSsb();
const content = {
...message,
root: forumId,
type: 'forum',
author: userId,
timestamp: new Date().toISOString(),
votes: { positives: 0, negatives: 0 },
votes_inhabitants: []
};
if (parentId) content.branch = parentId;
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
);
},
voteContent: async (targetId, value) => {
const ssbClient = await openSsb();
const whoami = await new Promise((res, rej) =>
ssbClient.whoami((err, info) => err ? rej(err) : res(info))
);
const voter = whoami.id;
const newVal = parseInt(value, 10);
const existing = await findActiveVote(ssbClient, targetId, voter);
if (existing.length > 0) {
const prev = existing[0].value.content.vote.value;
if (prev === newVal) return existing[0];
await new Promise((resolve, reject) =>
ssbClient.publish(
{ type: 'tombstone', target: existing[0].key, timestamp: new Date().toISOString(), author: voter },
err => err ? reject(err) : resolve()
)
);
}
return new Promise((resolve, reject) =>
ssbClient.publish(
{
type: 'vote',
vote: { link: targetId, value: newVal },
timestamp: new Date().toISOString(),
author: voter
},
(err, result) => err ? reject(err) : resolve(result)
)
);
},
deleteForumById: async id => {
const ssbClient = await openSsb();
return new Promise((resolve, reject) =>
ssbClient.publish(
{ type: 'tombstone', target: id, timestamp: new Date().toISOString(), author: userId },
(err, res) => err ? reject(err) : resolve(res)
)
);
},
listAll: async filter => {
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, data) => err ? rej(err) : res(data)))
);
const deleted = new Set(
msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
);
const forums = msgs
.filter(m => m.value.content?.type === 'forum' && !m.value.content.root && !deleted.has(m.key))
.map(m => ({ ...m.value.content, key: m.key }));
const forumsWithVotes = await Promise.all(
forums.map(async f => {
const { positives, negatives } = await aggregateVotes(ssbClient, f.key);
return { ...f, positiveVotes: positives, negativeVotes: negatives };
})
);
const repliesByRoot = {};
msgs.forEach(m => {
const c = m.value.content;
if (c?.type === 'forum' && c.root && !deleted.has(m.key)) {
repliesByRoot[c.root] = repliesByRoot[c.root] || [];
repliesByRoot[c.root].push({ key: m.key, text: c.text, author: c.author, timestamp: m.value.timestamp });
}
});
const final = await Promise.all(
forumsWithVotes.map(async f => {
const replies = repliesByRoot[f.key] || [];
for (let r of replies) {
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
r.positiveVotes = rp;
r.negativeVotes = rn;
r.score = rp - rn;
}
const replyPos = replies.reduce((sum, r) => sum + (r.positiveVotes || 0), 0);
const replyNeg = replies.reduce((sum, r) => sum + (r.negativeVotes || 0), 0);
const positiveVotes = f.positiveVotes + replyPos;
const negativeVotes = f.negativeVotes + replyNeg;
const score = positiveVotes - negativeVotes;
const participants = new Set(replies.map(r => r.author).concat(f.author));
const messagesCount = replies.length + 1;
const lastMessage =
replies.length
? replies.reduce((a, b) => (new Date(a.timestamp) > new Date(b.timestamp) ? a : b))
: null;
return {
...f,
positiveVotes,
negativeVotes,
score,
participants: Array.from(participants),
messagesCount,
lastMessage,
messages: replies
};
})
);
const filtered =
filter === 'mine'
? final.filter(f => f.author === userId)
: filter === 'recent'
? final.filter(f => new Date(f.createdAt).getTime() >= Date.now() - 86400000)
: final;
return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
},
getForumById: async id => {
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, data) => err ? rej(err) : res(data)))
);
const deleted = new Set(
msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
);
const original = msgs.find(m => m.key === id && !deleted.has(m.key));
if (!original || original.value.content?.type !== 'forum') throw new Error('Forum not found');
const base = original.value.content;
const { positives, negatives } = await aggregateVotes(ssbClient, id);
return {
...base,
key: id,
positiveVotes: positives,
negativeVotes: negatives,
score: positives - negatives
};
},
getMessagesByForumId: async forumId => {
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, data) => err ? rej(err) : res(data)))
);
const deleted = new Set(
msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
);
const replies = msgs
.filter(m => m.value.content?.type === 'forum' && m.value.content.root === forumId && !deleted.has(m.key))
.map(m => ({
key: m.key,
text: m.value.content.text,
author: m.value.content.author,
timestamp: m.value.timestamp,
parent: m.value.content.branch || null
}));
for (let r of replies) {
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
r.positiveVotes = rp;
r.negativeVotes = rn;
r.score = rp - rn;
}
const { positives: p, negatives: n } = await aggregateVotes(ssbClient, forumId);
const replyPos = replies.reduce((sum, r) => sum + (r.positiveVotes || 0), 0);
const replyNeg = replies.reduce((sum, r) => sum + (r.negativeVotes || 0), 0);
const positiveVotes = p + replyPos;
const negativeVotes = n + replyNeg;
const totalScore = positiveVotes - negativeVotes;
return {
messages: nestReplies(replies),
total: replies.length,
positiveVotes,
negativeVotes,
totalScore
};
},
getMessageById
};
};

View file

@ -0,0 +1,332 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s || null;
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
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 !== "image") continue;
const ts = v.timestamp || m.timestamp || 0;
nodes.set(k, { key: k, ts, c });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
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;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, tipOf(r));
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
};
const buildImage = (node, rootId, viewerId) => {
const c = node.c || {};
const voters = safeArr(c.opinions_inhabitants);
return {
key: node.key,
rootId,
url: 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 || "",
meme: !!c.meme,
opinions: c.opinions || {},
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false
};
};
return {
type: "image",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Image not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Image not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createImage(blobMarkdown, tagsRaw, title, description, memeBool) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const content = {
type: "image",
url: blobId,
createdAt: now,
updatedAt: now,
author: ssbClient.id,
tags,
title: title || "",
description: description || "",
meme: !!memeBool,
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateImageById(id, blobMarkdown, tagsRaw, title, description, memeBool) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "image") throw new Error("Image not found");
if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit image after it has received opinions.");
if (oldMsg.content.author !== userId) throw new Error("Not the author");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "",
meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme,
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
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)));
});
},
async deleteImageById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "image") throw new Error("Image not found");
if (msg.content.author !== userId) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const sort = String(opts.sort || "recent").trim();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildImage(node, rootId, viewerId));
}
let list = items;
const now = Date.now();
if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000);
else if (filter === "meme") list = list.filter((im) => im.meme === true);
else if (filter === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
}
if (q) {
list = list.filter((im) => {
const t = String(im.title || "").toLowerCase();
const d = String(im.description || "").toLowerCase();
const tags = safeArr(im.tags).join(" ").toLowerCase();
const a = String(im.author || "").toLowerCase();
return t.includes(q) || d.includes(q) || tags.includes(q) || a.includes(q);
});
}
if (sort === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getImageById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Image not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
const node = idx.nodes.get(tip);
if (node) return buildImage(node, root, viewer);
const msg = await getMsg(ssbClient, tip);
if (!msg || msg.content?.type !== "image") throw new Error("Image not found");
return buildImage({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "image") throw new Error("Image not found");
const voters = safeArr(msg.content.opinions_inhabitants);
if (voters.includes(userId)) throw new Error("Already voted");
const now = new Date().toISOString();
const updated = {
...msg.content,
replaces: tipId,
opinions: {
...msg.content.opinions,
[category]: (msg.content.opinions?.[category] || 0) + 1
},
opinions_inhabitants: voters.concat(userId),
updatedAt: now
};
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, (err2, result) => (err2 ? reject(err2) : resolve(result)));
});
}
};
};

View file

@ -0,0 +1,360 @@
const pull = require('../server/node_modules/pull-stream');
const ssbClientGUI = require("../client/gui");
const coolerInstance = ssbClientGUI({ offline: require('../server/ssb_config').offline });
const models = require("../models/main_models");
const { about, friend } = models({
cooler: coolerInstance,
isPublic: require('../server/ssb_config').public,
});
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
function toImageUrl(imgId, size=256){
if (!imgId) return '/assets/images/default-avatar.png';
if (typeof imgId === 'string' && imgId.startsWith('/image/')) return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
return `/image/${size}/${encodeURIComponent(imgId)}`;
}
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
async function getLastKarmaScore(feedId) {
const ssbClient = await openSsb();
return new Promise(resolve => {
const src = ssbClient.messagesByType
? ssbClient.messagesByType({ type: "karmaScore", reverse: true })
: ssbClient.createLogStream && ssbClient.createLogStream({ reverse: true });
if (!src) return resolve(0);
pull(
src,
pull.filter(msg => {
const v = msg.value || msg;
const c = v.content || {};
return v.author === feedId && c.type === "karmaScore" && typeof c.karmaScore !== "undefined";
}),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(0);
const v = arr[0].value || arr[0];
resolve(v.content.karmaScore || 0);
})
);
});
}
async function getLastActivityTimestamp(feedId) {
const ssbClient = await openSsb();
const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
return new Promise((resolve) => {
pull(
ssbClient.createUserStream({ id: feedId, reverse: true }),
pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(null);
const m = arr[0];
const ts = norm((m.value && m.value.timestamp) || m.timestamp);
resolve(ts || null);
})
);
});
}
function bucketLastActivity(ts) {
if (!ts) return { bucket: 'red', range: '≥6m' };
const now = Date.now();
const delta = Math.max(0, now - ts);
const days = delta / 86400000;
if (days < 14) return { bucket: 'green', range: '<2w' };
if (days < 182.5) return { bucket: 'orange', range: '2w6m' };
return { bucket: 'red', range: '≥6m' };
}
const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout));
const fetchUserImageUrl = async (feedId, size=256) => {
try{
const img = await Promise.race([about.image(feedId), timeoutPromise(5000)]);
const id = typeof img === 'string' ? img : (img && (img.link || img.url));
return toImageUrl(id, size);
}catch{
return '/assets/images/default-avatar.png';
}
};
async function listAllBase(ssbClient) {
const authorsMsgs = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.filter(msg => !!msg.value?.author && msg.value?.content?.type !== 'tombstone'),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const uniqueFeedIds = Array.from(new Set(authorsMsgs.map(r => r.value.author).filter(Boolean)));
const users = await Promise.all(
uniqueFeedIds.map(async (feedId) => {
const rawName = await about.name(feedId);
const name = rawName || feedId.slice(0, 10);
const description = await about.description(feedId);
const photo = await fetchUserImageUrl(feedId, 256);
const lastActivityTs = await getLastActivityTimestamp(feedId);
const { bucket, range } = bucketLastActivity(lastActivityTs);
return { id: feedId, name, description, photo, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
})
);
return Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
}
function normalizeRel(rel) {
const r = rel || {};
const iFollow = !!(r.following || r.iFollow || r.youFollow || r.i_follow || r.isFollowing);
const followsMe = !!(r.followsMe || r.followingMe || r.follows_me || r.theyFollow || r.isFollowedBy);
const blocking = !!(r.blocking || r.iBlock || r.isBlocking);
const blockedBy = !!(r.blocked || r.blocksMe || r.isBlockedBy);
return { iFollow, followsMe, blocking, blockedBy };
}
return {
async listInhabitants(options = {}) {
const { filter = 'all', search = '', location = '', language = '', skills = '', includeInactive = false } = options;
const ssbClient = await openSsb();
const userId = ssbClient.id;
const filterInactive = (users) => {
if (includeInactive) return users;
return users.filter(u => u.lastActivityBucket !== 'red');
};
if (filter === 'GALLERY') {
const users = await listAllBase(ssbClient);
return filterInactive(users);
}
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY') {
let users = await listAllBase(ssbClient);
if (filter !== 'TOP ACTIVITY') {
users = filterInactive(users);
}
if (search) {
const q = search.toLowerCase();
users = users.filter(u =>
(u.name || '').toLowerCase().includes(q) ||
(u.description || '').toLowerCase().includes(q) ||
(u.id || '').toLowerCase().includes(q)
);
}
const withMetrics = await Promise.all(users.map(async u => {
const karmaScore = await getLastKarmaScore(u.id);
return { ...u, karmaScore };
}));
if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
return withMetrics;
}
if (filter === 'contacts') {
const all = await this.listInhabitants({ filter: 'all' });
const result = [];
for (const user of all) {
const rel = await friend.getRelationship(user.id).catch(() => ({}));
if (rel && (rel.following || rel.iFollow)) result.push(user);
}
return Array.from(new Map(result.map(u => [u.id, u])).values());
}
if (filter === 'blocked') {
const all = await this.listInhabitants({ filter: 'all', includeInactive: true });
const result = [];
for (const user of all) {
const rel = await friend.getRelationship(user.id).catch(() => ({}));
const n = normalizeRel(rel);
if (n.blocking) result.push({ ...user, isBlocked: true });
}
return Array.from(new Map(result.map(u => [u.id, u])).values());
}
if (filter === 'SUGGESTED') {
const base = await listAllBase(ssbClient);
const active = filterInactive(base);
const rels = await Promise.all(
active.map(async u => {
if (u.id === userId) return null;
const rel = await friend.getRelationship(u.id).catch(() => ({}));
const n = normalizeRel(rel);
const karmaScore = await getLastKarmaScore(u.id);
return { user: u, rel: n, karmaScore };
})
);
const candidates = rels.filter(Boolean).filter(x => !x.rel.iFollow && !x.rel.blocking && !x.rel.blockedBy);
const enriched = candidates.map(x => ({
...x.user,
karmaScore: x.karmaScore,
mutualCount: x.rel.followsMe ? 1 : 0
}));
const unique = Array.from(new Map(enriched.map(u => [u.id, u])).values());
return unique.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0) || (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
}
if (filter === 'CVs' || filter === 'MATCHSKILLS') {
const records = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true}),
pull.filter(msg =>
msg.value.content?.type === 'curriculum' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
let cvs = records.map(r => r.value.content);
cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values());
if (filter === 'CVs') {
let out = await Promise.all(cvs.map(async c => {
const photo = await fetchUserImageUrl(c.author, 256);
const lastActivityTs = await getLastActivityTimestamp(c.author);
const { bucket, range } = bucketLastActivity(lastActivityTs);
const base = this._normalizeCurriculum(c, photo);
return { ...base, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
}));
out = filterInactive(out);
if (search) {
const q = search.toLowerCase();
out = out.filter(u =>
(u.name || '').toLowerCase().includes(q) ||
(u.description || '').toLowerCase().includes(q) ||
u.skills.some(s => (s || '').toLowerCase().includes(q))
);
}
if (location) out = out.filter(u => (u.location || '').toLowerCase() === location.toLowerCase());
if (language) out = out.filter(u => u.languages.map(l => l.toLowerCase()).includes(language.toLowerCase()));
if (skills) {
const skillList = skills.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
out = out.filter(u => skillList.every(s => u.skills.map(k => (k || '').toLowerCase()).includes(s)));
}
return out;
}
if (filter === 'MATCHSKILLS') {
let base = await Promise.all(cvs.map(async c => {
const photo = await fetchUserImageUrl(c.author, 256);
const lastActivityTs = await getLastActivityTimestamp(c.author);
const { bucket, range } = bucketLastActivity(lastActivityTs);
const norm = this._normalizeCurriculum(c, photo);
return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
}));
base = filterInactive(base);
const mecv = await this.getCVByUserId();
const userSkills = mecv
? [
...(mecv.personalSkills || []),
...(mecv.oasisSkills || []),
...(mecv.educationalSkills || []),
...(mecv.professionalSkills || [])
].map(s => (s || '').toLowerCase())
: [];
if (!userSkills.length) return [];
const matches = base.map(c => {
if (c.id === userId) return null;
const common = c.skills.map(s => (s || '').toLowerCase()).filter(s => userSkills.includes(s));
if (!common.length) return null;
const matchScore = common.length / userSkills.length;
return { ...c, commonSkills: common, matchScore };
}).filter(Boolean);
return matches.sort((a, b) => b.matchScore - a.matchScore);
}
}
return [];
},
_normalizeCurriculum(c, photoUrl) {
const photo = photoUrl || toImageUrl(c.photo, 256);
return {
id: c.author,
name: c.name,
description: c.description,
photo,
skills: [
...(c.personalSkills || []),
...(c.oasisSkills || []),
...(c.educationalSkills || []),
...(c.professionalSkills || [])
],
location: c.location,
languages: typeof c.languages === 'string'
? c.languages.split(',').map(x => x.trim())
: Array.isArray(c.languages) ? c.languages : [],
createdAt: c.createdAt
};
},
async getLatestAboutById(id) {
const ssbClient = await openSsb();
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id }),
pull.filter(msg =>
msg.value.content?.type === 'about' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
if (!records.length) return null;
const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0];
return latest.value.content;
},
async getFeedByUserId(id) {
const ssbClient = await openSsb();
const targetId = id || ssbClient.id;
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id: targetId }),
pull.filter(msg =>
msg.value &&
msg.value.content &&
typeof msg.value.content.text === 'string' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
return records
.filter(m => typeof m.value.content.text === 'string')
.sort((a, b) => b.value.timestamp - a.value.timestamp)
.slice(0, 10);
},
async getCVByUserId(id) {
const ssbClient = await openSsb();
const targetId = id || ssbClient.id;
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id: targetId }),
pull.filter(msg =>
msg.value.content?.type === 'curriculum' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
return records.length ? records[records.length - 1].value.content : null;
},
async getPhotoUrlByUserId(id, size = 256) {
return await fetchUserImageUrl(id, size);
},
async getLastActivityTimestampByUserId(id) {
return await getLastActivityTimestamp(id);
},
bucketLastActivity(ts) {
return bucketLastActivity(ts);
}
};
};

View file

@ -0,0 +1,473 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const logLimit = getConfig().ssbLogStream?.limit || 1000
const norm = (s) => String(s || "").trim().toLowerCase()
const toNum = (v) => {
const n = parseFloat(String(v ?? "").replace(",", "."))
return Number.isFinite(n) ? n : NaN
}
const toInt = (v, fallback = 0) => {
const n = parseInt(String(v ?? ""), 10)
return Number.isFinite(n) ? n : fallback
}
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const matchSearch = (job, q) => {
const qq = norm(q)
if (!qq) return true
const hay = [
job.title,
job.description,
job.requirements,
job.tasks,
job.languages,
Array.isArray(job.tags) ? job.tags.join(" ") : ""
].map(x => norm(x)).join(" ")
return hay.includes(qq)
}
module.exports = ({ cooler }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const readAll = async (ssbClient) =>
new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
)
const buildIndex = (messages) => {
const tomb = new Set()
const jobNodes = new Map()
const parent = new Map()
const child = new Map()
const jobSubLatest = new Map()
for (const m of messages) {
const key = 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 === "job") {
jobNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
if (c.replaces) {
parent.set(key, c.replaces)
child.set(c.replaces, key)
}
continue
}
if (c.type === "job_sub" && c.jobId) {
const author = v.author
if (!author) continue
const ts = v.timestamp || m.timestamp || 0
const jobId = c.jobId
const k = `${jobId}::${author}`
const prev = jobSubLatest.get(k)
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
continue
}
}
const rootOf = (id) => {
let cur = id
while (parent.has(cur)) cur = parent.get(cur)
return cur
}
const roots = new Set()
for (const id of jobNodes.keys()) roots.add(rootOf(id))
const tipOf = (id) => {
let cur = id
while (child.has(cur)) cur = child.get(cur)
return cur
}
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, tipOf(r))
const subsByJob = new Map()
for (const { jobId, author, value } of jobSubLatest.values()) {
if (!subsByJob.has(jobId)) subsByJob.set(jobId, new Set())
const set = subsByJob.get(jobId)
if (value) set.add(author)
else set.delete(author)
}
return { tomb, jobNodes, parent, child, rootOf, tipOf, tipByRoot, subsByJob }
}
const buildJobObject = (node, rootId, subscribers) => {
const c = node.c || {}
let blobId = c.image || null
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
const vacants = Math.max(1, toInt(c.vacants, 1))
const salaryN = toNum(c.salary)
const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
return {
id: node.key,
rootId,
job_type: c.job_type,
title: c.title,
description: c.description,
requirements: c.requirements,
languages: c.languages,
job_time: c.job_time,
tasks: c.tasks,
location: c.location,
vacants,
salary,
image: blobId,
author: c.author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
status: c.status || "OPEN",
tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
subscribers: Array.isArray(subscribers) ? subscribers : []
}
}
return {
type: "job",
async createJob(jobData) {
const ssbClient = await openSsb()
const job_type = String(jobData.job_type || "").toLowerCase()
if (!["freelancer", "employee"].includes(job_type)) throw new Error("Invalid job type")
const title = String(jobData.title || "").trim()
const description = String(jobData.description || "").trim()
if (!title) throw new Error("Invalid title")
if (!description) throw new Error("Invalid description")
const vacants = Math.max(1, toInt(jobData.vacants, 1))
const salaryN = toNum(jobData.salary)
const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
const job_time = String(jobData.job_time || "").toLowerCase()
if (!["partial", "complete"].includes(job_time)) throw new Error("Invalid job time")
const location = String(jobData.location || "").toLowerCase()
if (!["remote", "presencial"].includes(location)) throw new Error("Invalid location")
let blobId = jobData.image || null
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
const tags = normalizeTags(jobData.tags)
const content = {
type: "job",
job_type,
title,
description,
requirements: String(jobData.requirements || ""),
languages: String(jobData.languages || ""),
job_time,
tasks: String(jobData.tasks || ""),
location,
vacants,
salary,
image: blobId,
tags,
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "OPEN"
}
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async resolveCurrentId(jobId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const { tomb, child } = buildIndex(messages)
let cur = jobId
while (child.has(cur)) cur = child.get(cur)
if (tomb.has(cur)) throw new Error("Job not found")
return cur
},
async resolveRootId(jobId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const { tomb, parent, child } = buildIndex(messages)
let tip = jobId
while (child.has(tip)) tip = child.get(tip)
if (tomb.has(tip)) throw new Error("Job not found")
let root = tip
while (parent.has(root)) root = parent.get(root)
return root
},
async updateJob(id, jobData) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const tipId = await this.resolveCurrentId(id)
const node = idx.jobNodes.get(tipId)
if (!node || !node.c) throw new Error("Job not found")
const existingContent = node.c
const author = existingContent.author
if (author !== ssbClient.id) throw new Error("Unauthorized")
const patch = {}
if (jobData.job_type !== undefined) {
const jt = String(jobData.job_type || "").toLowerCase()
if (!["freelancer", "employee"].includes(jt)) throw new Error("Invalid job type")
patch.job_type = jt
}
if (jobData.title !== undefined) {
const t = String(jobData.title || "").trim()
if (!t) throw new Error("Invalid title")
patch.title = t
}
if (jobData.description !== undefined) {
const d = String(jobData.description || "").trim()
if (!d) throw new Error("Invalid description")
patch.description = d
}
if (jobData.requirements !== undefined) patch.requirements = String(jobData.requirements || "")
if (jobData.languages !== undefined) patch.languages = String(jobData.languages || "")
if (jobData.tasks !== undefined) patch.tasks = String(jobData.tasks || "")
if (jobData.job_time !== undefined) {
const jt = String(jobData.job_time || "").toLowerCase()
if (!["partial", "complete"].includes(jt)) throw new Error("Invalid job time")
patch.job_time = jt
}
if (jobData.location !== undefined) {
const loc = String(jobData.location || "").toLowerCase()
if (!["remote", "presencial"].includes(loc)) throw new Error("Invalid location")
patch.location = loc
}
if (jobData.vacants !== undefined) {
const v = Math.max(1, toInt(jobData.vacants, 1))
patch.vacants = v
}
if (jobData.salary !== undefined) {
const s = toNum(jobData.salary)
if (!Number.isFinite(s) || s < 0) throw new Error("Invalid salary")
patch.salary = s.toFixed(6)
}
if (jobData.tags !== undefined) patch.tags = normalizeTags(jobData.tags)
if (jobData.image !== undefined) {
let blobId = jobData.image
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
patch.image = blobId || null
}
if (jobData.status !== undefined) {
const s = String(jobData.status || "").toUpperCase()
if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status")
patch.status = s
}
const next = {
...existingContent,
...patch,
author,
createdAt: existingContent.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId,
type: "job"
}
const tomb = {
type: "tombstone",
target: tipId,
deletedAt: new Date().toISOString(),
author: ssbClient.id
}
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res()))
return new Promise((res, rej) => ssbClient.publish(next, (e, m) => e ? rej(e) : res(m)))
},
async updateJobStatus(id, status) {
return this.updateJob(id, { status: String(status || "").toUpperCase() })
},
async deleteJob(id) {
const ssbClient = await openSsb()
const tipId = await this.resolveCurrentId(id)
const job = await this.getJobById(tipId)
if (!job || job.author !== ssbClient.id) throw new Error("Unauthorized")
const tomb = {
type: "tombstone",
target: tipId,
deletedAt: new Date().toISOString(),
author: ssbClient.id
}
return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
},
async subscribeToJob(id, userId) {
const ssbClient = await openSsb()
const me = ssbClient.id
const uid = userId || me
const job = await this.getJobById(id)
if (!job) throw new Error("Job not found")
if (job.author === uid) throw new Error("Cannot subscribe to your own job")
if (String(job.status || "").toUpperCase() !== "OPEN") throw new Error("Job is closed")
const rootId = job.rootId || (await this.resolveRootId(id))
const msg = {
type: "job_sub",
jobId: rootId,
value: true,
createdAt: new Date().toISOString()
}
return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
},
async unsubscribeFromJob(id, userId) {
const ssbClient = await openSsb()
const me = ssbClient.id
const uid = userId || me
const job = await this.getJobById(id)
if (!job) throw new Error("Job not found")
if (job.author === uid) throw new Error("Cannot unsubscribe from your own job")
const rootId = job.rootId || (await this.resolveRootId(id))
const msg = {
type: "job_sub",
jobId: rootId,
value: false,
createdAt: new Date().toISOString()
}
return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
},
async listJobs(filter = "ALL", viewerId = null, query = {}) {
const ssbClient = await openSsb()
const me = ssbClient.id
const viewer = viewerId || me
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const jobs = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.jobNodes.get(tipId)
if (!node) continue
const subsSet = idx.subsByJob.get(rootId) || new Set()
const subs = Array.from(subsSet)
jobs.push(buildJobObject(node, rootId, subs))
}
const F = String(filter || "ALL").toUpperCase()
let list = jobs
if (F === "MINE") list = list.filter((j) => j.author === viewer)
else if (F === "REMOTE") list = list.filter((j) => String(j.location || "").toUpperCase() === "REMOTE")
else if (F === "PRESENCIAL") list = list.filter((j) => String(j.location || "").toUpperCase() === "PRESENCIAL")
else if (F === "FREELANCER") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "FREELANCER")
else if (F === "EMPLOYEE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EMPLOYEE")
else if (F === "OPEN") list = list.filter((j) => String(j.status || "").toUpperCase() === "OPEN")
else if (F === "CLOSED") list = list.filter((j) => String(j.status || "").toUpperCase() === "CLOSED")
else if (F === "RECENT") list = list.filter((j) => moment(j.createdAt).isAfter(moment().subtract(24, "hours")))
else if (F === "APPLIED") list = list.filter((j) => Array.isArray(j.subscribers) && j.subscribers.includes(viewer))
const search = String(query.search || query.q || "").trim()
const minSalary = query.minSalary ?? ""
const maxSalary = query.maxSalary ?? ""
const sort = String(query.sort || "").trim()
if (search) list = list.filter((j) => matchSearch(j, search))
const minS = toNum(minSalary)
const maxS = toNum(maxSalary)
if (Number.isFinite(minS)) list = list.filter((j) => toNum(j.salary) >= minS)
if (Number.isFinite(maxS)) list = list.filter((j) => toNum(j.salary) <= maxS)
const byRecent = () => list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
const bySalary = () => list.sort((a, b) => toNum(b.salary) - toNum(a.salary))
const bySubscribers = () => list.sort((a, b) => (b.subscribers || []).length - (a.subscribers || []).length)
if (F === "TOP") bySalary()
else if (sort === "salary") bySalary()
else if (sort === "subscribers") bySubscribers()
else byRecent()
return list
},
async getJobById(id, viewerId = null) {
const ssbClient = await openSsb()
void viewerId
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let tipId = id
while (idx.child.has(tipId)) tipId = idx.child.get(tipId)
if (idx.tomb.has(tipId)) throw new Error("Job not found")
let rootId = tipId
while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
const node = idx.jobNodes.get(tipId)
if (!node) {
const msg = await new Promise((r, j) => ssbClient.get(tipId, (e, m) => e ? j(e) : r(m)))
if (!msg || !msg.content) throw new Error("Job not found")
const tmpNode = { key: tipId, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
const subsSet = idx.subsByJob.get(rootId) || new Set()
const subs = Array.from(subsSet)
return buildJobObject(tmpNode, rootId, subs)
}
const subsSet = idx.subsByJob.get(rootId) || new Set()
const subs = Array.from(subsSet)
return buildJobObject(node, rootId, subs)
},
async getJobTipId(id) {
return this.resolveCurrentId(id)
}
}
}

View file

@ -0,0 +1,85 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
function encryptFile(filePath, password) {
if (typeof password === 'object' && password.password) {
password = password.password;
}
const key = Buffer.from(password, 'utf-8');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const homeDir = os.homedir();
const encryptedFilePath = path.join(homeDir, 'oasis.enc');
const output = fs.createWriteStream(encryptedFilePath);
const input = fs.createReadStream(filePath);
input.pipe(cipher).pipe(output);
return new Promise((resolve, reject) => {
output.on('finish', () => {
resolve(encryptedFilePath);
});
output.on('error', (err) => {
reject(err);
});
});
}
function decryptFile(filePath, password) {
if (typeof password === 'object' && password.password) {
password = password.password;
}
const key = Buffer.from(password, 'utf-8');
const iv = crypto.randomBytes(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const homeDir = os.homedir();
const decryptedFilePath = path.join(homeDir, 'secret');
const output = fs.createWriteStream(decryptedFilePath);
const input = fs.createReadStream(filePath);
input.pipe(decipher).pipe(output);
return new Promise((resolve, reject) => {
output.on('finish', () => {
resolve(decryptedFilePath);
});
output.on('error', (err) => {
console.error('Error deciphering data:', err);
reject(err);
});
});
}
module.exports = {
exportData: async (password) => {
try {
const homeDir = os.homedir();
const secretFilePath = path.join(homeDir, '.ssb', 'secret');
if (!fs.existsSync(secretFilePath)) {
throw new Error(".ssb/secret file doesn't exist");
}
const encryptedFilePath = await encryptFile(secretFilePath, password);
fs.unlinkSync(secretFilePath);
return encryptedFilePath;
} catch (error) {
throw new Error("Error exporting data: " + error.message);
}
},
importData: async ({ filePath, password }) => {
try {
if (!fs.existsSync(filePath)) {
throw new Error('Encrypted file not found.');
}
const decryptedFilePath = await decryptFile(filePath, password);
if (!fs.existsSync(decryptedFilePath)) {
throw new Error("Decryption failed.");
}
fs.unlinkSync(filePath);
return decryptedFilePath;
} catch (error) {
throw new Error("Error importing data: " + error.message);
}
}
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,625 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const logLimit = getConfig().ssbLogStream?.limit || 1000
const N = (s) => String(s || "").toUpperCase().replace(/\s+/g, "_")
const D = (s) => ({ FOR_SALE: "FOR SALE", OPEN: "OPEN", RESERVED: "RESERVED", CLOSED: "CLOSED", SOLD: "SOLD", DISCARDED: "DISCARDED" })[s] || (s ? s.replace(/_/g, " ") : s)
const ORDER = ["FOR_SALE", "OPEN", "RESERVED", "CLOSED", "SOLD", "DISCARDED"]
const OI = (s) => ORDER.indexOf(N(s))
const parseBidEntry = (raw) => {
const s = String(raw || "").trim()
if (!s) return null
if (s.includes("|")) {
const parts = s.split("|")
if (parts.length < 3) return null
const bidder = parts[0] || ""
const amount = parseFloat(String(parts[1] || "").replace(",", "."))
const time = parts.slice(2).join("|")
if (!bidder || !Number.isFinite(amount) || !time) return null
return { bidder, amount, time }
}
const first = s.indexOf(":")
const second = s.indexOf(":", first + 1)
if (first === -1 || second === -1) return null
const bidder = s.slice(0, first)
const amountStr = s.slice(first + 1, second)
const time = s.slice(second + 1)
const amount = parseFloat(String(amountStr || "").replace(",", "."))
if (!bidder || !Number.isFinite(amount) || !time) return null
return { bidder, amount, time }
}
const highestBidAmount = (poll) => {
const arr = Array.isArray(poll) ? poll : []
let best = 0
for (const x of arr) {
const b = parseBidEntry(x)
if (b && Number.isFinite(b.amount) && b.amount > best) best = b.amount
}
return best
}
const hasBidder = (poll, userId) => {
const arr = Array.isArray(poll) ? poll : []
for (const x of arr) {
const b = parseBidEntry(x)
if (b && b.bidder === userId) return true
}
return false
}
module.exports = ({ cooler }) => {
let ssb
const openSsb = async () => {
if (!ssb) ssb = await cooler.open()
return ssb
}
const readAll = async (ssbClient) => {
return new Promise((resolve, reject) =>
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))))
)
}
const resolveGraph = async () => {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const tomb = new Set()
const fwd = new Map()
const parent = new Map()
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "tombstone" && c.target) {
tomb.add(c.target)
continue
}
if (c.type !== "market") continue
if (c.replaces) {
fwd.set(c.replaces, m.key)
parent.set(m.key, c.replaces)
}
}
return { tomb, fwd, parent }
}
return {
type: "market",
async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0) {
const ssbClient = await openSsb()
const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true) : null
if (!formattedDeadline || !formattedDeadline.isValid()) throw new Error("Invalid deadline")
if (formattedDeadline.isBefore(moment(), "minute")) throw new Error("Cannot create an item in the past")
let blobId = null
if (image) {
blobId = String(image).trim() || null
}
const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(",").map((t) => t.trim()).filter(Boolean)
const p = typeof price === "string" ? parseFloat(String(price).replace(",", ".")) : parseFloat(price)
if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
const s = parseInt(String(stock || "0"), 10)
if (!Number.isFinite(s) || s <= 0) throw new Error("Invalid stock")
const itemContent = {
type: "market",
item_type,
title,
description,
image: blobId,
price: p.toFixed(6),
tags,
item_status,
status: "FOR SALE",
deadline: formattedDeadline.toISOString(),
includesShipping: !!includesShipping,
stock: s,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
seller: ssbClient.id,
auctions_poll: []
}
return new Promise((resolve, reject) => {
ssbClient.publish(itemContent, (err, res) => (err ? reject(err) : resolve(res)))
})
},
async resolveCurrentId(itemId) {
const { tomb, fwd } = await resolveGraph()
let cur = itemId
while (fwd.has(cur)) cur = fwd.get(cur)
if (tomb.has(cur)) throw new Error("Item not found")
return cur
},
async updateItemById(itemId, updatedData) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
const normalizeTags = (v) => {
if (v === undefined) return undefined
if (Array.isArray(v)) return v.filter(Boolean)
if (typeof v === "string") return v.split(",").map((t) => t.trim()).filter(Boolean)
return []
}
const normalized = { ...(updatedData || {}) }
const tagsCandidate = normalizeTags(updatedData && updatedData.tags)
if (tagsCandidate !== undefined) normalized.tags = tagsCandidate
if (normalized.price !== undefined && normalized.price !== null && normalized.price !== "") {
const p = typeof normalized.price === "string" ? parseFloat(String(normalized.price).replace(",", ".")) : parseFloat(normalized.price)
if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
normalized.price = p.toFixed(6)
}
if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") {
const dl = moment(normalized.deadline, moment.ISO_8601, true)
if (!dl.isValid()) throw new Error("Invalid deadline")
normalized.deadline = dl.toISOString()
}
if (normalized.stock !== undefined) {
const s = parseInt(String(normalized.stock), 10)
if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock")
normalized.stock = s
}
if (normalized.includesShipping !== undefined) {
normalized.includesShipping = !!normalized.includesShipping
}
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const curStatusNorm = N(item.content.status || "FOR SALE")
const curStatus = D(curStatusNorm)
if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Cannot update this item"))
const updated = {
...item.content,
...normalized,
tags: updatedData && updatedData.tags !== undefined ? normalized.tags : item.content.tags,
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (err1) => {
if (err1) return reject(err1)
ssbClient.publish(updated, (err2, res) => (err2 ? reject(err2) : resolve(res)))
})
})
})
},
async deleteItemById(itemId) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (err2) => (err2 ? reject(err2) : resolve({ message: "Item deleted successfully" })))
})
})
},
async listAllItems(filter = "all") {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
for (const m of messages) {
const k = m.key
const c = m.value && m.value.content
if (!c) continue
if (c.type === "tombstone" && c.target) {
tomb.add(c.target)
continue
}
if (c.type !== "market") continue
nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c })
if (c.replaces) {
parent.set(k, c.replaces)
child.set(c.replaces, k)
}
}
const rootOf = (id) => {
let cur = id
while (parent.has(cur)) cur = parent.get(cur)
return cur
}
const groups = new Map()
for (const id of nodes.keys()) {
const r = rootOf(id)
if (!groups.has(r)) groups.set(r, new Set())
groups.get(r).add(id)
}
const items = []
const now = moment()
for (const [rootId, ids] of groups.entries()) {
const leaf = Array.from(ids).find((id) => !child.has(id)) || Array.from(ids)[0]
if (!leaf) continue
if (tomb.has(leaf)) continue
let best = nodes.get(leaf)
if (!best) continue
let bestS = N(best.c.status || "FOR_SALE")
for (const id of ids) {
const n = nodes.get(id)
if (!n) continue
const s = N(n.c.status || "")
if (OI(s) > OI(bestS)) {
best = n
bestS = s
}
}
const c = best.c
let status = D(bestS)
if (c.deadline) {
const dl = moment(c.deadline)
if (dl.isValid() && dl.isBefore(now)) {
if (status !== "SOLD" && status !== "DISCARDED") {
if (String(c.item_type || "").toLowerCase() === "auction") {
status = highestBidAmount(c.auctions_poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
}
}
}
if (status === "FOR SALE" && (Number(c.stock) || 0) === 0) continue
items.push({
id: leaf,
rootId,
title: c.title,
description: c.description,
image: c.image,
price: c.price,
tags: c.tags || [],
item_type: c.item_type,
item_status: c.item_status || "NEW",
status,
createdAt: c.createdAt || new Date(best.ts).toISOString(),
updatedAt: c.updatedAt,
seller: c.seller,
includesShipping: !!c.includesShipping,
stock: Number(c.stock) || 0,
deadline: c.deadline || null,
auctions_poll: Array.isArray(c.auctions_poll) ? c.auctions_poll : []
})
}
let list = items
switch (filter) {
case "mine":
list = list.filter((i) => i.seller === userId)
break
case "exchange":
list = list.filter((i) => i.item_type === "exchange" && i.status === "FOR SALE")
break
case "auctions":
list = list.filter((i) => i.item_type === "auction" && i.status === "FOR SALE")
break
case "mybids":
list = list.filter((i) => i.item_type === "auction").filter((i) => hasBidder(i.auctions_poll, userId))
break
case "new":
list = list.filter((i) => i.item_status === "NEW" && i.status === "FOR SALE")
break
case "used":
list = list.filter((i) => i.item_status === "USED" && i.status === "FOR SALE")
break
case "broken":
list = list.filter((i) => i.item_status === "BROKEN" && i.status === "FOR SALE")
break
case "for sale":
list = list.filter((i) => i.status === "FOR SALE")
break
case "sold":
list = list.filter((i) => i.status === "SOLD")
break
case "discarded":
list = list.filter((i) => i.status === "DISCARDED")
break
case "recent": {
const oneDayAgo = moment().subtract(1, "days")
list = list.filter((i) => i.status === "FOR SALE" && moment(i.createdAt).isAfter(oneDayAgo))
break
}
}
return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
},
async getItemById(itemId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
for (const m of messages) {
const k = m.key
const c = m.value && m.value.content
if (!c) continue
if (c.type === "tombstone" && c.target) {
tomb.add(c.target)
continue
}
if (c.type !== "market") continue
nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c })
if (c.replaces) {
parent.set(k, c.replaces)
child.set(c.replaces, k)
}
}
let tip = itemId
while (child.has(tip)) tip = child.get(tip)
if (tomb.has(tip)) return null
let rootId = tip
while (parent.has(rootId)) rootId = parent.get(rootId)
const ids = new Set()
let cur = tip
ids.add(cur)
while (parent.has(cur)) {
cur = parent.get(cur)
ids.add(cur)
}
let best = nodes.get(tip) || null
if (!best || !best.c) return null
let bestS = N(best.c.status || "FOR_SALE")
for (const id of ids) {
const n = nodes.get(id)
if (!n) continue
const s = N(n.c.status || "")
if (OI(s) > OI(bestS)) {
best = n
bestS = s
}
}
const c = best.c
let status = D(bestS)
const now = moment()
if (c.deadline) {
const dl = moment(c.deadline)
if (dl.isValid() && dl.isBefore(now)) {
if (status !== "SOLD" && status !== "DISCARDED") {
if (String(c.item_type || "").toLowerCase() === "auction") {
status = highestBidAmount(c.auctions_poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
}
}
}
return {
id: tip,
rootId,
title: c.title,
description: c.description,
image: c.image,
price: c.price,
tags: c.tags || [],
item_type: c.item_type,
item_status: c.item_status,
status,
createdAt: c.createdAt || new Date(best.ts).toISOString(),
updatedAt: c.updatedAt,
seller: c.seller,
includesShipping: !!c.includesShipping,
stock: Number(c.stock) || 0,
deadline: c.deadline,
auctions_poll: Array.isArray(c.auctions_poll) ? c.auctions_poll : []
}
},
async checkAuctionItemsStatus(items) {
const ssbClient = await openSsb()
const myId = ssbClient.id
const now = moment()
const list = Array.isArray(items) ? items : []
for (const item of list) {
if (!item || !item.deadline) continue
if (item.seller !== myId) continue
const dl = moment(item.deadline)
if (!dl.isValid()) continue
if (!dl.isBefore(now)) continue
const curStatus = D(N(item.status))
if (curStatus === "SOLD" || curStatus === "DISCARDED") continue
let status = curStatus
const kind = String(item.item_type || "").toLowerCase()
if (kind === "auction") {
status = highestBidAmount(item.auctions_poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
try {
await this.updateItemById(item.id, { status })
} catch (_) {}
}
},
async setItemAsSold(itemId) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g, "_")
if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Already sold/discarded"))
const soldMsg = {
...item.content,
stock: 0,
status: "SOLD",
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tomb1 = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tomb1, (err1) => {
if (err1) return reject(err1)
ssbClient.publish(soldMsg, (err2, soldRes) => {
if (err2) return reject(err2)
const touchMsg = {
...soldMsg,
updatedAt: new Date().toISOString(),
replaces: soldRes.key
}
const tomb2 = { type: "tombstone", target: soldRes.key, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tomb2, (err3) => {
if (err3) return reject(err3)
ssbClient.publish(touchMsg, (err4, finalRes) => (err4 ? reject(err4) : resolve(finalRes)))
})
})
})
})
})
},
async addBidToAuction(itemId, userId, bidAmount) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const me = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
const c = item.content
if (String(c.item_type || "").toLowerCase() !== "auction") return reject(new Error("Not an auction"))
if (c.seller === userId) return reject(new Error("Cannot bid on your own item"))
const curStatus = D(N(c.status || "FOR_SALE"))
if (curStatus !== "FOR SALE") return reject(new Error("Auction is not active"))
const dl = c.deadline ? moment(c.deadline) : null
if (!dl || !dl.isValid()) return reject(new Error("Invalid deadline"))
if (dl.isBefore(moment())) return reject(new Error("Auction closed"))
const stock = Number(c.stock) || 0
if (stock <= 0) return reject(new Error("Out of stock"))
const basePrice = parseFloat(String(c.price || "0").replace(",", "."))
const bid = parseFloat(String(bidAmount || "").replace(",", "."))
if (!Number.isFinite(bid) || bid <= 0) return reject(new Error("Invalid bid"))
const highest = highestBidAmount(c.auctions_poll)
const min = Number.isFinite(highest) && highest > 0 ? highest : Number.isFinite(basePrice) ? basePrice : 0
if (bid <= min) return reject(new Error("Bid not highest"))
const bidLine = `${userId}|${bid.toFixed(6)}|${new Date().toISOString()}`
const updated = {
...c,
auctions_poll: [...(Array.isArray(c.auctions_poll) ? c.auctions_poll : []), bidLine],
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: me }
ssbClient.publish(tombstone, (err1) => {
if (err1) return reject(err1)
ssbClient.publish(updated, (err2, res) => (err2 ? reject(err2) : resolve(res)))
})
})
})
},
async decrementStock(itemId) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g, "_")
if (["SOLD", "DISCARDED"].includes(curStatus)) return resolve({ ok: true, noop: true })
const current = Number(item.content.stock) || 0
if (current <= 0) return resolve({ ok: true, noop: true })
const newStock = current - 1
const updated = {
...item.content,
stock: newStock,
status: newStock === 0 ? "SOLD" : item.content.status,
updatedAt: new Date().toISOString(),
replaces: tipId
}
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)))
})
})
})
}
}
}

View file

@ -0,0 +1,206 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const categories = require('../backend/opinion_categories');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const hasBlob = async (ssbClient, url) => {
return new Promise(resolve => {
ssbClient.blobs.has(url, (err, has) => {
resolve(!err && has);
});
});
};
const validTypes = [
'bookmark', 'votes', 'transfer',
'feed', 'image', 'audio', 'video', 'document'
];
const getPreview = c => {
if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`;
return c.text || c.description || c.title || '';
};
const createVote = async (contentId, category) => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
if (!categories.includes(category)) throw new Error("Invalid voting category.");
const msg = await new Promise((resolve, reject) =>
ssbClient.get(contentId, (err, value) => err ? reject(err) : resolve(value))
);
if (!msg || !msg.content) throw new Error("Opinion not found.");
const type = msg.content.type;
if (!validTypes.includes(type) || ['task', 'event', 'report'].includes(type)) {
throw new Error("Voting not allowed on this content type.");
}
if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted.");
const tombstone = {
type: 'tombstone',
target: contentId,
deletedAt: new Date().toISOString()
};
const updated = {
...msg.content,
opinions: {
...msg.content.opinions,
[category]: (msg.content.opinions?.[category] || 0) + 1
},
opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
updatedAt: new Date().toISOString(),
replaces: contentId
};
await new Promise((resolve, reject) =>
ssbClient.publish(tombstone, err => err ? reject(err) : resolve())
);
return new Promise((resolve, reject) =>
ssbClient.publish(updated, (err, result) => err ? reject(err) : resolve(result))
);
};
const listOpinions = async (filter = 'ALL', category = '') => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const tombstoned = new Set();
const replaces = new Map();
const byId = new Map();
for (const msg of messages) {
const key = msg.key;
const c = msg.value?.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
continue;
}
if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) {
if (c.replaces) replaces.set(c.replaces, key);
byId.set(key, {
key,
value: {
...msg.value,
content: c,
preview: getPreview(c)
}
});
}
}
for (const replacedId of replaces.keys()) {
byId.delete(replacedId);
}
let filtered = Array.from(byId.values());
const blobTypes = ['document', 'image', 'audio', 'video'];
const blobCheckCache = new Map();
filtered = await Promise.all(
filtered.map(async m => {
const c = m.value.content;
if (blobTypes.includes(c.type) && c.url) {
if (!blobCheckCache.has(c.url)) {
const valid = await hasBlob(ssbClient, c.url);
blobCheckCache.set(c.url, valid);
}
if (!blobCheckCache.get(c.url)) return null;
}
return m;
})
);
filtered = filtered.filter(Boolean);
const signatureOf = (m) => {
const c = m.value?.content || {};
switch (c.type) {
case 'document':
case 'image':
case 'audio':
case 'video':
return `${c.type}::${(c.url || '').trim()}`;
case 'bookmark': {
const u = (c.url || c.bookmark || '').trim().toLowerCase();
return `bookmark::${u}`;
}
case 'feed': {
const t = (c.text || '').replace(/\s+/g, ' ').trim();
return `feed::${t}`;
}
case 'votes': {
const q = (c.question || '').replace(/\s+/g, ' ').trim();
return `votes::${q}`;
}
case 'transfer': {
const concept = (c.concept || '').trim();
const amount = c.amount || '';
const from = c.from || '';
const to = c.to || '';
const deadline = c.deadline || '';
return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`;
}
default:
return `key::${m.key}`;
}
};
const bySig = new Map();
for (const m of filtered) {
const sig = signatureOf(m);
const prev = bySig.get(sig);
if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
bySig.set(sig, m);
}
}
filtered = Array.from(bySig.values());
if (filter === 'MINE') {
filtered = filtered.filter(m => m.value.author === userId);
} else if (filter === 'RECENT') {
const now = Date.now();
filtered = filtered.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
} else if (filter === 'TOP') {
filtered = filtered.sort((a, b) => {
const sum = v => Object.values(v.content.opinions || {}).reduce((acc, x) => acc + x, 0);
return sum(b.value) - sum(a.value);
});
} else if (categories.includes(filter)) {
filtered = filtered
.filter(m => m.value.content.opinions?.[filter])
.sort((a, b) =>
(b.value.content.opinions[filter] || 0) - (a.value.content.opinions[filter] || 0)
);
}
return filtered;
};
const getMessageById = async id => {
const ssbClient = await openSsb();
return new Promise((resolve, reject) =>
ssbClient.get(id, (err, msg) =>
err ? reject(new Error("Error fetching opinion: " + err)) :
!msg?.content ? reject(new Error("Opinion not found")) :
resolve(msg)
)
);
};
return {
createVote,
listOpinions,
getMessageById,
categories
};
};

View file

@ -0,0 +1,15 @@
const os = require('os');
const fs = require('fs');
const path = require('path');
module.exports = {
removeSSB: async () => {
try {
const homeDir = os.homedir();
const ssbPath = path.join(homeDir, '.ssb');
await fs.promises.rm(ssbPath, { recursive: true, force: true });
} catch (error) {
throw new Error("Error deleting data: " + error.message);
}
}
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,136 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getPixelByCoordinate = async (coordinateKey) => {
const ssbClient = await openSsb();
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const tombstoned = new Set(
messages
.filter(m => m.value?.content?.type === 'tombstone' && m.value?.content?.target)
.map(m => m.value.content.target)
);
const replaces = new Map();
const byId = new Map();
for (const m of messages) {
const c = m.value?.content;
const k = m.key;
if (!c || c.type !== 'pixelia' || c.coordinateKey !== coordinateKey) continue;
if (tombstoned.has(k)) continue;
if (c.replaces) replaces.set(c.replaces, k);
byId.set(k, m);
}
for (const r of replaces.keys()) {
byId.delete(r);
}
return [...byId.values()][0] || null;
};
const paintPixel = async (x, y, color) => {
if (x < 1 || x > 50 || y < 1 || y > 200) {
throw new Error('Coordinates out of bounds. Please use x (1-50) and y (1-200)');
}
const ssbClient = await openSsb();
const userId = ssbClient.id;
const coordinateKey = `${x}:${y}`;
const existingPixel = await getPixelByCoordinate(coordinateKey);
if (existingPixel) {
const tombstone = {
type: 'tombstone',
target: existingPixel.key,
deletedAt: new Date().toISOString()
};
await new Promise((resolve, reject) =>
ssbClient.publish(tombstone, err => err ? reject(err) : resolve())
);
}
const contributors = existingPixel?.value?.content?.contributors_inhabitants || [];
const contributors_inhabitants = contributors.includes(userId)
? contributors
: [...contributors, userId];
const content = {
type: 'pixelia',
x: x - 1,
y: y - 1,
color,
author: userId,
contributors_inhabitants,
timestamp: Date.now(),
coordinateKey,
replaces: existingPixel?.key || null
};
await new Promise((resolve, reject) => {
ssbClient.publish(content, (err) => err ? reject(err) : resolve());
});
};
const listPixels = async () => {
const ssbClient = await openSsb();
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const tombstoned = new Set();
const replaces = new Map();
const byKey = new Map();
for (const m of messages) {
const c = m.value?.content;
const k = m.key;
if (!c) continue;
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
continue;
}
if (c.type === 'pixelia') {
if (tombstoned.has(k)) continue;
if (c.replaces) replaces.set(c.replaces, k);
byKey.set(k, m);
}
}
for (const replaced of replaces.keys()) {
byKey.delete(replaced);
}
return Array.from(byKey.values()).map(m => ({
x: m.value.content.x + 1,
y: m.value.content.y + 1,
color: m.value.content.color,
author: m.value.content.author,
contributors_inhabitants: m.value.content.contributors_inhabitants || [],
timestamp: m.value.timestamp
}));
};
return {
paintPixel,
listPixels
};
};

View file

@ -0,0 +1,124 @@
const pull = require('../server/node_modules/pull-stream');
const util = require('../server/node_modules/util');
module.exports = ({ cooler }) => {
let ssb;
let userId;
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
function uniqueRecps(list) {
const out = [];
const seen = new Set();
for (const x of (list || [])) {
if (typeof x !== 'string') continue;
const id = x.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
return out;
}
return {
type: 'post',
async sendMessage(recipients = [], subject = '', text = '') {
const ssbClient = await openSsb();
const recps = uniqueRecps([userId, ...recipients]);
const content = {
type: 'post',
from: userId,
to: recps,
subject,
text,
sentAt: new Date().toISOString(),
private: true
};
const publishAsync = util.promisify(ssbClient.private.publish);
return publishAsync(content, recps);
},
async deleteMessageById(messageId) {
const ssbClient = await openSsb();
const rawMsg = await new Promise((resolve, reject) =>
ssbClient.get(messageId, (err, m) =>
err ? reject(new Error("Error retrieving message.")) : resolve(m)
)
);
let decrypted;
try {
decrypted = ssbClient.private.unbox({
key: messageId,
value: rawMsg,
timestamp: rawMsg?.timestamp || Date.now()
});
} catch {
throw new Error("Malformed message.");
}
const content = decrypted?.value?.content;
const author = decrypted?.value?.author;
const originalRecps = Array.isArray(content?.to) ? content.to : [];
if (!content || !author) throw new Error("Malformed message.");
if (content.type === 'tombstone') throw new Error("Message already deleted.");
const tombstone = {
type: 'tombstone',
target: messageId,
deletedAt: new Date().toISOString(),
private: true
};
const tombstoneRecps = uniqueRecps([userId, author, ...originalRecps]);
const publishAsync = util.promisify(ssbClient.private.publish);
return publishAsync(tombstone, tombstoneRecps);
},
async listAllPrivate() {
const ssbClient = await openSsb();
const raw = await new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ reverse: false }),
pull.collect((err, arr) => err ? reject(err) : resolve(arr))
);
});
const posts = [];
const tombed = new Set();
for (const m of raw) {
if (!m || !m.value) continue;
const keyIn = m.key || m.value?.key || m.value?.hash || '';
const valueIn = m.value || m;
const tsIn = m.timestamp || m.value?.timestamp || Date.now();
let dec;
try {
dec = ssbClient.private.unbox({ key: keyIn, value: valueIn, timestamp: tsIn });
} catch {
continue;
}
const v = dec?.value || {};
const c = v.content || {};
const k = dec?.key || keyIn;
if (!c || c.private !== true || !k) continue;
if (c.type === 'tombstone' && c.target) {
tombed.add(c.target);
continue;
}
if (c.type === 'post') {
const to = Array.isArray(c.to) ? c.to : [];
const author = v.author;
if (author === userId || to.includes(userId)) {
posts.push({
key: k,
value: { author, content: c },
timestamp: v.timestamp || tsIn
});
}
}
}
return posts.filter(m => m && m.key && !tombed.has(m.key));
}
};
};

View file

@ -0,0 +1,525 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000
module.exports = ({ cooler }) => {
let ssb
const openSsb = async () => {
if (!ssb) ssb = await cooler.open()
return ssb
}
const TYPE = "project"
const clampPercent = (n) => {
const x = parseInt(n, 10)
if (!Number.isFinite(x)) return 0
return Math.max(0, Math.min(100, x))
}
async function getAllMsgs(ssbClient) {
return new Promise((r, j) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((e, m) => (e ? j(e) : r(m)))
)
})
}
function extractBlobId(possibleMarkdownImage) {
return possibleMarkdownImage || null
}
function normalizeMilestonesFrom(data) {
if (Array.isArray(data.milestones)) {
return data.milestones
.map((m) => {
return {
title: String((m && m.title) || "").trim(),
description: (m && m.description) || "",
targetPercent: clampPercent(m && m.targetPercent),
dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null,
done: !!(m && m.done)
}
})
.filter((m) => m.title)
}
const title = String((data["milestones[0][title]"] || data.milestoneTitle || "")).trim()
const description = data["milestones[0][description]"] || data.milestoneDescription || ""
const tpRaw = (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent) != null
? (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent)
: 0
const targetPercent = clampPercent(tpRaw)
const dueRaw = data["milestones[0][dueDate]"] || data.milestoneDueDate || null
const dueDate = dueRaw ? new Date(dueRaw).toISOString() : null
const out = []
if (title) out.push({ title, description, targetPercent, dueDate, done: false })
return out
}
function safeMilestoneIndex(project, idx) {
const total = Array.isArray(project.milestones) ? project.milestones.length : 0
if (idx === null || idx === undefined || idx === "" || isNaN(idx)) return null
const n = parseInt(idx, 10)
if (!Number.isFinite(n)) return null
if (n < 0 || n >= total) return null
return n
}
function autoCompleteMilestoneIfReady(projectLike, milestoneIdx) {
if (milestoneIdx === null || milestoneIdx === undefined) {
return { milestones: projectLike.milestones || [], progress: projectLike.progress || 0, changed: false }
}
const milestones = Array.isArray(projectLike.milestones) ? projectLike.milestones.slice() : []
if (!milestones[milestoneIdx]) {
return { milestones, progress: projectLike.progress || 0, changed: false }
}
const bounties = Array.isArray(projectLike.bounties) ? projectLike.bounties : []
const related = bounties.filter((b) => b && b.milestoneIndex === milestoneIdx)
if (related.length === 0) {
return { milestones, progress: projectLike.progress || 0, changed: false }
}
const allDone = related.every((b) => !!(b && b.done))
let progress = projectLike.progress || 0
let changed = false
if (allDone && !milestones[milestoneIdx].done) {
milestones[milestoneIdx].done = true
const target = clampPercent(milestones[milestoneIdx].targetPercent || 0)
const pInt = parseInt(progress, 10)
progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
changed = true
}
return { milestones, progress, changed }
}
async function resolveTipId(id) {
const ssbClient = await openSsb()
const all = await getAllMsgs(ssbClient)
const tomb = new Set()
const forward = new Map()
for (const m of all) {
const c = m && m.value && m.value.content
if (!c) continue
if (c.type === "tombstone" && c.target) tomb.add(c.target)
if (c.type === TYPE && c.replaces) forward.set(c.replaces, m.key)
}
let cur = id
while (forward.has(cur)) cur = forward.get(cur)
if (tomb.has(cur)) throw new Error("Project not found")
return cur
}
async function getById(id) {
const ssbClient = await openSsb()
const tip = await resolveTipId(id)
const msg = await new Promise((r, j) => ssbClient.get(tip, (e, m) => (e ? j(e) : r(m))))
if (!msg || !msg.content) throw new Error("Project not found")
return { id: tip, ...msg.content }
}
async function publishReplace(ssbClient, currentId, content) {
const tomb = { type: "tombstone", target: currentId, deletedAt: new Date().toISOString(), author: ssbClient.id }
const updated = { ...content, type: TYPE, replaces: currentId, updatedAt: new Date().toISOString() }
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => (e ? rej(e) : res())))
return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => (e ? rej(e) : res(m))))
}
function isParticipant(project, uid) {
if (!project || !uid) return false
const backers = Array.isArray(project.backers) ? project.backers : []
if (backers.some((b) => b && b.userId === uid)) return true
const bounties = Array.isArray(project.bounties) ? project.bounties : []
if (bounties.some((b) => b && b.claimedBy === uid)) return true
return false
}
return {
type: TYPE,
async createProject(data) {
const ssbClient = await openSsb()
const blobId = extractBlobId(data.image)
const milestones = normalizeMilestonesFrom(data)
let goal = parseFloat(data.goal || 0) || 0
if (goal < 0) goal = 0
const deadlineISO = data.deadline ? new Date(data.deadline).toISOString() : null
const content = {
type: TYPE,
title: data.title,
description: data.description,
image: blobId || null,
goal,
pledged: parseFloat(data.pledged || 0) || 0,
deadline: deadlineISO,
progress: clampPercent(data.progress || 0),
status: String(data.status || "ACTIVE").toUpperCase(),
milestones,
bounties: Array.isArray(data.bounties)
? data.bounties
.map((b) => {
return {
title: String((b && b.title) || "").trim(),
amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0),
description: (b && b.description) || "",
claimedBy: (b && b.claimedBy) || null,
done: !!(b && b.done),
milestoneIndex: b && b.milestoneIndex != null ? parseInt(b.milestoneIndex, 10) : null
}
})
.filter((b) => b.title)
: [],
followers: [],
backers: [],
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: null
}
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
},
async updateProject(id, patch) {
const ssbClient = await openSsb()
const current = await getById(id)
if (current.author !== ssbClient.id) throw new Error("Unauthorized")
let blobId = patch.image === undefined ? current.image : patch.image
blobId = extractBlobId(blobId)
let milestones = patch.milestones === undefined ? current.milestones : patch.milestones
if (milestones != null) {
milestones = Array.isArray(milestones)
? milestones
.map((m) => {
return {
title: String((m && m.title) || "").trim(),
description: (m && m.description) || "",
targetPercent: clampPercent(m && m.targetPercent),
dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null,
done: !!(m && m.done)
}
})
.filter((m) => m.title)
: current.milestones
}
let bounties = patch.bounties === undefined ? current.bounties : patch.bounties
if (bounties != null) {
bounties = Array.isArray(bounties)
? bounties
.map((b) => {
return {
title: String((b && b.title) || "").trim(),
amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0),
description: (b && b.description) || "",
claimedBy: (b && b.claimedBy) || null,
done: !!(b && b.done),
milestoneIndex: b && b.milestoneIndex != null ? safeMilestoneIndex({ milestones: milestones || current.milestones }, b.milestoneIndex) : null
}
})
.filter((b) => b.title)
: current.bounties
}
let deadline = patch.deadline === undefined ? current.deadline : patch.deadline
if (deadline != null && deadline !== "") deadline = new Date(deadline).toISOString()
else if (deadline === "") deadline = null
const updated = {
...current,
...patch,
image: blobId || null,
milestones,
bounties,
deadline,
progress: patch.progress === undefined ? current.progress : clampPercent(patch.progress),
status: patch.status === undefined ? current.status : String(patch.status || "").toUpperCase()
}
return publishReplace(ssbClient, current.id, updated)
},
async deleteProject(id) {
const ssbClient = await openSsb()
const tip = await resolveTipId(id)
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const tomb = { type: "tombstone", target: tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => (e ? rej(e) : res(r))))
},
async updateProjectStatus(id, status) {
const s = String(status || "").toUpperCase()
return this.updateProject(id, { status: s })
},
async updateProjectProgress(id, progress) {
const p = clampPercent(progress)
return this.updateProject(id, { progress: p, ...(p >= 100 ? { status: "COMPLETED" } : {}) })
},
async followProject(id, uid) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
const followers = Array.isArray(project.followers) ? project.followers.slice() : []
if (!followers.includes(uid)) followers.push(uid)
return publishReplace(ssbClient, project.id, { ...project, followers, activity: { kind: "follow", activityActor: uid, at: new Date().toISOString() } })
},
async unfollowProject(id, uid) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
const followers = Array.isArray(project.followers) ? project.followers.filter((x) => x !== uid) : []
return publishReplace(ssbClient, project.id, { ...project, followers, activity: { kind: "unfollow", activityActor: uid, at: new Date().toISOString() } })
},
async pledgeToProject(id, uid, amount) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
const amt = Math.max(0, parseFloat(amount || 0) || 0)
if (amt <= 0) throw new Error("Invalid amount")
const backers = Array.isArray(project.backers) ? project.backers.slice() : []
backers.push({ userId: uid, amount: amt, at: new Date().toISOString(), confirmed: false })
const pledged = (parseFloat(project.pledged || 0) || 0) + amt
const progress = project.goal ? (pledged / parseFloat(project.goal || 1)) * 100 : project.progress
return publishReplace(ssbClient, project.id, { ...project, backers, pledged, progress })
},
async addBounty(id, bounty) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
const clean = {
title: String((bounty && bounty.title) || "").trim(),
amount: Math.max(0, parseFloat((bounty && bounty.amount) || 0) || 0),
description: (bounty && bounty.description) || "",
claimedBy: null,
done: false,
milestoneIndex: safeMilestoneIndex(project, bounty && bounty.milestoneIndex)
}
if (!clean.title) throw new Error("Bounty title required")
bounties.push(clean)
return publishReplace(ssbClient, project.id, { ...project, bounties })
},
async updateBounty(id, index, patch) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
if (!bounties[index]) throw new Error("Bounty not found")
if (patch.title !== undefined) bounties[index].title = String(patch.title || "").trim()
if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0)
if (patch.description !== undefined) bounties[index].description = patch.description || ""
if (patch.milestoneIndex !== undefined) bounties[index].milestoneIndex = safeMilestoneIndex(project, patch.milestoneIndex)
if (patch.done !== undefined) bounties[index].done = !!patch.done
return publishReplace(ssbClient, project.id, { ...project, bounties })
},
async addMilestone(id, milestone) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
const clean = {
title: String((milestone && milestone.title) || "").trim(),
description: (milestone && milestone.description) || "",
targetPercent: clampPercent(milestone && milestone.targetPercent),
dueDate: milestone && milestone.dueDate ? new Date(milestone.dueDate).toISOString() : null,
done: false
}
if (!clean.title) throw new Error("Milestone title required")
milestones.push(clean)
return publishReplace(ssbClient, project.id, { ...project, milestones })
},
async updateMilestone(id, index, patch) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
if (!milestones[index]) throw new Error("Milestone not found")
if (patch.title !== undefined) milestones[index].title = String(patch.title || "").trim()
if (patch.description !== undefined) milestones[index].description = patch.description || ""
if (patch.targetPercent !== undefined) milestones[index].targetPercent = clampPercent(patch.targetPercent)
if (patch.dueDate !== undefined) milestones[index].dueDate = patch.dueDate ? new Date(patch.dueDate).toISOString() : null
let progress = project.progress
if (patch.done !== undefined) {
milestones[index].done = !!patch.done
if (milestones[index].done) {
const target = clampPercent(milestones[index].targetPercent || 0)
const pInt = parseInt(project.progress || 0, 10)
progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
}
}
const updated = { ...project, milestones, ...(progress !== project.progress ? { progress, ...(progress >= 100 ? { status: "COMPLETED" } : {}) } : {}) }
return publishReplace(ssbClient, project.id, updated)
},
async claimBounty(id, index, uid) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
if (!bounties[index]) throw new Error("Bounty not found")
if (bounties[index].claimedBy) throw new Error("Already claimed")
if (project.author === uid) throw new Error("Authors cannot claim")
bounties[index].claimedBy = uid
return publishReplace(ssbClient, project.id, { ...project, bounties })
},
async completeBounty(id, index, uid) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== uid) throw new Error("Unauthorized")
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
if (!bounties[index]) throw new Error("Bounty not found")
bounties[index].done = true
const ac = autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex)
const patch = { ...project, bounties }
if (ac && ac.changed) {
patch.milestones = ac.milestones
patch.progress = ac.progress
if (ac.progress >= 100) patch.status = "COMPLETED"
}
return publishReplace(ssbClient, project.id, patch)
},
async completeMilestone(id, index, uid) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== uid) throw new Error("Unauthorized")
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
if (!milestones[index]) throw new Error("Milestone not found")
milestones[index].done = true
const target = clampPercent(milestones[index].targetPercent || 0)
const pInt = parseInt(project.progress || 0, 10)
const progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
const patch = { ...project, milestones, progress }
if (progress >= 100) patch.status = "COMPLETED"
return publishReplace(ssbClient, project.id, patch)
},
async listProjects(filter) {
const ssbClient = await openSsb()
const currentUserId = ssbClient.id
const msgs = await getAllMsgs(ssbClient)
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
for (const m of msgs) {
const k = m && m.key
const c = m && m.value && m.value.content
if (!c) continue
if (c.type === "tombstone" && c.target) {
tomb.add(c.target)
continue
}
if (c.type !== TYPE) continue
nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || 0, c })
if (c.replaces) {
parent.set(k, c.replaces)
child.set(c.replaces, k)
}
}
const rootOf = (id) => {
let cur = id
while (parent.has(cur)) cur = parent.get(cur)
return cur
}
const groups = new Map()
for (const id of nodes.keys()) {
const r = rootOf(id)
if (!groups.has(r)) groups.set(r, new Set())
groups.get(r).add(id)
}
const out = []
for (const entry of groups.entries()) {
const root = entry[0]
const ids = entry[1]
let tip = Array.from(ids).find((id) => !child.has(id))
if (!tip) {
const arr = Array.from(ids)
tip = arr.reduce((a, b) => (nodes.get(a).ts > nodes.get(b).ts ? a : b))
}
if (tomb.has(tip)) continue
const n = nodes.get(tip)
if (!n || !n.c) continue
const c = n.c
const status = String(c.status || "ACTIVE").toUpperCase()
const createdAt = c.createdAt || new Date(n.ts).toISOString()
const deadline = c.deadline || null
out.push({
id: tip,
...c,
status,
createdAt,
deadline
})
}
let list = out
const F = String(filter || "ALL").toUpperCase()
if (F === "MINE") list = list.filter((p) => p && p.author === currentUserId)
else if (F === "APPLIED") list = list.filter((p) => p && p.author !== currentUserId && isParticipant(p, currentUserId))
else if (F === "ACTIVE") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "ACTIVE")
else if (F === "COMPLETED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "COMPLETED")
else if (F === "PAUSED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "PAUSED")
else if (F === "CANCELLED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "CANCELLED")
else if (F === "RECENT") list = list.filter((p) => p && moment(p.createdAt).isAfter(moment().subtract(24, "hours")))
else if (F === "FOLLOWING") list = list.filter((p) => Array.isArray(p.followers) && p.followers.includes(currentUserId))
if (F === "TOP") {
list.sort((a, b) => (parseFloat(b.pledged || 0) / (parseFloat(b.goal || 1))) - (parseFloat(a.pledged || 0) / (parseFloat(a.goal || 1))))
} else {
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
}
return list
},
async getProjectById(id) {
return getById(id)
},
async getProjectTipId(id) {
return resolveTipId(id)
}
}
}

View file

@ -0,0 +1,250 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const normU = (v) => String(v || '').trim().toUpperCase();
const normalizeStatus = (v) => normU(v).replace(/\s+/g, '_').replace(/-+/g, '_');
const normalizeSeverity = (v) => String(v || '').trim().toLowerCase();
const ensureArray = (v) => Array.isArray(v) ? v.filter(Boolean) : [];
const trimStr = (v) => String(v || '').trim();
const normalizeTemplate = (category, tpl) => {
const cat = normU(category);
const t = tpl && typeof tpl === 'object' ? tpl : {};
const pick = (keys) => {
const out = {};
for (const k of keys) {
const val = trimStr(t[k]);
if (val) out[k] = val;
}
return out;
};
if (cat === 'BUGS') {
const out = pick(['stepsToReproduce', 'expectedBehavior', 'actualBehavior', 'environment', 'reproduceRate']);
if (out.reproduceRate) out.reproduceRate = normU(out.reproduceRate);
return out;
}
if (cat === 'FEATURES') {
return pick(['problemStatement', 'userStory', 'acceptanceCriteria']);
}
if (cat === 'ABUSE') {
return pick(['whatHappened', 'reportedUser', 'evidenceLinks']);
}
if (cat === 'CONTENT') {
return pick(['contentLocation', 'whyInappropriate', 'requestedAction', 'evidenceLinks']);
}
return {};
};
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
return {
async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}) {
const ssb = await openSsb();
const userId = ssb.id;
let blobId = null;
if (image) {
blobId = String(image).trim() || null;
}
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
const cat = normU(category);
const content = {
type: 'report',
title,
description,
category: cat,
createdAt: new Date().toISOString(),
author: userId,
image: blobId,
tags,
confirmations: [],
severity: normalizeSeverity(severity) || 'low',
status: 'OPEN',
template: normalizeTemplate(cat, template)
};
return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async updateReportById(id, updatedContent) {
const ssb = await openSsb();
const userId = ssb.id;
const report = await new Promise((res, rej) =>
ssb.get(id, (err, r) => err ? rej(new Error('Report not found')) : res(r))
);
if (report.content.author !== userId) throw new Error('Not the author');
const tags = Object.prototype.hasOwnProperty.call(updatedContent, 'tags')
? String(updatedContent.tags || '').split(',').map(t => t.trim()).filter(Boolean)
: ensureArray(report.content.tags);
let blobId = report.content.image || null;
if (updatedContent.image) {
blobId = String(updatedContent.image).trim() || null;
}
const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status')
? normalizeStatus(updatedContent.status)
: normalizeStatus(report.content.status || 'OPEN');
const nextSeverity = Object.prototype.hasOwnProperty.call(updatedContent, 'severity')
? (normalizeSeverity(updatedContent.severity) || 'low')
: (normalizeSeverity(report.content.severity) || 'low');
const nextCategory = Object.prototype.hasOwnProperty.call(updatedContent, 'category')
? normU(updatedContent.category)
: normU(report.content.category);
const confirmations = ensureArray(report.content.confirmations);
const baseTemplate = Object.prototype.hasOwnProperty.call(updatedContent, 'template')
? updatedContent.template
: (report.content.template || {});
const nextTemplate = normalizeTemplate(nextCategory, baseTemplate);
const updated = {
...report.content,
...updatedContent,
type: 'report',
replaces: id,
image: blobId,
tags,
confirmations,
severity: nextSeverity,
status: nextStatus,
category: nextCategory,
template: nextTemplate,
updatedAt: new Date().toISOString(),
author: report.content.author
};
return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
},
async deleteReportById(id) {
const ssb = await openSsb();
const userId = ssb.id;
const report = await new Promise((res, rej) =>
ssb.get(id, (err, r) => err ? rej(new Error('Report not found')) : res(r))
);
if (report.content.author !== userId) throw new Error('Not the author');
const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
},
async getReportById(id) {
const ssb = await openSsb();
const report = await new Promise((res, rej) =>
ssb.get(id, (err, r) => err ? rej(new Error('Report not found')) : res(r))
);
const c = report.content || {};
const cat = normU(c.category);
return {
id,
...c,
category: cat,
status: normalizeStatus(c.status || 'OPEN'),
severity: normalizeSeverity(c.severity) || 'low',
confirmations: ensureArray(c.confirmations),
tags: ensureArray(c.tags),
template: normalizeTemplate(cat, c.template || {})
};
},
async confirmReportById(id) {
const ssb = await openSsb();
const userId = ssb.id;
const report = await new Promise((res, rej) =>
ssb.get(id, (err, r) => err ? rej(new Error('Report not found')) : res(r))
);
const confirmations = ensureArray(report.content.confirmations);
if (confirmations.includes(userId)) throw new Error('Already confirmed');
const cat = normU(report.content.category);
const updated = {
...report.content,
type: 'report',
replaces: id,
confirmations: [...confirmations, userId],
updatedAt: new Date().toISOString(),
status: normalizeStatus(report.content.status || 'OPEN'),
category: cat,
severity: normalizeSeverity(report.content.severity) || 'low',
template: normalizeTemplate(cat, report.content.template || {})
};
return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
},
async listAll() {
const ssb = await openSsb();
return new Promise((resolve, reject) => {
pull(
ssb.createLogStream({ limit: logLimit }),
pull.collect((err, results) => {
if (err) return reject(err);
const tombstoned = new Set();
const replaced = new Map();
const reports = new Map();
for (const r of results) {
const key = r && r.key;
const c = r && r.value && r.value.content ? r.value.content : null;
if (!key || !c) continue;
if (c.type === 'tombstone' && c.target) tombstoned.add(c.target);
if (c.type === 'report') {
if (c.replaces) replaced.set(c.replaces, key);
const cat = normU(c.category);
reports.set(key, {
id: key,
...c,
category: cat,
status: normalizeStatus(c.status || 'OPEN'),
severity: normalizeSeverity(c.severity) || 'low',
confirmations: ensureArray(c.confirmations),
tags: ensureArray(c.tags),
template: normalizeTemplate(cat, c.template || {})
});
}
}
tombstoned.forEach(id => reports.delete(id));
replaced.forEach((_, oldId) => reports.delete(oldId));
resolve([...reports.values()]);
})
);
});
}
};
};

View file

@ -0,0 +1,287 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const searchableTypes = [
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
'votes', 'report', 'task', 'event', 'bookmark', 'document',
'image', 'audio', 'video', 'market', 'bankWallet', 'bankClaim',
'project', 'job', 'forum', 'vote', 'contact', 'pub'
];
const getRelevantFields = (type, content) => {
switch (type) {
case 'post':
return [content?.text, content?.contentWarning, ...(content?.tags || [])];
case 'about':
return [content?.about, content?.name, content?.description];
case 'feed':
return [content?.text, content?.author, content?.createdAt, ...(content?.tags || []), content?.refeeds];
case 'event':
return [content?.title, content?.description, content?.date, content?.location, content?.price, content?.eventUrl, ...(content?.tags || []), content?.attendees, content?.organizer, content?.status, content?.isPublic];
case 'votes':
return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes];
case 'tribe':
return [content?.title, content?.description, content?.image, content?.location, ...(content?.tags || []), content?.isLARP, content?.isAnonymous, content?.members?.length, content?.createdAt, content?.author];
case 'audio':
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
case 'image':
return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.meme];
case 'video':
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
case 'document':
return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.key];
case 'market':
return [content?.item_type, content?.title, content?.description, content?.price, ...(content?.tags || []), content?.status, content?.item_status, content?.deadline, content?.includesShipping, content?.seller, content?.image, content?.auctions_poll, content?.stock];
case 'bookmark':
return [content?.author, content?.url, ...(content?.tags || []), content?.description, content?.category, content?.lastVisit];
case 'task':
return [content?.title, content?.description, content?.startTime, content?.endTime, content?.priority, content?.location, ...(content?.tags || []), content?.isPublic, content?.assignees?.length, content?.status, content?.author];
case 'report':
return [content?.title, content?.description, content?.category, content?.createdAt, content?.author, content?.image, ...(content?.tags || []), content?.confirmations, content?.severity, content?.status, content?.isAnonymous];
case 'transfer':
return [content?.from, content?.to, content?.concept, content?.amount, content?.deadline, content?.status, ...(content?.tags || []), content?.confirmedBy?.length];
case 'curriculum':
return [content?.author, content?.name, content?.description, content?.photo, ...(content?.personalSkills || []), ...(content?.personalExperiences || []), ...(content?.oasisExperiences || []), ...(content?.oasisSkills || []), ...(content?.educationExperiences || []), ...(content?.educationalSkills || []), ...(content?.languages || []), ...(content?.professionalExperiences || []), ...(content?.professionalSkills || []), content?.location, content?.status, content?.preferences, content?.createdAt];
case 'bankWallet':
return [content?.address];
case 'bankClaim':
return [content?.amount, content?.epochId, content?.allocationId, content?.txid];
case 'project':
return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor];
case 'job':
return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length];
case 'forum':
return [content?.root, content?.category, content?.title, content?.text, content?.key];
case 'vote':
return [content?.vote?.link];
case 'contact':
return [content?.contact];
case 'pub':
return [content?.address?.host, content?.address?.key];
default:
return [];
}
};
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const getDedupeKey = (msg) => {
const c = msg?.value?.content || {};
const t = c?.type || 'unknown';
const author = c.author || msg?.value?.author || '';
if (t === 'post') return `post:${msg.key}`;
if (t === 'about') return `about:${c.about || author || msg.key}`;
if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
if (t === 'contact') return `contact:${c.contact || msg.key}`;
if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`;
if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
if (t === 'tribe') {
return [
'tribe',
author,
norm(c.title),
norm(c.location),
norm(c.image)
].join('|');
}
if (t === 'event') {
return [
'event',
c.organizer || author,
norm(c.title),
norm(c.date),
norm(c.location)
].join('|');
}
if (t === 'task') {
return [
'task',
c.author || author,
norm(c.title),
norm(c.startTime),
norm(c.endTime),
norm(c.location)
].join('|');
}
if (t === 'report') {
return [
'report',
c.author || author,
norm(c.title),
norm(c.category),
norm(c.severity)
].join('|');
}
if (t === 'votes') {
return [
'votes',
c.createdBy || author,
norm(c.question),
norm(c.deadline)
].join('|');
}
if (t === 'market') {
return [
'market',
c.seller || author,
norm(c.title),
norm(c.deadline),
norm(c.item_type),
norm(c.image)
].join('|');
}
if (t === 'transfer') {
const txid = c.txid || c.transactionId || c.id;
if (txid) return `transfer:${txid}`;
return [
'transfer',
norm(c.from),
norm(c.to),
norm(c.amount),
norm(c.concept),
norm(c.deadline)
].join('|');
}
if (t === 'feed') {
return [
'feed',
c.author || author,
norm(c.text)
].join('|');
}
if (t === 'project') {
return [
'project',
c.activityActor || author,
norm(c.title),
norm(c.deadline),
norm(c.goal)
].join('|');
}
if (t === 'job') {
return [
'job',
author,
norm(c.title),
norm(c.location),
norm(c.salary),
norm(c.job_type)
].join('|');
}
if (t === 'forum') {
return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
}
return `${t}:${msg.key}`;
};
const dedupeKeepLatest = (msgs) => {
const map = new Map();
for (const msg of msgs) {
const k = getDedupeKey(msg);
const prev = map.get(k);
const ts = msg?.value?.timestamp || 0;
const pts = prev?.value?.timestamp || 0;
if (!prev || ts > pts) map.set(k, msg);
}
return Array.from(map.values());
};
const search = async ({ query, types = [], resultsPerPage = "10" }) => {
const ssbClient = await openSsb();
const queryLower = String(query || '').toLowerCase();
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const tombstoned = new Set(messages.filter(m => m.value?.content?.type === 'tombstone').map(m => m.value.content.target));
const replacesMap = new Map();
const latestByKey = new Map();
for (const msg of messages) {
const k = msg.key;
const c = msg?.value?.content;
const t = c?.type;
if (!t || !searchableTypes.includes(t)) continue;
if (tombstoned.has(k)) continue;
if (c.replaces) replacesMap.set(c.replaces, k);
latestByKey.set(k, msg);
}
for (const oldId of replacesMap.keys()) {
latestByKey.delete(oldId);
}
let filtered = Array.from(latestByKey.values()).filter(msg => {
const c = msg?.value?.content;
const t = c?.type;
if (!t || (types.length > 0 && !types.includes(t))) return false;
if (t === 'market') {
if (c.stock === 0 && c.status !== 'SOLD') return false;
}
if (!queryLower) return true;
if (queryLower.startsWith('@') && queryLower.length > 1) return (t === 'about' && c?.about === query);
const fields = getRelevantFields(t, c);
if (queryLower.startsWith('#') && queryLower.length > 1) {
const tag = queryLower.substring(1);
return (c?.tags || []).some(x => String(x).toLowerCase() === tag);
}
return fields.filter(Boolean).map(String).some(field => field.toLowerCase().includes(queryLower));
});
filtered = dedupeKeepLatest(filtered);
filtered.sort((a, b) => (b?.value?.timestamp || 0) - (a?.value?.timestamp || 0));
const grouped = filtered.reduce((acc, msg) => {
const t = msg?.value?.content?.type || 'unknown';
if (!acc[t]) acc[t] = [];
acc[t].push(msg);
return acc;
}, {});
if (resultsPerPage !== "all") {
const limit = parseInt(resultsPerPage, 10);
for (const key in grouped) grouped[key] = grouped[key].slice(0, limit);
}
return grouped;
};
return { search };
};

View file

@ -0,0 +1,387 @@
const pull = require('../server/node_modules/pull-stream');
const os = require('os');
const fs = require('fs');
const path = require('path');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const STORAGE_DIR = path.join(__dirname, "..", "configs");
const ADDR_FILE = path.join(STORAGE_DIR, "wallet_addresses.json");
function readAddrMap() {
try {
if (!fs.existsSync(ADDR_FILE)) return {};
const raw = fs.readFileSync(ADDR_FILE, 'utf8');
const obj = JSON.parse(raw || '{}');
return obj && typeof obj === 'object' ? obj : {};
} catch {
return {};
}
}
const listPubsFromEbt = () => {
try {
const ebtDir = path.join(os.homedir(), '.ssb', 'ebt');
const files = fs.readdirSync(ebtDir);
return files.filter(f => f.endsWith('.ed25519'));
} catch {
return [];
}
};
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const types = [
'bookmark','event','task','votes','report','feed','project',
'image','audio','video','document','transfer','post','tribe',
'market','forum','job','aiExchange',
'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',
'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote'
];
const getFolderSize = (folderPath) => {
const files = fs.readdirSync(folderPath);
let totalSize = 0;
for (const file of files) {
const filePath = `${folderPath}/${file}`;
const st = fs.statSync(filePath);
totalSize += st.isDirectory() ? getFolderSize(filePath) : st.size;
}
return totalSize;
};
const formatSize = (sizeInBytes) => {
if (sizeInBytes < 1024) return `${sizeInBytes} B`;
const kb = 1024, mb = kb * 1024, gb = mb * 1024, tb = gb * 1024;
if (sizeInBytes < mb) return `${(sizeInBytes / kb).toFixed(2)} KB`;
if (sizeInBytes < gb) return `${(sizeInBytes / mb).toFixed(2)} MB`;
if (sizeInBytes < tb) return `${(sizeInBytes / gb).toFixed(2)} GB`;
return `${(sizeInBytes / tb).toFixed(2)} TB`;
};
const N = s => String(s || '').toUpperCase();
const sum = arr => arr.reduce((a, b) => a + b, 0);
const median = arr => {
if (!arr.length) return 0;
const a = [...arr].sort((x, y) => x - y);
const m = Math.floor(a.length / 2);
return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
};
const parseAuctionMax = auctions_poll => {
if (!Array.isArray(auctions_poll) || auctions_poll.length === 0) return 0;
const amounts = auctions_poll.map(s => {
const parts = String(s).split(':');
const amt = parseFloat(parts[1]);
return isNaN(amt) ? 0 : amt;
});
return amounts.length ? Math.max(...amounts) : 0;
};
const dayKey = ts => new Date(ts || 0).toISOString().slice(0, 10);
const lastNDays = (n) => {
const out = [];
const today = new Date();
today.setUTCHours(0,0,0,0);
for (let i = n - 1; i >= 0; i--) {
const d = new Date(today);
d.setUTCDate(today.getUTCDate() - i);
out.push(d.toISOString().slice(0, 10));
}
return out;
};
const norm = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
const bestContentTs = (c, fallbackTs = 0) =>
Number(c?.updatedAt ? Date.parse(c.updatedAt) : 0) ||
Number(c?.createdAt ? Date.parse(c.createdAt) : 0) ||
Number(c?.timestamp || 0) ||
Number(fallbackTs || 0);
const dedupeTribesNodes = (nodes = []) => {
const pick = new Map();
for (const n of nodes) {
const c = n?.content || {};
const title = c.title || c.name || '';
const author = n?.author || '';
const key = `${norm(title)}::${author}`;
const ts = bestContentTs(c, n?.ts || 0);
const prev = pick.get(key);
if (!prev || ts > prev._ts) pick.set(key, { ...n, _ts: ts });
}
return Array.from(pick.values());
};
const getStats = async (filter = 'ALL') => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const allMsgs = messages.filter(m => m.value?.content);
const tombTargets = new Set(
allMsgs
.filter(m => m.value.content.type === 'tombstone' && m.value.content.target)
.map(m => m.value.content.target)
);
const scopedMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
const byType = {};
const parentOf = {};
for (const t of types) {
byType[t] = new Map();
parentOf[t] = new Map();
}
for (const m of scopedMsgs) {
const k = m.key;
const c = m.value.content;
theType = c.type;
if (!types.includes(theType)) continue;
byType[theType].set(k, { key: k, ts: m.value.timestamp, content: c, author: m.value.author });
if (c.replaces) parentOf[theType].set(k, c.replaces);
}
const findRoot = (t, id) => {
let cur = id;
const pMap = parentOf[t];
while (pMap.has(cur)) cur = pMap.get(cur);
return cur;
};
const tipOf = {};
for (const t of types) {
tipOf[t] = new Map();
const pMap = parentOf[t];
const fwd = new Map();
for (const [child, parent] of pMap.entries()) fwd.set(parent, child);
const allMap = byType[t];
const roots = new Set(Array.from(allMap.keys()).map(id => findRoot(t, id)));
for (const root of roots) {
let tip = root;
while (fwd.has(tip)) tip = fwd.get(tip);
if (tombTargets.has(tip)) continue;
const node = allMap.get(tip) || allMap.get(root);
if (node) tipOf[t].set(root, node);
}
}
const tribeTipNodes = Array.from(tipOf['tribe'].values());
const tribeDedupNodes = dedupeTribesNodes(tribeTipNodes);
const tribeDedupContents = tribeDedupNodes.map(n => n.content);
const tribePublic = tribeDedupContents.filter(c => c.isAnonymous === false);
const tribePrivate = tribeDedupContents.filter(c => c.isAnonymous !== false);
const tribePublicNames = tribePublic.map(c => c.name || c.title || c.id).filter(Boolean);
const tribePublicCount = tribePublicNames.length;
const tribePrivateCount = tribePrivate.length;
const allTribesPublic = tribeDedupNodes
.filter(n => n.content?.isAnonymous === false)
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
const allTribes = allTribesPublic.map(t => t.name);
const memberTribesDetailed = tribeDedupNodes
.filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId))
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
const myPrivateTribesDetailed = tribeDedupNodes
.filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId))
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
const content = {};
const opinions = {};
for (const t of types) {
let vals;
if (t === 'tribe') {
vals = tribeDedupContents;
} else {
vals = Array.from(tipOf[t].values()).map(v => v.content);
if (t === 'forum') vals = vals.filter(c => !(c.root && tombTargets.has(c.root)));
}
content[t] = vals.length || 0;
opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length || 0;
}
const karmaMsgsAll = allMsgs.filter(m => m.value?.content?.type === 'karmaScore' && Number.isFinite(Number(m.value.content.karmaScore)));
if (filter === 'MINE') {
const mine = karmaMsgsAll.filter(m => m.value.author === userId).sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0));
const myKarma = mine.length ? Number(mine[0].value.content.karmaScore) || 0 : 0;
content['karmaScore'] = myKarma;
} else {
const latestByAuthor = new Map();
for (const m of karmaMsgsAll) {
const a = m.value.author;
const ts = m.value.timestamp || 0;
const k = Number(m.value.content.karmaScore) || 0;
const prev = latestByAuthor.get(a);
if (!prev || ts > prev.ts) latestByAuthor.set(a, { ts, k });
}
const sumKarma = Array.from(latestByAuthor.values()).reduce((s, x) => s + x.k, 0);
content['karmaScore'] = sumKarma;
}
const inhabitants = new Set(allMsgs.map(m => m.value.author)).size;
const secretStat = fs.statSync(`${os.homedir()}/.ssb/secret`);
const createdAt = secretStat.birthtime.toLocaleString();
const folderSize = getFolderSize(`${os.homedir()}/.ssb`);
const flumeSize = getFolderSize(`${os.homedir()}/.ssb/flume`);
const blobsSize = getFolderSize(`${os.homedir()}/.ssb/blobs`);
const allTs = scopedMsgs.map(m => m.value.timestamp || 0).filter(Boolean);
const lastTs = allTs.length ? Math.max(...allTs) : 0;
const mapDay = new Map();
for (const m of scopedMsgs) {
const dk = dayKey(m.value.timestamp || 0);
mapDay.set(dk, (mapDay.get(dk) || 0) + 1);
}
const days7 = lastNDays(7).map(d => ({ day: d, count: mapDay.get(d) || 0 }));
const days30 = lastNDays(30).map(d => ({ day: d, count: mapDay.get(d) || 0 }));
const daily7Total = sum(days7.map(o => o.count));
const daily30Total = sum(days30.map(o => o.count));
const jobsVals = Array.from(tipOf['job'].values()).map(v => v.content);
const jobOpen = jobsVals.filter(j => N(j.status) === 'OPEN').length;
const jobClosed = jobsVals.filter(j => N(j.status) === 'CLOSED').length;
const jobSalaries = jobsVals.map(j => parseFloat(j.salary)).filter(n => isFinite(n));
const jobVacantsOpen = jobsVals.filter(j => N(j.status) === 'OPEN').map(j => parseInt(j.vacants || 0, 10) || 0);
const jobSubsTotal = jobsVals.map(j => Array.isArray(j.subscribers) ? j.subscribers.length : 0);
const marketVals = Array.from(tipOf['market'].values()).map(v => v.content);
const mkForSale = marketVals.filter(m => N(m.status) === 'FOR SALE').length;
const mkReserved = marketVals.filter(m => N(m.status) === 'RESERVED').length;
const mkClosed = marketVals.filter(m => N(m.status) === 'CLOSED').length;
const mkSold = marketVals.filter(m => N(m.status) === 'SOLD').length;
let revenueECO = 0;
const soldPrices = [];
for (const m of marketVals) {
if (N(m.status) !== 'SOLD') continue;
let price = 0;
if (String(m.item_type || '').toLowerCase() === 'auction') {
price = parseAuctionMax(m.auctions_poll);
} else {
price = parseFloat(m.price || 0) || 0;
}
soldPrices.push(price);
revenueECO += price;
}
const projectVals = Array.from(tipOf['project'].values()).map(v => v.content);
const prActive = projectVals.filter(p => N(p.status) === 'ACTIVE').length;
const prCompleted = projectVals.filter(p => N(p.status) === 'COMPLETED').length;
const prPaused = projectVals.filter(p => N(p.status) === 'PAUSED').length;
const prCancelled = projectVals.filter(p => N(p.status) === 'CANCELLED').length;
const prGoals = projectVals.map(p => parseFloat(p.goal || 0) || 0);
const prPledged = projectVals.map(p => parseFloat(p.pledged || 0) || 0);
const prProgress = projectVals.map(p => parseFloat(p.progress || 0) || 0);
const activeFundingRates = projectVals
.filter(p => N(p.status) === 'ACTIVE' && parseFloat(p.goal || 0) > 0)
.map(p => (parseFloat(p.pledged || 0) / parseFloat(p.goal || 1)) * 100);
const projectsKPIs = {
total: projectVals.length,
active: prActive,
completed: prCompleted,
paused: prPaused,
cancelled: prCancelled,
ecoGoalTotal: sum(prGoals),
ecoPledgedTotal: sum(prPledged),
successRate: projectVals.length ? (prCompleted / projectVals.length) * 100 : 0,
avgProgress: prProgress.length ? (sum(prProgress) / prProgress.length) : 0,
medianProgress: median(prProgress),
activeFundingAvg: activeFundingRates.length ? (sum(activeFundingRates) / activeFundingRates.length) : 0
};
const topAuthorsMap = new Map();
for (const m of scopedMsgs) {
const a = m.value.author;
topAuthorsMap.set(a, (topAuthorsMap.get(a) || 0) + 1);
}
const topAuthors = Array.from(topAuthorsMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([id, count]) => ({ id, count }));
const addrMap = readAddrMap();
const myAddress = addrMap[userId] || null;
const banking = {
ecoWalletConfigured: !!myAddress,
myAddress,
myAddressCount: myAddress ? 1 : 0,
totalAddresses: Object.keys(addrMap).length
};
const pubsCount = listPubsFromEbt().length;
const stats = {
id: userId,
createdAt,
inhabitants,
content,
opinions,
memberTribes: memberTribesDetailed.map(t => t.name),
memberTribesDetailed,
myPrivateTribesDetailed,
allTribes,
allTribesPublic,
tribePublicNames,
tribePublicCount,
tribePrivateCount,
userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone').length,
networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
folderSize: formatSize(folderSize),
statsBlockchainSize: formatSize(flumeSize),
statsBlobsSize: formatSize(blobsSize),
pubsCount,
activity: {
lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
daily7: days7,
daily30Total,
daily7Total
},
jobsKPIs: {
total: jobsVals.length,
open: jobOpen,
closed: jobClosed,
avgSalary: jobSalaries.length ? (sum(jobSalaries) / jobSalaries.length) : 0,
medianSalary: median(jobSalaries),
openVacants: sum(jobVacantsOpen),
subscribersTotal: sum(jobSubsTotal)
},
marketKPIs: {
total: marketVals.length,
forSale: mkForSale,
reserved: mkReserved,
closed: mkClosed,
sold: mkSold,
revenueECO,
avgSoldPrice: soldPrices.length ? (sum(soldPrices) / soldPrices.length) : 0
},
projectsKPIs,
usersKPIs: {
totalInhabitants: inhabitants,
topAuthors
},
tombstoneKPIs: {
networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
ratio: allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0
},
banking
};
return stats;
};
return { getStats };
};

View file

@ -0,0 +1,170 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const normalizeTag = (tag) => String(tag == null ? '' : tag).trim().replace(/^#/, '');
const tagKey = (tag) => normalizeTag(tag).toLowerCase();
const getDedupeKey = (msg) => {
const c = msg?.value?.content || {};
const t = c?.type || 'unknown';
const author = c.author || msg?.value?.author || '';
if (t === 'post') return `post:${msg.key}`;
if (t === 'about') return `about:${c.about || author || msg.key}`;
if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
if (t === 'contact') return `contact:${c.contact || msg.key}`;
if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`;
if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
if (t === 'tribe') {
return ['tribe', author, norm(c.title), norm(c.location), norm(c.image)].join('|');
}
if (t === 'event') {
return ['event', c.organizer || author, norm(c.title), norm(c.date), norm(c.location)].join('|');
}
if (t === 'task') {
return ['task', c.author || author, norm(c.title), norm(c.startTime), norm(c.endTime), norm(c.location)].join('|');
}
if (t === 'report') {
return ['report', c.author || author, norm(c.title), norm(c.category), norm(c.severity)].join('|');
}
if (t === 'votes') {
return ['votes', c.createdBy || author, norm(c.question), norm(c.deadline)].join('|');
}
if (t === 'market') {
return ['market', c.seller || author, norm(c.title), norm(c.deadline), norm(c.item_type), norm(c.image)].join('|');
}
if (t === 'transfer') {
const txid = c.txid || c.transactionId || c.id;
if (txid) return `transfer:${txid}`;
return ['transfer', norm(c.from), norm(c.to), norm(c.amount), norm(c.concept), norm(c.deadline)].join('|');
}
if (t === 'feed') {
return ['feed', c.author || author, norm(c.text)].join('|');
}
if (t === 'project') {
return ['project', c.activityActor || author, norm(c.title), norm(c.deadline), norm(c.goal)].join('|');
}
if (t === 'job') {
return ['job', author, norm(c.title), norm(c.location), norm(c.salary), norm(c.job_type)].join('|');
}
if (t === 'forum') {
return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
}
return `${t}:${msg.key}`;
};
const dedupeKeepLatest = (msgs) => {
const map = new Map();
for (const msg of msgs) {
const k = getDedupeKey(msg);
const prev = map.get(k);
const ts = msg?.value?.timestamp || 0;
const pts = prev?.value?.timestamp || 0;
if (!prev || ts > pts) map.set(k, msg);
}
return Array.from(map.values());
};
return {
async listTags(filter = 'all') {
const ssbClient = await openSsb();
const messages = await new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
const tombstoned = new Set(
messages
.filter(m => m?.value?.content?.type === 'tombstone')
.map(m => m.value.content.target)
.filter(Boolean)
);
const replacesMap = new Map();
const latestByKey = new Map();
for (const msg of messages) {
const k = msg?.key;
const c = msg?.value?.content;
const t = c?.type;
if (!k || !c || !t) continue;
if (tombstoned.has(k)) continue;
if (t === 'tombstone') continue;
if (c.replaces) replacesMap.set(c.replaces, k);
latestByKey.set(k, msg);
}
for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
let filtered = Array.from(latestByKey.values()).filter(msg => {
const c = msg?.value?.content;
if (!c || c.type === 'tombstone') return false;
if (tombstoned.has(msg.key)) return false;
return Array.isArray(c.tags) && c.tags.filter(Boolean).length > 0;
});
filtered = dedupeKeepLatest(filtered);
const counts = new Map();
for (const record of filtered) {
const tagsArr = record?.value?.content?.tags || [];
const uniqueTags = new Set(tagsArr.map(tagKey).filter(Boolean));
for (const k of uniqueTags) {
const display = normalizeTag(tagsArr.find(t => tagKey(t) === k) || k) || k;
const prev = counts.get(k);
if (!prev) counts.set(k, { name: display, count: 1 });
else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
}
}
let tags = Array.from(counts.values());
if (filter === 'top') {
tags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
} else if (filter === 'cloud') {
const max = Math.max(...tags.map(t => t.count), 1);
tags = tags.map(t => ({ ...t, weight: t.count / max }));
} else {
tags.sort((a, b) => a.name.localeCompare(b.name));
}
return tags;
}
};
};

View file

@ -0,0 +1,216 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
const normalizeVisibility = (v) => {
const vv = String(v || 'PUBLIC').toUpperCase();
return (vv === 'PUBLIC' || vv === 'PRIVATE') ? vv : 'PUBLIC';
};
const normalizeStatus = (v, fallback) => {
const vv = String(v || '').toUpperCase();
if (vv === 'OPEN' || vv === 'IN-PROGRESS' || vv === 'CLOSED') return vv;
return fallback;
};
return {
async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic) {
const ssb = await openSsb();
const userId = ssb.id;
const start = moment(startTime);
const end = moment(endTime);
if (!start.isValid() || !end.isValid()) throw new Error('Invalid dates');
const nowFloor = moment().startOf('minute');
if (start.isBefore(nowFloor) || end.isBefore(start)) throw new Error('Invalid time range');
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
const visibility = normalizeVisibility(isPublic);
const content = {
type: 'task',
title,
description,
startTime: start.toISOString(),
endTime: end.toISOString(),
priority,
location,
tags,
isPublic: visibility,
assignees: [userId],
createdAt: new Date().toISOString(),
status: 'OPEN',
author: userId
};
return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async deleteTaskById(taskId) {
const ssb = await openSsb();
const userId = ssb.id;
const task = await new Promise((res, rej) => ssb.get(taskId, (err, task) => err ? rej(new Error('Task not found')) : res(task)));
if (task.content.author !== userId) throw new Error('Not the author');
const tombstone = { type: 'tombstone', target: taskId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
},
async updateTaskById(taskId, updatedData) {
const ssb = await openSsb();
const userId = ssb.id;
const old = await new Promise((res, rej) =>
ssb.get(taskId, (err, msg) => err || !msg ? rej(new Error('Task not found')) : res(msg))
);
const c = old.content;
if (c.type !== 'task') throw new Error('Invalid type');
const keys = Object.keys(updatedData || {}).filter(k => updatedData[k] !== undefined);
const assigneesOnly = keys.length === 1 && keys[0] === 'assignees';
const taskCreator = c.author || old.author;
if (!assigneesOnly && taskCreator !== userId) throw new Error('Not the author');
if (c.status === 'CLOSED') throw new Error('Cannot edit a closed task');
let nextAssignees = Array.isArray(c.assignees) ? uniq(c.assignees) : [];
if (assigneesOnly) {
const proposed = uniq(updatedData.assignees);
const oldNoSelf = uniq(nextAssignees.filter(x => x !== userId)).sort();
const newNoSelf = uniq(proposed.filter(x => x !== userId)).sort();
if (oldNoSelf.length !== newNoSelf.length || oldNoSelf.some((v, i) => v !== newNoSelf[i])) {
throw new Error('Not allowed');
}
const hadSelf = nextAssignees.includes(userId);
const hasSelfNow = proposed.includes(userId);
if (hadSelf === hasSelfNow) throw new Error('Not allowed');
nextAssignees = proposed;
}
let newStart = c.startTime;
if (updatedData.startTime != null && updatedData.startTime !== '') {
const m = moment(updatedData.startTime);
if (!m.isValid()) throw new Error('Invalid startTime');
newStart = m.toISOString();
}
let newEnd = c.endTime;
if (updatedData.endTime != null && updatedData.endTime !== '') {
const m = moment(updatedData.endTime);
if (!m.isValid()) throw new Error('Invalid endTime');
newEnd = m.toISOString();
}
if (moment(newEnd).isBefore(moment(newStart))) throw new Error('Invalid time range');
let newTags = c.tags || [];
if (updatedData.tags !== undefined) {
if (Array.isArray(updatedData.tags)) newTags = updatedData.tags.filter(Boolean);
else if (typeof updatedData.tags === 'string') newTags = updatedData.tags.split(',').map(t => t.trim()).filter(Boolean);
else newTags = [];
}
let newVisibility = c.isPublic;
if (updatedData.isPublic !== undefined) {
newVisibility = normalizeVisibility(updatedData.isPublic);
}
let newStatus = c.status;
if (updatedData.status !== undefined) {
const normalized = normalizeStatus(updatedData.status, null);
if (!normalized) throw new Error('Invalid status');
newStatus = normalized;
}
const updated = {
...c,
title: updatedData.title ?? c.title,
description: updatedData.description ?? c.description,
startTime: newStart,
endTime: newEnd,
priority: updatedData.priority ?? c.priority,
location: updatedData.location ?? c.location,
tags: newTags,
isPublic: newVisibility,
status: newStatus,
assignees: assigneesOnly ? nextAssignees : (updatedData.assignees !== undefined ? uniq(updatedData.assignees) : nextAssignees),
updatedAt: new Date().toISOString(),
replaces: taskId
};
return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
},
async updateTaskStatus(taskId, status) {
const normalized = String(status || '').toUpperCase();
if (!['OPEN', 'IN-PROGRESS', 'CLOSED'].includes(normalized)) throw new Error('Invalid status');
return this.updateTaskById(taskId, { status: normalized });
},
async getTaskById(taskId) {
const ssb = await openSsb();
const now = moment();
const task = await new Promise((res, rej) => ssb.get(taskId, (err, task) => err ? rej(new Error('Task not found')) : res(task)));
const c = task.content;
const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status;
return { id: taskId, ...c, status };
},
async toggleAssignee(taskId) {
const ssb = await openSsb();
const userId = ssb.id;
const task = await this.getTaskById(taskId);
if (task.status === 'CLOSED') throw new Error('Cannot assign users to a closed task');
let assignees = Array.isArray(task.assignees) ? [...task.assignees] : [];
const idx = assignees.indexOf(userId);
if (idx !== -1) assignees.splice(idx, 1);
else assignees.push(userId);
return this.updateTaskById(taskId, { assignees });
},
async listAll() {
const ssb = await openSsb();
const now = moment();
return new Promise((resolve, reject) => {
pull(ssb.createLogStream({ limit: logLimit }),
pull.collect((err, results) => {
if (err) return reject(err);
const tombstoned = new Set();
const replaced = new Map();
const tasks = new Map();
for (const r of results) {
const { key, value: { content: c } } = r;
if (!c) continue;
if (c.type === 'tombstone') tombstoned.add(c.target);
if (c.type === 'task') {
if (c.replaces) replaced.set(c.replaces, key);
const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status;
tasks.set(key, { id: key, ...c, status });
}
}
tombstoned.forEach(id => tasks.delete(id));
replaced.forEach((_, oldId) => tasks.delete(oldId));
resolve([...tasks.values()]);
})
);
});
}
};
};

View file

@ -0,0 +1,355 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const categories = require("../backend/opinion_categories")
const logLimit = getConfig().ssbLogStream?.limit || 1000
const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || ""))
const parseNum = (v) => {
const n = parseFloat(String(v ?? "").replace(",", "."))
return Number.isFinite(n) ? n : NaN
}
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
module.exports = ({ cooler }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
})
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => err ? reject(err) : resolve(msg))
})
const buildIndex = (messages) => {
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
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 === "transfer") {
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
if (c.replaces) {
parent.set(k, c.replaces)
child.set(c.replaces, k)
}
}
}
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
}
const roots = new Set()
for (const id of nodes.keys()) roots.add(rootOf(id))
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, tipOf(r))
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
}
const deriveStatus = (t) => {
const status = String(t.status || "").toUpperCase()
const from = t.from
const to = t.to
const required = from === to ? 1 : 2
const confirmedCount = Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0
const dl = t.deadline ? moment(t.deadline) : null
if (status === "UNCONFIRMED" && dl && dl.isValid() && dl.isBefore(moment())) {
return confirmedCount >= required ? "CLOSED" : "DISCARDED"
}
if (status === "CLOSED" || status === "DISCARDED" || status === "UNCONFIRMED") return status
return status || "UNCONFIRMED"
}
const buildTransfer = (node) => {
const c = node.c || {}
return {
id: node.key,
from: c.from,
to: c.to,
concept: c.concept,
amount: c.amount,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
deadline: c.deadline,
confirmedBy: Array.isArray(c.confirmedBy) ? c.confirmedBy : [],
status: deriveStatus(c),
tags: Array.isArray(c.tags) ? c.tags : [],
opinions: c.opinions || {},
opinions_inhabitants: Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : []
}
}
return {
type: "transfer",
async resolveCurrentId(id) {
const ssbClient = await openSsb()
const messages = await getAllMessages(ssbClient)
const idx = buildIndex(messages)
let tip = id
while (idx.child.has(tip)) tip = idx.child.get(tip)
if (idx.tomb.has(tip)) throw new Error("Not found")
return tip
},
async createTransfer(to, concept, amount, deadline, tagsRaw = []) {
const ssbClient = await openSsb()
const userId = ssbClient.id
if (!isValidId(to)) throw new Error("Invalid recipient ID")
const num = parseNum(amount)
if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive")
const dl = moment(deadline, moment.ISO_8601, true)
if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
const tags = normalizeTags(tagsRaw)
const isSelf = to === userId
const now = new Date().toISOString()
const content = {
type: "transfer",
from: userId,
to,
concept: String(concept || ""),
amount: num.toFixed(6),
createdAt: now,
updatedAt: now,
deadline: dl.toISOString(),
confirmedBy: [userId],
status: isSelf ? "CLOSED" : "UNCONFIRMED",
tags,
opinions: {},
opinions_inhabitants: []
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
},
async updateTransferById(id, to, concept, amount, deadline, tagsRaw = []) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const tipId = await this.resolveCurrentId(id)
const old = await getMsg(ssbClient, tipId)
if (!old?.content || old.content.type !== "transfer") throw new Error("Transfer not found")
const current = old.content
const currentStatus = deriveStatus(current)
if (Object.keys(current.opinions || {}).length > 0) throw new Error("Cannot edit transfer after it has received opinions.")
if (current.from !== userId) throw new Error("Not the author")
if (currentStatus !== "UNCONFIRMED") throw new Error("Can only edit unconfirmed")
const dlOld = current.deadline ? moment(current.deadline) : null
if (dlOld && dlOld.isValid() && dlOld.isBefore(moment())) throw new Error("Cannot edit expired")
if (!isValidId(to)) throw new Error("Invalid recipient ID")
const num = parseNum(amount)
if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive")
const dl = moment(deadline, moment.ISO_8601, true)
if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
const tags = normalizeTags(tagsRaw)
const isSelf = to === userId
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((res, rej) => ssbClient.publish(tombstone, (err) => err ? rej(err) : res()))
const updated = {
type: "transfer",
from: userId,
to,
concept: String(concept || ""),
amount: num.toFixed(6),
createdAt: current.createdAt,
deadline: dl.toISOString(),
confirmedBy: [userId],
status: isSelf ? "CLOSED" : "UNCONFIRMED",
tags,
opinions: {},
opinions_inhabitants: [],
updatedAt: new Date().toISOString(),
replaces: tipId
}
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, msg) => err ? reject(err) : resolve(msg))
})
},
async confirmTransferById(id) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const tipId = await this.resolveCurrentId(id)
const msg = await getMsg(ssbClient, tipId)
if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
const t = msg.content
const status = deriveStatus(t)
if (status !== "UNCONFIRMED") throw new Error("Not unconfirmed")
if (t.to !== userId) throw new Error("Not the recipient")
const dl = t.deadline ? moment(t.deadline) : null
if (dl && dl.isValid() && dl.isBefore(moment())) throw new Error("Expired")
const existing = Array.isArray(t.confirmedBy) ? t.confirmedBy : []
if (existing.includes(userId)) throw new Error("Already confirmed")
const required = t.from === t.to ? 1 : 2
const newConfirmed = existing.concat(userId).filter((v, i, a) => a.indexOf(v) === i)
const newStatus = newConfirmed.length >= required ? "CLOSED" : "UNCONFIRMED"
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => e ? rej(e) : res()))
const upd = {
...t,
confirmedBy: newConfirmed,
status: newStatus,
updatedAt: new Date().toISOString(),
replaces: tipId
}
return new Promise((resolve, reject) => {
ssbClient.publish(upd, (e2, result) => e2 ? reject(e2) : resolve(result))
})
},
async deleteTransferById(id) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const tipId = await this.resolveCurrentId(id)
const msg = await getMsg(ssbClient, tipId)
if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
const t = msg.content
const st = deriveStatus(t)
const confirmedCount = Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0
const required = t.from === t.to ? 1 : 2
if (t.from !== userId) throw new Error("Not the author")
if (st !== "UNCONFIRMED") throw new Error("Not editable")
if (confirmedCount >= required) throw new Error("Not editable")
const dl = t.deadline ? moment(t.deadline) : null
if (dl && dl.isValid() && dl.isBefore(moment())) throw new Error("Cannot delete expired")
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve())
})
},
async listAll(filter = "all") {
const ssbClient = await openSsb()
const messages = await getAllMessages(ssbClient)
const idx = buildIndex(messages)
const out = []
for (const tipId of idx.tipByRoot.values()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node) continue
out.push(buildTransfer(node))
}
return out
},
async getTransferById(id) {
const ssbClient = await openSsb()
const messages = await getAllMessages(ssbClient)
const idx = buildIndex(messages)
let tip = id
while (idx.child.has(tip)) tip = idx.child.get(tip)
if (idx.tomb.has(tip)) throw new Error("Not found")
const node = idx.nodes.get(tip)
if (node) return buildTransfer(node)
const msg = await getMsg(ssbClient, tip)
if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
const tmpNode = { key: tip, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
return buildTransfer(tmpNode)
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category")
const ssbClient = await openSsb()
const userId = ssbClient.id
const tipId = await this.resolveCurrentId(id)
const msg = await getMsg(ssbClient, tipId)
if (!msg?.content || msg.content.type !== "transfer") throw new Error("Transfer not found")
const t = msg.content
const voters = Array.isArray(t.opinions_inhabitants) ? t.opinions_inhabitants : []
if (voters.includes(userId)) throw new Error("Already voted")
const updated = {
...t,
opinions: {
...(t.opinions || {}),
[category]: ((t.opinions || {})[category] || 0) + 1
},
opinions_inhabitants: voters.concat(userId),
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => e ? rej(e) : res()))
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (e2, result) => e2 ? reject(e2) : resolve(result))
})
}
}
}

View file

@ -0,0 +1,193 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const opinionCategories = require('../backend/opinion_categories');
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const hasBlob = async (ssbClient, url) => {
return new Promise(resolve => {
ssbClient.blobs.has(url, (err, has) => resolve(!err && has));
});
};
const types = [
'bookmark', 'votes', 'feed',
'image', 'audio', 'video', 'document', 'transfer'
];
const categories = opinionCategories;
const listTrending = async (filter = 'ALL') => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, xs) => err ? rej(err) : res(xs))
);
});
const tombstoned = new Set();
const replaces = new Map();
const itemsById = new Map();
for (const m of messages) {
const k = m.key;
const c = m.value?.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
continue;
}
if (c.opinions && !tombstoned.has(k) && !['task', 'event', 'report'].includes(c.type)) {
if (c.replaces) replaces.set(c.replaces, k);
itemsById.set(k, m);
}
}
for (const replacedId of replaces.keys()) {
itemsById.delete(replacedId);
}
let rawItems = Array.from(itemsById.values());
const blobTypes = ['document', 'image', 'audio', 'video'];
let items = await Promise.all(
rawItems.map(async m => {
const c = m.value?.content;
if (blobTypes.includes(c.type) && c.url) {
const valid = await hasBlob(ssbClient, c.url);
if (!valid) return null;
}
return m;
})
);
items = items.filter(Boolean);
const signatureOf = (m) => {
const c = m.value?.content || {};
switch (c.type) {
case 'document':
case 'image':
case 'audio':
case 'video':
return `${c.type}::${(c.url || '').trim()}`;
case 'bookmark':
return `bookmark::${(c.url || '').trim().toLowerCase()}`;
case 'feed':
return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
case 'votes':
return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
case 'transfer':
return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
default:
return `key::${m.key}`;
}
};
const bySig = new Map();
for (const m of items) {
const sig = signatureOf(m);
const prev = bySig.get(sig);
if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
bySig.set(sig, m);
}
}
items = Array.from(bySig.values());
if (filter === 'MINE') {
items = items.filter(m => m.value.author === userId);
} else if (filter === 'RECENT') {
const now = Date.now();
items = items.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
}
if (types.includes(filter)) {
items = items.filter(m => m.value.content.type === filter);
}
if (filter !== 'ALL' && !types.includes(filter)) {
items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0);
}
if (filter === 'TOP') {
items.sort((a, b) => {
const aLen = (a.value.content.opinions_inhabitants || []).length;
const bLen = (b.value.content.opinions_inhabitants || []).length;
if (bLen !== aLen) return bLen - aLen;
return b.value.timestamp - a.value.timestamp;
});
} else {
items.sort((a, b) => {
const aLen = (a.value.content.opinions_inhabitants || []).length;
const bLen = (b.value.content.opinions_inhabitants || []).length;
return bLen - aLen;
});
}
return { filtered: items };
};
const getMessageById = async (id) => {
const ssbClient = await openSsb();
return new Promise((res, rej) => {
ssbClient.get(id, (err, msg) => err ? rej(err) : res(msg));
});
};
const createVote = async (contentId, category) => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
if (!categories.includes(category)) throw new Error('Invalid voting category');
const msg = await getMessageById(contentId);
if (!msg || !msg.content) throw new Error('Content not found');
const type = msg.content.type;
if (!types.includes(type) || ['task', 'event', 'report'].includes(type)) {
throw new Error('Voting not allowed on this content type');
}
const inhabitants = Array.isArray(msg.content.opinions_inhabitants) ? msg.content.opinions_inhabitants : [];
if (inhabitants.includes(userId)) throw new Error('Already voted');
const tombstone = {
type: 'tombstone',
target: contentId,
deletedAt: new Date().toISOString(),
author: userId
};
const updated = {
...msg.content,
opinions: {
...(msg.content.opinions || {}),
[category]: ((msg.content.opinions && msg.content.opinions[category]) || 0) + 1
},
opinions_inhabitants: inhabitants.concat(userId),
updatedAt: new Date().toISOString(),
replaces: contentId
};
await new Promise((res, rej) => {
ssbClient.publish(tombstone, err => err ? rej(err) : res());
});
return new Promise((res, rej) => {
ssbClient.publish(updated, (err, result) => err ? rej(err) : res(result));
});
};
return { listTrending, getMessageById, createVote, types, categories };
};

View file

@ -0,0 +1,288 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia'];
const categories = require('../backend/opinion_categories');
const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS'];
const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const TYPE = 'tribe-content';
const publish = async (content) => {
const ssbClient = await openSsb();
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, result) => err ? reject(err) : resolve(result))
);
};
const readLog = async () => {
const ssbClient = await openSsb();
return new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
);
};
const buildIndex = (msgs, tribeId, contentType) => {
const tombstoned = new Set();
const replaced = new Map();
const items = new Map();
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 !== TYPE) continue;
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 id of tombstoned) items.delete(id);
for (const oldId of replaced.keys()) items.delete(oldId);
return [...items.values()].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;
});
};
return {
async create(tribeId, contentType, data) {
if (!VALID_CONTENT_TYPES.includes(contentType)) {
throw new Error('Invalid content type');
}
if (data.status && !VALID_STATUSES.includes(data.status)) {
throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
}
if (data.priority && !VALID_PRIORITIES.includes(data.priority)) {
throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
}
const ssbClient = await openSsb();
const now = new Date().toISOString();
const content = {
type: TYPE,
tribeId,
contentType,
title: data.title || '',
description: data.description || '',
status: data.status || 'OPEN',
date: data.date || null,
location: data.location || null,
price: data.price || null,
salary: data.salary || null,
priority: data.priority || null,
assignees: data.assignees || [],
options: data.options || [],
votes: data.votes || {},
category: data.category || null,
parentId: data.parentId || null,
tags: data.tags || [],
image: data.image || null,
mediaType: data.mediaType || null,
url: data.url || null,
attendees: data.attendees || [],
deadline: data.deadline || null,
goal: data.goal || null,
funded: data.funded || 0,
refeeds: data.refeeds || 0,
refeeds_inhabitants: data.refeeds_inhabitants || [],
opinions: data.opinions || {},
opinions_inhabitants: data.opinions_inhabitants || [],
author: ssbClient.id,
createdAt: now,
updatedAt: now,
};
return publish(content);
},
async update(contentId, data, existing) {
if (!existing) existing = await this.getById(contentId);
if (!existing) throw new Error('Content not found');
if (data.status && !VALID_STATUSES.includes(data.status)) {
throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
}
if (data.priority && !VALID_PRIORITIES.includes(data.priority)) {
throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
}
const now = new Date().toISOString();
const updated = {
type: TYPE,
replaces: contentId,
tribeId: existing.tribeId,
contentType: existing.contentType,
title: data.title !== undefined ? data.title : existing.title,
description: data.description !== undefined ? data.description : existing.description,
status: data.status !== undefined ? data.status : existing.status,
date: data.date !== undefined ? data.date : existing.date,
location: data.location !== undefined ? data.location : existing.location,
price: data.price !== undefined ? data.price : existing.price,
salary: data.salary !== undefined ? data.salary : existing.salary,
priority: data.priority !== undefined ? data.priority : existing.priority,
assignees: data.assignees !== undefined ? data.assignees : existing.assignees,
options: data.options !== undefined ? data.options : existing.options,
votes: data.votes !== undefined ? data.votes : existing.votes,
category: data.category !== undefined ? data.category : existing.category,
parentId: data.parentId !== undefined ? data.parentId : existing.parentId,
tags: data.tags !== undefined ? data.tags : existing.tags,
image: data.image !== undefined ? data.image : existing.image,
mediaType: data.mediaType !== undefined ? data.mediaType : existing.mediaType,
url: data.url !== undefined ? data.url : existing.url,
attendees: data.attendees !== undefined ? data.attendees : existing.attendees,
deadline: data.deadline !== undefined ? data.deadline : existing.deadline,
goal: data.goal !== undefined ? data.goal : existing.goal,
funded: data.funded !== undefined ? data.funded : existing.funded,
refeeds: data.refeeds !== undefined ? data.refeeds : existing.refeeds,
refeeds_inhabitants: data.refeeds_inhabitants !== undefined ? data.refeeds_inhabitants : existing.refeeds_inhabitants,
opinions: data.opinions !== undefined ? data.opinions : existing.opinions,
opinions_inhabitants: data.opinions_inhabitants !== undefined ? data.opinions_inhabitants : existing.opinions_inhabitants,
author: existing.author,
createdAt: existing.createdAt,
updatedAt: now,
};
return publish(updated);
},
async deleteById(contentId) {
const ssbClient = await openSsb();
return publish({
type: 'tombstone',
target: contentId,
deletedAt: new Date().toISOString(),
author: ssbClient.id,
});
},
async getById(contentId) {
const msgs = await readLog();
const tombstoned = new Set();
const replaced = new Map();
const items = new Map();
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 !== TYPE) continue;
if (c.replaces) replaced.set(c.replaces, m.key);
items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
}
let latestId = contentId;
while (replaced.has(latestId)) latestId = replaced.get(latestId);
if (tombstoned.has(latestId)) return null;
return items.get(latestId) || null;
},
async listByTribe(tribeId, contentType, filter) {
const msgs = await readLog();
let items = buildIndex(msgs, tribeId, contentType);
if (filter === 'open') items = items.filter(i => i.status === 'OPEN');
if (filter === 'closed') items = items.filter(i => i.status === 'CLOSED');
if (filter === 'in-progress') items = items.filter(i => i.status === 'IN-PROGRESS');
return items;
},
async toggleAttendee(contentId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const item = await this.getById(contentId);
if (!item) throw new Error('Content not found');
const attendees = Array.isArray(item.attendees) ? [...item.attendees] : [];
const idx = attendees.indexOf(userId);
if (idx === -1) attendees.push(userId);
else attendees.splice(idx, 1);
return this.update(contentId, { attendees }, item);
},
async toggleAssignee(contentId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const item = await this.getById(contentId);
if (!item) throw new Error('Content not found');
const assignees = Array.isArray(item.assignees) ? [...item.assignees] : [];
const idx = assignees.indexOf(userId);
if (idx === -1) assignees.push(userId);
else assignees.splice(idx, 1);
return this.update(contentId, { assignees }, item);
},
async updateStatus(contentId, status) {
if (!VALID_STATUSES.includes(status)) {
throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
}
return this.update(contentId, { status });
},
async castVote(votationId, optionIndex) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const item = await this.getById(votationId);
if (!item) throw new Error('Votation not found');
if (item.status === 'CLOSED') throw new Error('Votation is closed');
const options = item.options || [];
if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= options.length) {
throw new Error('Invalid option index');
}
const votes = item.votes || {};
for (const key of Object.keys(votes)) {
const arr = Array.isArray(votes[key]) ? votes[key] : [];
if (arr.includes(userId)) throw new Error('Already voted');
}
const key = String(optionIndex);
if (!votes[key]) votes[key] = [];
votes[key].push(userId);
return this.update(votationId, { votes }, item);
},
async toggleRefeed(contentId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const item = await this.getById(contentId);
if (!item) throw new Error('Content not found');
const inhabitants = Array.isArray(item.refeeds_inhabitants) ? [...item.refeeds_inhabitants] : [];
if (inhabitants.includes(userId)) return item;
inhabitants.push(userId);
return this.update(contentId, { refeeds: (item.refeeds || 0) + 1, refeeds_inhabitants: inhabitants }, item);
},
async castOpinion(contentId, category) {
if (!categories.includes(category)) throw new Error('Invalid opinion category');
const ssbClient = await openSsb();
const userId = ssbClient.id;
const item = await this.getById(contentId);
if (!item) throw new Error('Content not found');
const inhabitants = Array.isArray(item.opinions_inhabitants) ? [...item.opinions_inhabitants] : [];
if (inhabitants.includes(userId)) throw new Error('Already voted');
inhabitants.push(userId);
const opinions = { ...(item.opinions || {}), [category]: (item.opinions?.[category] || 0) + 1 };
return this.update(contentId, { opinions, opinions_inhabitants: inhabitants }, item);
},
async getThread(forumId) {
const msgs = await readLog();
const allItems = buildIndex(msgs, null, null);
const parent = allItems.find(i => i.id === forumId);
if (!parent) return { parent: null, replies: [] };
const replies = allItems
.filter(i => i.parentId === forumId && i.contentType === 'forum-reply')
.sort((a, b) => {
const ta = Date.parse(a.createdAt) || 0;
const tb = Date.parse(b.createdAt) || 0;
return ta - tb;
});
return { parent, replies };
},
};
};

View file

@ -0,0 +1,290 @@
const pull = require('../server/node_modules/pull-stream');
const crypto = require('crypto');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const INVITE_CODE_BYTES = 16;
const VALID_INVITE_MODES = ['strict', 'open'];
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
let tribeIndex = null;
let tribeIndexTs = 0;
const buildTribeIndex = async () => {
if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
const client = await openSsb();
return new Promise((resolve, reject) => {
pull(
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();
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; }
if (c.type !== 'tribe') continue;
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
tribes.set(k, { id: k, content: c, _ts: msg.value?.timestamp });
}
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; };
const tipByRoot = new Map();
for (const k of tribes.keys()) {
const root = rootOf(k);
const tip = tipOf(root);
tipByRoot.set(root, tip);
}
tribeIndex = { tribes, tombstoned, parent, child, tipByRoot };
tribeIndexTs = Date.now();
resolve(tribeIndex);
})
);
});
};
return {
type: 'tribe',
async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN') {
if (!VALID_INVITE_MODES.includes(inviteMode)) {
throw new Error('Invalid invite mode. Must be "strict" or "open"');
}
const ssb = await openSsb();
const userId = ssb.id;
let blobId = null;
if (image) {
blobId = String(image).trim() || null;
}
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
const content = {
type: 'tribe',
title,
description,
image: blobId,
location,
tags,
isLARP: Boolean(isLARP),
isAnonymous: Boolean(isAnonymous),
members: [userId],
invites: [],
inviteMode,
status: status || 'OPEN',
parentTribeId: parentTribeId || null,
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)));
tribeIndex = null;
return result;
},
async generateInvite(tribeId) {
const ssb = await openSsb();
const userId = ssb.id;
const tribe = await this.getTribeById(tribeId);
if (tribe.inviteMode === 'strict' && tribe.author !== userId) {
throw new Error('Only the author can generate invites in strict mode');
}
if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
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];
await this.updateTribeInvites(tribeId, invites);
return code;
},
async updateTribeInvites(tribeId, invites) {
return this.updateTribeById(tribeId, { invites });
},
async leaveTribe(tribeId) {
const ssb = await openSsb();
const userId = ssb.id;
const tribe = await this.getTribeById(tribeId);
if (!tribe) throw new Error('Tribe not found');
if (tribe.author === userId) {
throw new Error('Tribe author cannot leave their own tribe');
}
const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
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 });
},
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)) {
throw new Error('Already a member of this tribe');
}
const members = [...tribe.members, userId];
const invites = tribe.invites.filter(c => c !== code);
await this.updateTribeById(tribe.id, { members, invites });
return tribe.id;
},
async deleteTribeById(tribeId) {
await this.publishTombstone(tribeId);
},
async updateTribeMembers(tribeId, members) {
return this.updateTribeById(tribeId, { members });
},
async publishUpdatedTribe(tribeId, updatedTribe) {
const ssb = await openSsb();
const updatedTribeData = {
type: 'tribe',
replaces: updatedTribe.replaces || tribeId,
title: updatedTribe.title,
description: updatedTribe.description,
image: updatedTribe.image,
location: updatedTribe.location,
tags: updatedTribe.tags,
isLARP: updatedTribe.isLARP,
isAnonymous: updatedTribe.isAnonymous,
members: updatedTribe.members,
invites: updatedTribe.invites,
inviteMode: updatedTribe.inviteMode,
status: updatedTribe.status || 'OPEN',
parentTribeId: updatedTribe.parentTribeId || null,
createdAt: updatedTribe.createdAt,
updatedAt: new Date().toISOString(),
author: updatedTribe.author,
};
const result = await new Promise((resolve, reject) => {
ssb.publish(updatedTribeData, (err, result) => err ? reject(err) : resolve(result));
});
tribeIndex = null;
return result;
},
async getTribeById(tribeId) {
const { tribes, tombstoned, child } = await buildTribeIndex();
let latestId = tribeId;
while (child.has(latestId)) latestId = child.get(latestId);
if (tombstoned.has(latestId)) throw new Error('Tribe not found');
const tribe = tribes.get(latestId);
if (!tribe) throw new Error('Tribe not found');
return {
id: tribe.id,
title: tribe.content.title,
description: tribe.content.description,
image: tribe.content.image || null,
location: tribe.content.location,
tags: Array.isArray(tribe.content.tags) ? tribe.content.tags : [],
isLARP: !!tribe.content.isLARP,
isAnonymous: tribe.content.isAnonymous,
members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
inviteMode: tribe.content.inviteMode || 'strict',
status: tribe.content.status || 'OPEN',
parentTribeId: tribe.content.parentTribeId || null,
createdAt: tribe.content.createdAt,
updatedAt: tribe.content.updatedAt,
author: tribe.content.author,
};
},
async listAll() {
const { tribes, tombstoned, tipByRoot } = await buildTribeIndex();
const items = [];
for (const [root, tip] of tipByRoot) {
if (tombstoned.has(root) || tombstoned.has(tip)) continue;
const entry = tribes.get(tip);
if (!entry) continue;
const c = entry.content;
items.push({
id: tip,
title: c.title,
description: c.description,
image: c.image || null,
location: c.location,
tags: Array.isArray(c.tags) ? c.tags : [],
isLARP: !!c.isLARP,
isAnonymous: c.isAnonymous !== false,
members: Array.isArray(c.members) ? c.members : [],
invites: Array.isArray(c.invites) ? c.invites : [],
inviteMode: c.inviteMode || 'strict',
status: c.status || 'OPEN',
parentTribeId: c.parentTribeId || null,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
author: c.author,
_ts: entry._ts
});
}
return items;
},
async getChainIds(tribeId) {
const { parent, child } = await buildTribeIndex();
let root = tribeId;
while (parent.has(root)) root = parent.get(root);
const ids = [root];
let cur = root;
while (child.has(cur)) { cur = child.get(cur); ids.push(cur); }
return ids;
},
async updateTribeById(tribeId, updatedContent) {
const ssb = await openSsb();
const tribe = await this.getTribeById(tribeId);
if (!tribe) throw new Error('Tribe not found');
const updatedTribe = {
type: 'tribe',
...tribe,
...updatedContent,
replaces: tribeId,
updatedAt: new Date().toISOString()
};
return this.publishUpdatedTribe(tribeId, updatedTribe);
},
async publishTombstone(tribeId) {
const ssb = await openSsb();
const userId = ssb.id;
const tombstone = {
type: 'tombstone',
target: tribeId,
deletedAt: new Date().toISOString(),
author: userId
};
await new Promise((resolve, reject) => {
ssb.publish(tombstone, (err) => {
if (err) return reject(err);
resolve();
});
});
tribeIndex = null;
},
async listSubTribes(parentId) {
const idx = await buildTribeIndex();
const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
const parentRoot = rootOf(parentId);
const all = await this.listAll();
return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
}
};
};

View file

@ -0,0 +1,327 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s || null;
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
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 !== "video") continue;
const ts = v.timestamp || m.timestamp || 0;
nodes.set(k, { key: k, ts, c });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
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;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, tipOf(r));
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
};
const buildVideo = (node, rootId, viewerId) => {
const c = node.c || {};
const voters = safeArr(c.opinions_inhabitants);
return {
key: node.key,
rootId,
url: 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 || "",
opinions: c.opinions || {},
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false
};
};
return {
type: "video",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Video not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Video not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createVideo(blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const content = {
type: "video",
url: blobId,
createdAt: now,
updatedAt: now,
author: ssbClient.id,
tags,
title: title || "",
description: description || "",
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateVideoById(id, blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "video") throw new Error("Video not found");
if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit video after it has received opinions.");
if (oldMsg.content.author !== userId) throw new Error("Not the author");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "",
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
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)));
});
},
async deleteVideoById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "video") throw new Error("Video not found");
if (msg.content.author !== userId) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const sort = String(opts.sort || "recent").trim();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildVideo(node, rootId, viewerId));
}
let list = items;
const now = Date.now();
if (filter === "mine") list = list.filter((v) => String(v.author) === String(viewerId));
else if (filter === "recent") list = list.filter((v) => new Date(v.createdAt).getTime() >= now - 86400000);
else if (filter === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
}
if (q) {
list = list.filter((v) => {
const t = String(v.title || "").toLowerCase();
const d = String(v.description || "").toLowerCase();
const tags = safeArr(v.tags).join(" ").toLowerCase();
const a = String(v.author || "").toLowerCase();
return t.includes(q) || d.includes(q) || tags.includes(q) || a.includes(q);
});
}
if (sort === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getVideoById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Video not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
const node = idx.nodes.get(tip);
if (node) return buildVideo(node, root, viewer);
const msg = await getMsg(ssbClient, tip);
if (!msg || msg.content?.type !== "video") throw new Error("Video not found");
return buildVideo({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "video") throw new Error("Video not found");
const voters = safeArr(msg.content.opinions_inhabitants);
if (voters.includes(userId)) throw new Error("Already voted");
const now = new Date().toISOString();
const updated = {
...msg.content,
replaces: tipId,
opinions: {
...msg.content.opinions,
[category]: (msg.content.opinions?.[category] || 0) + 1
},
opinions_inhabitants: voters.concat(userId),
updatedAt: now
};
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, (err2, result) => (err2 ? reject(err2) : resolve(result)));
});
}
};
};

View file

@ -0,0 +1,414 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js');
const categories = require('../backend/opinion_categories');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const TYPE = 'votes';
async function getAllMessages(ssbClient) {
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, results) => (err ? reject(err) : resolve(results)))
);
});
}
function buildIndex(messages) {
const tombstoned = new Set();
const replaced = new Map();
const votes = new Map();
const parent = new Map();
for (const m of messages) {
const key = m.key;
const v = m.value;
const c = v && v.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
continue;
}
if (c.type !== TYPE) continue;
const node = {
key,
ts: v.timestamp || m.timestamp || 0,
content: c
};
votes.set(key, node);
if (c.replaces) {
replaced.set(c.replaces, key);
parent.set(key, c.replaces);
}
}
return { tombstoned, replaced, votes, parent };
}
function statusFromContent(content, now) {
const raw = String(content.status || 'OPEN').toUpperCase();
if (raw === 'OPEN') {
const dl = content.deadline ? moment(content.deadline) : null;
if (dl && dl.isValid() && dl.isBefore(now)) return 'CLOSED';
}
return raw;
}
function computeActiveVotes(index) {
const { tombstoned, replaced, votes, parent } = index;
const active = new Map(votes);
tombstoned.forEach(id => active.delete(id));
replaced.forEach((_, oldId) => active.delete(oldId));
const rootOf = id => {
let cur = id;
while (parent.has(cur)) cur = parent.get(cur);
return cur;
};
const groups = new Map();
for (const [id, node] of active.entries()) {
const root = rootOf(id);
if (!groups.has(root)) groups.set(root, []);
groups.get(root).push(node);
}
const now = moment();
const result = [];
for (const nodes of groups.values()) {
if (!nodes.length) continue;
let best = nodes[0];
let bestStatus = statusFromContent(best.content, now);
for (let i = 1; i < nodes.length; i++) {
const candidate = nodes[i];
const cStatus = statusFromContent(candidate.content, now);
if (cStatus === bestStatus) {
const bestTime = new Date(best.content.updatedAt || best.content.createdAt || best.ts || 0);
const cTime = new Date(candidate.content.updatedAt || candidate.content.createdAt || candidate.ts || 0);
if (cTime > bestTime) {
best = candidate;
bestStatus = cStatus;
}
} else if (cStatus === 'CLOSED' && bestStatus !== 'CLOSED') {
best = candidate;
bestStatus = cStatus;
} else if (cStatus === 'OPEN' && bestStatus !== 'OPEN') {
best = candidate;
bestStatus = cStatus;
}
}
result.push({
id: best.key,
latestId: best.key,
...best.content,
status: bestStatus
});
}
return result;
}
async function resolveCurrentId(voteId) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const forward = new Map();
for (const m of messages) {
const c = m.value && m.value.content;
if (!c) continue;
if (c.type === TYPE && c.replaces) {
forward.set(c.replaces, m.key);
}
}
let cur = voteId;
while (forward.has(cur)) cur = forward.get(cur);
return cur;
}
return {
async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const parsedDeadline = moment(deadline, moment.ISO_8601, true);
if (!parsedDeadline.isValid() || parsedDeadline.isBefore(moment())) throw new Error('Invalid deadline');
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
const content = {
type: TYPE,
question,
options,
deadline: parsedDeadline.toISOString(),
createdBy: userId,
status: 'OPEN',
votes: options.reduce((acc, opt) => {
acc[opt] = 0;
return acc;
}, {}),
totalVotes: 0,
voters: [],
tags,
opinions: {},
opinions_inhabitants: [],
createdAt: new Date().toISOString(),
updatedAt: null
};
return new Promise((res, rej) =>
ssbClient.publish(content, (err, msg) => (err ? rej(err) : res(msg)))
);
},
async deleteVoteById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await resolveCurrentId(id);
const vote = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
);
if (!vote.content || vote.content.createdBy !== userId) throw new Error('Not the author');
const tombstone = {
type: 'tombstone',
target: tipId,
deletedAt: new Date().toISOString(),
author: userId
};
return new Promise((res, rej) =>
ssbClient.publish(tombstone, (err, result) => (err ? rej(err) : res(result)))
);
},
async updateVoteById(id, payload) {
const { question, deadline, options, tags } = payload || {};
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await resolveCurrentId(id);
const oldMsg = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
);
const c = oldMsg.content;
if (!c || c.type !== TYPE) throw new Error('Invalid type');
if (c.createdBy !== userId) throw new Error('Not the author');
if (Object.keys(c.opinions || {}).length > 0) throw new Error('Cannot edit vote after it has received opinions.');
let newDeadline = c.deadline;
if (deadline != null && deadline !== '') {
const parsed = moment(deadline, moment.ISO_8601, true);
if (!parsed.isValid() || parsed.isBefore(moment())) throw new Error('Invalid deadline');
newDeadline = parsed.toISOString();
}
let newOptions = c.options || [];
let newVotesMap = c.votes || {};
let newTotalVotes = c.totalVotes || 0;
const optionsChanged = Array.isArray(options) && (
options.length !== newOptions.length ||
options.some((o, i) => o !== newOptions[i])
);
if (optionsChanged) {
if ((c.totalVotes || 0) > 0) {
throw new Error('Cannot change options after voting has started');
}
newOptions = options;
newVotesMap = newOptions.reduce((acc, opt) => {
acc[opt] = 0;
return acc;
}, {});
newTotalVotes = 0;
}
let newTags = c.tags || [];
if (Array.isArray(tags)) {
newTags = tags.filter(Boolean);
} else if (typeof tags === 'string') {
newTags = tags.split(',').map(t => t.trim()).filter(Boolean);
}
const updated = {
...c,
replaces: tipId,
question: question != null ? question : c.question,
deadline: newDeadline,
options: newOptions,
votes: newVotesMap,
totalVotes: newTotalVotes,
tags: newTags,
updatedAt: new Date().toISOString()
};
return new Promise((res, rej) =>
ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
);
},
async voteOnVote(id, choice) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await resolveCurrentId(id);
const vote = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
);
const content = vote.content || {};
const options = Array.isArray(content.options) ? content.options : [];
if (!options.includes(choice)) throw new Error('Invalid choice');
const voters = Array.isArray(content.voters) ? content.voters.slice() : [];
if (voters.includes(userId)) throw new Error('Already voted');
const votesMap = Object.assign({}, content.votes || {});
votesMap[choice] = (votesMap[choice] || 0) + 1;
voters.push(userId);
const totalVotes = (parseInt(content.totalVotes || 0, 10) || 0) + 1;
const tombstone = {
type: 'tombstone',
target: tipId,
deletedAt: new Date().toISOString(),
author: userId
};
const updated = {
...content,
votes: votesMap,
voters,
totalVotes,
updatedAt: new Date().toISOString(),
replaces: tipId
};
await new Promise((res, rej) =>
ssbClient.publish(tombstone, err => (err ? rej(err) : res()))
);
return new Promise((res, rej) =>
ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
);
},
async getVoteById(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const index = buildIndex(messages);
const activeList = computeActiveVotes(index);
const byId = new Map(activeList.map(v => [v.id, v]));
if (byId.has(id)) {
return byId.get(id);
}
const parent = index.parent;
const rootOf = key => {
let cur = key;
while (parent.has(cur)) cur = parent.get(cur);
return cur;
};
const root = rootOf(id);
const candidate = activeList.find(v => rootOf(v.id) === root);
if (candidate) {
return candidate;
}
const msg = await new Promise((res, rej) =>
ssbClient.get(id, (err, vote) => (err || !vote ? rej(new Error('Vote not found')) : res(vote)))
);
const content = msg.content || {};
const status = statusFromContent(content, moment());
return {
id,
latestId: id,
...content,
status
};
},
async listAll(filter = 'all') {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await getAllMessages(ssbClient);
const index = buildIndex(messages);
let list = computeActiveVotes(index);
if (filter === 'mine') {
list = list.filter(v => v.createdBy === userId);
} else if (filter === 'open') {
list = list.filter(v => v.status === 'OPEN');
} else if (filter === 'closed') {
list = list.filter(v => v.status === 'CLOSED');
}
return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error('Invalid voting category');
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await resolveCurrentId(id);
const vote = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
);
const content = vote.content || {};
const list = Array.isArray(content.opinions_inhabitants) ? content.opinions_inhabitants : [];
if (list.includes(userId)) throw new Error('Already voted');
const opinions = Object.assign({}, content.opinions || {});
opinions[category] = (opinions[category] || 0) + 1;
const tombstone = {
type: 'tombstone',
target: tipId,
deletedAt: new Date().toISOString(),
author: userId
};
const updated = {
...content,
opinions,
opinions_inhabitants: list.concat(userId),
updatedAt: new Date().toISOString(),
replaces: tipId
};
await new Promise((res, rej) =>
ssbClient.publish(tombstone, err => (err ? rej(err) : res()))
);
return new Promise((res, rej) =>
ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
);
}
};
};

View file

@ -0,0 +1,61 @@
const {
RequestManager,
HTTPTransport,
Client
} = require("../server/node_modules/@open-rpc/client-js");
async function makeClient(url, user, pass) {
const headers = {};
if (user !== undefined || pass !== undefined) {
headers['Authorization'] = 'Basic ' + Buffer.from(`${user || ''}:${pass || ''}`).toString('base64');
}
const transport = new HTTPTransport(url, { headers });
return new Client(new RequestManager([transport]));
}
module.exports = {
client: async (url, user, pass) => {
return makeClient(url, user, pass);
},
execute: async (url, user, pass, method, params = []) => {
try {
const clientrpc = await makeClient(url, user, pass);
return await clientrpc.request({ method, params });
} catch (error) {
throw new Error("ECOin wallet disconnected. Check your wallet settings or connection status.");
}
},
getBalance: async (url, user, pass) => {
return Number(await module.exports.execute(url, user, pass, "getbalance")) || 0;
},
getAddress: async (url, user, pass) => {
try {
const addrs = await module.exports.execute(url, user, pass, "getaddressesbyaccount", [""]);
if (Array.isArray(addrs) && addrs.length > 0) return addrs[0];
} catch {}
try {
const addr = await module.exports.execute(url, user, pass, "getnewaddress", [""]);
if (typeof addr === "string" && addr) return addr;
} catch {}
return "";
},
listTransactions: async (url, user, pass) => {
return await module.exports.execute(url, user, pass, "listtransactions", ["", 1000000, 0]);
},
sendToAddress: async (url, user, pass, address, amount) => {
return await module.exports.execute(url, user, pass, "sendtoaddress", [address, Number(amount)]);
},
validateSend: async (url, user, pass, address, amount, fee) => {
let isValid = false;
const errors = [];
const addrInfo = await module.exports.execute(url, user, pass, "validateaddress", [address]);
const addressValid = !!addrInfo?.isvalid;
const amountValid = Number(amount) > 0;
const feeValid = Number(fee) >= 0;
if (!addressValid) errors.push("invalid_dest");
if (!amountValid) errors.push("invalid_amount");
if (!feeValid) errors.push("invalid_fee");
if (errors.length === 0) isValid = true;
return { isValid, errors };
}
};

View file

@ -0,0 +1,102 @@
#!/usr/bin/env node
const moduleAlias = require('module-alias');
moduleAlias.addAlias('punycode', 'punycode/');
const fs = require('fs');
const path = require('path');
const SecretStack = require('secret-stack');
const caps = require('ssb-caps');
const SSB = require('ssb-db');
const config = require('./ssb_config');
const { printMetadata } = require('./ssb_metadata');
require('ssb-plugins').loadUserPlugins(SecretStack({ caps }), config);
const Server = SecretStack({ caps })
.use(SSB)
.use(require('ssb-master'))
.use(require('ssb-gossip'))
.use(require('ssb-ebt'))
.use(require('ssb-friends'))
.use(require('ssb-blobs'))
.use(require('ssb-lan'))
.use(require('ssb-meme'))
.use(require('ssb-plugins'))
.use(require('ssb-conn'))
.use(require('ssb-box'))
.use(require('ssb-search'))
.use(require('ssb-private'))
.use(require('ssb-friend-pub'))
.use(require('ssb-invite-client'))
.use(require('ssb-logging'))
.use(require('ssb-replication-scheduler'))
.use(require('ssb-partial-replication'))
.use(require('ssb-about'))
.use(require('ssb-onion'))
.use(require('ssb-unix-socket'))
.use(require('ssb-no-auth'))
.use(require('ssb-backlinks'))
.use(require('ssb-links'))
.use(require('ssb-tangle'))
.use(require('ssb-query'));
if (config.autofollow?.enabled !== false) {
Server.use(require('ssb-autofollow'));
}
const manifestFile = path.join(config.path, 'manifest.json');
let server;
const argv = process.argv.slice(2);
if (argv[0] === 'start') {
server = Server(config);
fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2));
const { cmdAliases } = require('../client/cli-cmd-aliases');
const manifest = server.getManifest();
for (const k in cmdAliases) {
server[k] = server[cmdAliases[k]];
manifest[k] = manifest[cmdAliases[k]];
}
manifest.config = 'sync';
server.config = cb => {
console.log(JSON.stringify(config, null, 2));
cb();
};
if (process.stdout.isTTY && config.logging?.level !== 'info') {
const showProgress = () => {
let prog = -1;
const bar = r => '\r' + '*'.repeat(Math.floor(r * 50)) + '.'.repeat(50 - Math.floor(r * 50));
const percent = r => (Math.round(r * 10000) / 100).toFixed(2) + '%';
const rate = prog => prog.target === prog.current ? 1 : (prog.current - prog.start) / (prog.target - prog.start);
const interval = setInterval(() => {
const p = server.progress();
let r = 1;
const tasks = [];
for (const k in p) {
const pr = rate(p[k]);
if (pr < 1) tasks.push(`${k}:${percent(pr)}`);
r = Math.min(r, pr);
}
if (r !== prog) {
prog = r;
process.stdout.write(bar(r) + ` (${tasks.join(', ')})\x1b[K\r`);
}
}, 333);
interval.unref?.();
};
showProgress();
}
const { printMetadata, colors } = require('./ssb_metadata');
printMetadata('OASIS Server Only', colors.cyan);
}
module.exports = {
config,
server: server || Server(config),
open: async () => server || Server(config)
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more