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>
1
nodejs-project/nodejs-project/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Oasis Mobile v0.6.5
|
||||
18
nodejs-project/nodejs-project/main.js
Normal 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);
|
||||
}
|
||||
199
nodejs-project/nodejs-project/package.json
Normal 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"
|
||||
}
|
||||
3249
nodejs-project/nodejs-project/src/backend/backend.js
Normal file
258
nodejs-project/nodejs-project/src/backend/blobHandler.js
Normal 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`;
|
||||
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 };
|
||||
|
||||
118
nodejs-project/nodejs-project/src/backend/media-favorites.js
Normal 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);
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
|
||||
`<img src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" alt="${alt}" class="post-image" />`
|
||||
)
|
||||
.replace(/\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
|
||||
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}"></video>`
|
||||
)
|
||||
.replace(/\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
|
||||
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}"></audio>`
|
||||
)
|
||||
.replace(/\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
|
||||
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&/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 }
|
||||
92
nodejs-project/nodejs-project/src/backend/renderUrl.js
Normal 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 };
|
||||
52
nodejs-project/nodejs-project/src/backend/sanitizeHtml.js
Normal 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 };
|
||||
121
nodejs-project/nodejs-project/src/backend/updater.js
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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 };
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
4191
nodejs-project/nodejs-project/src/client/assets/styles/style.css
Normal 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; }
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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;
|
||||
10
nodejs-project/nodejs-project/src/client/cli-cmd-aliases.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
feed: 'createFeedStream',
|
||||
history: 'createHistoryStream',
|
||||
hist: 'createHistoryStream',
|
||||
public: 'getPublicKey',
|
||||
pub: 'getPublicKey',
|
||||
log: 'createLogStream',
|
||||
logt: 'messagesByType',
|
||||
conf: 'config',
|
||||
};
|
||||
124
nodejs-project/nodejs-project/src/client/gui.js
Normal 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;
|
||||
};
|
||||
122
nodejs-project/nodejs-project/src/client/middleware.js
Normal 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;
|
||||
};
|
||||
|
||||
85
nodejs-project/nodejs-project/src/client/oasis_client.js
Normal 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 };
|
||||
|
||||
115
nodejs-project/nodejs-project/src/client/public/docs/ecoin.md
Normal 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
|
||||
|
||||
======================================
|
||||
|
||||
|
||||
|
|
@ -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'}`);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"discardedItems": []
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"cycle": 4,
|
||||
"url": "https://laplaza.solarnethub.com"
|
||||
}
|
||||
84
nodejs-project/nodejs-project/src/configs/config-manager.js
Normal 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,
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"audios": [],
|
||||
"bookmarks": [],
|
||||
"documents": [],
|
||||
"images": [],
|
||||
"videos": []
|
||||
}
|
||||
62
nodejs-project/nodejs-project/src/configs/oasis-config.json
Normal 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"
|
||||
}
|
||||
63
nodejs-project/nodejs-project/src/configs/server-config.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
14
nodejs-project/nodejs-project/src/configs/shared-state.js
Normal 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; }
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
520
nodejs-project/nodejs-project/src/models/activity_model.js
Normal 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
236
nodejs-project/nodejs-project/src/models/agenda_model.js
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
320
nodejs-project/nodejs-project/src/models/audios_model.js
Normal 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)));
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
842
nodejs-project/nodejs-project/src/models/banking_model.js
Normal 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
|
||||
};
|
||||
};
|
||||
|
||||
328
nodejs-project/nodejs-project/src/models/blockchain_model.js
Normal 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 };
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
332
nodejs-project/nodejs-project/src/models/bookmarking_model.js
Normal 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)));
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
51
nodejs-project/nodejs-project/src/models/cipher_model.js
Normal 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
|
||||
};
|
||||
625
nodejs-project/nodejs-project/src/models/courts_model.js
Normal 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
|
||||
};
|
||||
};
|
||||
|
||||
173
nodejs-project/nodejs-project/src/models/cv_model.js
Normal 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 });
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
350
nodejs-project/nodejs-project/src/models/documents_model.js
Normal 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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
240
nodejs-project/nodejs-project/src/models/events_model.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
52
nodejs-project/nodejs-project/src/models/exportmode_model.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
143
nodejs-project/nodejs-project/src/models/favorites_model.js
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
330
nodejs-project/nodejs-project/src/models/feed_model.js
Normal 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 };
|
||||
};
|
||||
|
||||
288
nodejs-project/nodejs-project/src/models/forum_model.js
Normal 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
|
||||
};
|
||||
};
|
||||
|
||||
332
nodejs-project/nodejs-project/src/models/images_model.js
Normal 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)));
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
360
nodejs-project/nodejs-project/src/models/inhabitants_model.js
Normal 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: '2w–6m' };
|
||||
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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
473
nodejs-project/nodejs-project/src/models/jobs_model.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
85
nodejs-project/nodejs-project/src/models/legacy_model.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
1950
nodejs-project/nodejs-project/src/models/main_models.js
Normal file
625
nodejs-project/nodejs-project/src/models/market_model.js
Normal 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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
206
nodejs-project/nodejs-project/src/models/opinions_model.js
Normal 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
|
||||
};
|
||||
};
|
||||
|
||||
15
nodejs-project/nodejs-project/src/models/panicmode_model.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
1257
nodejs-project/nodejs-project/src/models/parliament_model.js
Normal file
136
nodejs-project/nodejs-project/src/models/pixelia_model.js
Normal 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
|
||||
};
|
||||
};
|
||||
124
nodejs-project/nodejs-project/src/models/pm_model.js
Normal 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));
|
||||
}
|
||||
};
|
||||
};
|
||||
525
nodejs-project/nodejs-project/src/models/projects_model.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
250
nodejs-project/nodejs-project/src/models/reports_model.js
Normal 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()]);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
287
nodejs-project/nodejs-project/src/models/search_model.js
Normal 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 };
|
||||
};
|
||||
|
||||
387
nodejs-project/nodejs-project/src/models/stats_model.js
Normal 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 };
|
||||
};
|
||||
|
||||
170
nodejs-project/nodejs-project/src/models/tags_model.js
Normal 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
216
nodejs-project/nodejs-project/src/models/tasks_model.js
Normal 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()]);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
355
nodejs-project/nodejs-project/src/models/transfers_model.js
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
193
nodejs-project/nodejs-project/src/models/trending_model.js
Normal 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 };
|
||||
};
|
||||
|
||||
288
nodejs-project/nodejs-project/src/models/tribes_content_model.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
};
|
||||
290
nodejs-project/nodejs-project/src/models/tribes_model.js
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
327
nodejs-project/nodejs-project/src/models/videos_model.js
Normal 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)));
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
414
nodejs-project/nodejs-project/src/models/votes_model.js
Normal 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)))
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
61
nodejs-project/nodejs-project/src/models/wallet_model.js
Normal 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 };
|
||||
}
|
||||
};
|
||||
102
nodejs-project/nodejs-project/src/server/SSB_server.js
Normal 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)
|
||||
};
|
||||