Oasis 0.8.7 mobile (nodejs-project baseline)
This commit is contained in:
commit
e563b86ede
226 changed files with 141968 additions and 0 deletions
8158
src/backend/backend.js
Normal file
8158
src/backend/backend.js
Normal file
File diff suppressed because it is too large
Load diff
279
src/backend/blobHandler.js
Normal file
279
src/backend/blobHandler.js
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
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',
|
||||
'.torrent': 'application/x-bittorrent'
|
||||
};
|
||||
|
||||
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/') && blob.mime !== 'image/gif') {
|
||||
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})`;
|
||||
if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`;
|
||||
|
||||
return `\n[${blob.name}](${blob.id})`;
|
||||
};
|
||||
|
||||
function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
|
||||
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, 8000);
|
||||
} 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 {}
|
||||
|
||||
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
|
||||
const head = buffer.slice(0, 128).toString('ascii');
|
||||
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
|
||||
}
|
||||
|
||||
if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') {
|
||||
let head = buffer.slice(0, 512).toString('utf8');
|
||||
if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1);
|
||||
const trimmed = head.replace(/^\s+/, '').toLowerCase();
|
||||
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<svg')) {
|
||||
if (trimmed.includes('<svg')) mime = 'image/svg+xml';
|
||||
}
|
||||
}
|
||||
|
||||
const isSvg = mime === 'image/svg+xml';
|
||||
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
|
||||
const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
|
||||
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
|
||||
const disposition = isSvg ? 'attachment' : 'inline';
|
||||
ctx.type = mime;
|
||||
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
|
||||
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 };
|
||||
|
||||
66
src/backend/collab_content.js
Normal file
66
src/backend/collab_content.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
const { mergeDuplicatesBy, norm } = require('./dedupe');
|
||||
|
||||
const identitySignature = (x) => (x && x.author && x.createdAt) ? norm(x.author) + '|' + norm(x.createdAt) : null;
|
||||
|
||||
const openInviteOf = (item) => {
|
||||
if (!item || !Array.isArray(item.invites)) return null;
|
||||
const pub = item.invites.find((inv) => inv && typeof inv === 'object' && inv.public === true && inv.code);
|
||||
return pub ? { code: pub.code } : null;
|
||||
};
|
||||
|
||||
const collabContent = (opts = {}) => {
|
||||
const membersField = opts.membersField || 'members';
|
||||
const undecField = opts.undecField || 'undecryptable';
|
||||
const contentFields = Array.isArray(opts.contentFields) ? opts.contentFields : [];
|
||||
const listFields = Array.isArray(opts.listFields) ? opts.listFields : [];
|
||||
const arr = (v) => (Array.isArray(v) ? v : []);
|
||||
|
||||
const freshestMembers = (base, dup) => {
|
||||
const bt = new Date(base.updatedAt || base.createdAt || 0).getTime();
|
||||
const dt = new Date(dup.updatedAt || dup.createdAt || 0).getTime();
|
||||
const fresh = dt >= bt ? dup : base;
|
||||
return arr(fresh[membersField]).length ? fresh[membersField]
|
||||
: (arr(base[membersField]).length ? base[membersField] : arr(dup[membersField]));
|
||||
};
|
||||
|
||||
const mergePair = (base, dup) => {
|
||||
const out = { ...base, [membersField]: freshestMembers(base, dup) };
|
||||
for (const f of contentFields) out[f] = base[f] || dup[f];
|
||||
for (const f of listFields) out[f] = arr(base[f]).length ? base[f] : arr(dup[f]);
|
||||
out[undecField] = !!(base[undecField] && dup[undecField]);
|
||||
return out;
|
||||
};
|
||||
|
||||
const orderForMerge = (a, b) => {
|
||||
const c = new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
|
||||
if (c !== 0) return c;
|
||||
return ((a[undecField] || !a.title) ? 1 : 0) - ((b[undecField] || !b.title) ? 1 : 0);
|
||||
};
|
||||
|
||||
const collapse = (items) =>
|
||||
mergeDuplicatesBy(arr(items).slice().sort(orderForMerge), identitySignature, mergePair);
|
||||
|
||||
const fold = (base, all) => {
|
||||
const sig = identitySignature(base);
|
||||
if (!sig) return base;
|
||||
const group = arr(all).filter((x) => identitySignature(x) === sig);
|
||||
if (group.length <= 1) return base;
|
||||
const canonical = collapse(group)[0];
|
||||
const out = { ...base, [membersField]: canonical[membersField] };
|
||||
for (const f of contentFields) out[f] = base[f] || canonical[f];
|
||||
for (const f of listFields) out[f] = arr(base[f]).length ? base[f] : arr(canonical[f]);
|
||||
return out;
|
||||
};
|
||||
|
||||
const isVisible = (item, viewerId) => {
|
||||
if (!item[undecField]) return true;
|
||||
return item.author === viewerId || arr(item[membersField]).includes(viewerId);
|
||||
};
|
||||
|
||||
const visibleThenCollapsed = (items, viewerId) =>
|
||||
collapse(arr(items).filter((x) => isVisible(x, viewerId)));
|
||||
|
||||
return { identitySignature, mergePair, collapse, fold, isVisible, visibleThenCollapsed };
|
||||
};
|
||||
|
||||
module.exports = { collabContent, identitySignature, openInviteOf };
|
||||
52
src/backend/dedupe.js
Normal file
52
src/backend/dedupe.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
|
||||
|
||||
const dedupeBy = (items, keyFn) => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const it of (Array.isArray(items) ? items : [])) {
|
||||
let k = null;
|
||||
try { k = keyFn(it); } catch (_) { k = null; }
|
||||
if (k == null || k === '') { out.push(it); continue; }
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(it);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const mergeDuplicatesBy = (items, keyFn, mergeFn) => {
|
||||
const slots = new Map();
|
||||
const out = [];
|
||||
for (const it of (Array.isArray(items) ? items : [])) {
|
||||
let k = null;
|
||||
try { k = keyFn(it); } catch (_) { k = null; }
|
||||
if (k == null || k === '') { out.push(it); continue; }
|
||||
if (!slots.has(k)) {
|
||||
const slot = { i: out.length, v: it };
|
||||
slots.set(k, slot);
|
||||
out.push(it);
|
||||
} else {
|
||||
const slot = slots.get(k);
|
||||
slot.v = mergeFn(slot.v, it);
|
||||
out[slot.i] = slot.v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const dedupeByPreferring = (items, keyFn, scoreFn) => {
|
||||
const best = new Map();
|
||||
const order = [];
|
||||
for (const it of (Array.isArray(items) ? items : [])) {
|
||||
let k = null;
|
||||
try { k = keyFn(it); } catch (_) { k = null; }
|
||||
if (k == null || k === '') { order.push({ solo: it }); continue; }
|
||||
let s = 0;
|
||||
try { s = Number(scoreFn(it)) || 0; } catch (_) { s = 0; }
|
||||
if (!best.has(k)) { const slot = { v: it, s }; best.set(k, slot); order.push({ slot }); }
|
||||
else { const slot = best.get(k); if (s > slot.s) { slot.v = it; slot.s = s; } }
|
||||
}
|
||||
return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v));
|
||||
};
|
||||
|
||||
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm };
|
||||
206
src/backend/logsPdf.js
Normal file
206
src/backend/logsPdf.js
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
|
||||
|
||||
const splitSegments = (line) => {
|
||||
const segs = [];
|
||||
let last = 0;
|
||||
const re = new RegExp(linkPattern.source, 'g');
|
||||
let m;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
|
||||
segs.push({ t: m[0], l: true });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < line.length) segs.push({ t: line.slice(last), l: false });
|
||||
return segs;
|
||||
};
|
||||
|
||||
const wrap = (txt, max = 82) => {
|
||||
const out = [];
|
||||
for (const raw of String(txt || '').split('\n')) {
|
||||
let line = raw;
|
||||
while (line.length > max) {
|
||||
let cut = line.lastIndexOf(' ', max);
|
||||
if (cut <= 0) cut = max;
|
||||
out.push(line.slice(0, cut));
|
||||
line = line.slice(cut).replace(/^\s+/, '');
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const readJpegDims = (buf) => {
|
||||
let i = 2;
|
||||
while (i < buf.length) {
|
||||
if (buf[i] !== 0xFF) return null;
|
||||
const marker = buf[i + 1];
|
||||
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
const c = buf[i + 9];
|
||||
return { w, h, c };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function buildLogsPdf(entries, oasisId, opts = {}) {
|
||||
const pageW = 612;
|
||||
const pageH = 792;
|
||||
const marginX = 50;
|
||||
const headerH = 90;
|
||||
const footerH = 40;
|
||||
const bodyTop = pageH - headerH - 22;
|
||||
const bodyBottom = footerH + 10;
|
||||
const lineH = 12;
|
||||
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
|
||||
|
||||
let logoBuf = null;
|
||||
let logoDims = null;
|
||||
try {
|
||||
logoBuf = fs.readFileSync(LOGO_PATH);
|
||||
logoDims = readJpegDims(logoBuf);
|
||||
} catch (_) {}
|
||||
|
||||
const allLines = [];
|
||||
for (const e of entries) {
|
||||
const ts = new Date(e.ts);
|
||||
const when = ts.toISOString().replace('T', ' ').slice(0, 19);
|
||||
allLines.push({ kind: 'header', text: `[${when}]:` });
|
||||
allLines.push({ kind: 'blank', text: '' });
|
||||
for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
|
||||
allLines.push({ kind: 'blank', text: '' });
|
||||
}
|
||||
if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
|
||||
|
||||
const pages = [];
|
||||
for (let i = 0; i < allLines.length; i += maxBodyLines) {
|
||||
pages.push(allLines.slice(i, i + maxBodyLines));
|
||||
}
|
||||
if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
|
||||
|
||||
const objects = [];
|
||||
const addObj = body => { objects.push(body); return objects.length; };
|
||||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
||||
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
|
||||
logoXObjId = addObj({ dict, stream: logoBuf });
|
||||
}
|
||||
|
||||
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||||
const footerLeft = `Generated: ${exportDate}`;
|
||||
|
||||
const pageIds = [];
|
||||
const contentIds = [];
|
||||
|
||||
pages.forEach((pg, pgIdx) => {
|
||||
const parts = [];
|
||||
|
||||
if (logoXObjId) {
|
||||
const logoH = 60;
|
||||
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
|
||||
const logoX = marginX;
|
||||
const logoY = pageH - headerH + 15;
|
||||
parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} cm\n/Logo Do\nQ`);
|
||||
}
|
||||
|
||||
const titleX = (logoXObjId ? marginX + 80 : marginX);
|
||||
const titleY = pageH - 45;
|
||||
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Experience logs')}) Tj\nET`);
|
||||
const inhabitantPrefix = 'Inhabitant: ';
|
||||
const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
|
||||
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
|
||||
parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) Tj\nET`);
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
|
||||
|
||||
let y = bodyTop;
|
||||
const charW = 6;
|
||||
for (const ln of pg) {
|
||||
if (ln.kind === 'header') {
|
||||
parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.text) {
|
||||
const segs = splitSegments(ln.text);
|
||||
let x = marginX;
|
||||
for (const s of segs) {
|
||||
if (!s.t) continue;
|
||||
const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
|
||||
parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
|
||||
x += s.t.length * charW;
|
||||
}
|
||||
}
|
||||
y -= lineH;
|
||||
}
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
|
||||
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
|
||||
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
|
||||
const pageLabelW = pageLabel.length * 4.8;
|
||||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
pageIds.push(pid);
|
||||
});
|
||||
|
||||
for (let i = 0; i < pageIds.length; i++) {
|
||||
const resources = logoXObjId
|
||||
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
|
||||
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
|
||||
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
|
||||
}
|
||||
|
||||
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
||||
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
|
||||
|
||||
const chunks = [];
|
||||
const offsets = [0];
|
||||
let byteLen = 0;
|
||||
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
|
||||
|
||||
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
|
||||
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets.push(byteLen);
|
||||
const obj = objects[i];
|
||||
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
|
||||
push(obj.stream);
|
||||
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
|
||||
} else {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
|
||||
}
|
||||
}
|
||||
|
||||
const xrefStart = byteLen;
|
||||
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) {
|
||||
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
|
||||
push(Buffer.from(xref, 'binary'));
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
module.exports = { buildLogsPdf };
|
||||
126
src/backend/media-favorites.js
Normal file
126
src/backend/media-favorites.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const FILE = path.join(__dirname, "../configs/media-favorites.json");
|
||||
|
||||
const DEFAULT = {
|
||||
audios: [],
|
||||
bookmarks: [],
|
||||
calendars: [],
|
||||
chats: [],
|
||||
documents: [],
|
||||
images: [],
|
||||
maps: [],
|
||||
pads: [],
|
||||
shops: [],
|
||||
market: [],
|
||||
shopProducts: [],
|
||||
torrents: [],
|
||||
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);
|
||||
};
|
||||
|
||||
20
src/backend/nameCache.js
Normal file
20
src/backend/nameCache.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const cache = new Map()
|
||||
|
||||
const get = (feedId) => {
|
||||
if (!feedId) return null
|
||||
const entry = cache.get(String(feedId))
|
||||
return entry ? entry.name : null
|
||||
}
|
||||
|
||||
const set = (feedId, name, ts) => {
|
||||
if (!feedId || typeof name !== 'string' || !name) return
|
||||
const id = String(feedId)
|
||||
const t = Number(ts) || 0
|
||||
const prev = cache.get(id)
|
||||
if (!prev || (prev.ts || 0) <= t) cache.set(id, { name, ts: t })
|
||||
}
|
||||
|
||||
const has = (feedId) => !!feedId && cache.has(String(feedId))
|
||||
const size = () => cache.size
|
||||
|
||||
module.exports = { get, set, has, size }
|
||||
50
src/backend/opinion_categories.js
Normal file
50
src/backend/opinion_categories.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const positive = [
|
||||
"interesting",
|
||||
"necessary",
|
||||
"useful",
|
||||
"informative",
|
||||
"wellResearched",
|
||||
"accurate",
|
||||
"insightful",
|
||||
"actionable",
|
||||
"creative",
|
||||
"inspiring",
|
||||
"love",
|
||||
"funny",
|
||||
"clear",
|
||||
"uplifting"
|
||||
];
|
||||
|
||||
const constructive = [
|
||||
"unnecessary",
|
||||
"rejected",
|
||||
"needsSources",
|
||||
"wrong",
|
||||
"lowQuality",
|
||||
"confusing",
|
||||
"misleading",
|
||||
"offTopic",
|
||||
"duplicate",
|
||||
"clickbait",
|
||||
"propaganda"
|
||||
];
|
||||
|
||||
const moderation = [
|
||||
"spam",
|
||||
"troll",
|
||||
"adultOnly",
|
||||
"nsfw",
|
||||
"violent",
|
||||
"toxic",
|
||||
"harassment",
|
||||
"hate",
|
||||
"scam",
|
||||
"triggering"
|
||||
];
|
||||
|
||||
const all = [...positive, ...constructive, ...moderation];
|
||||
all.positive = positive;
|
||||
all.constructive = constructive;
|
||||
all.moderation = moderation;
|
||||
|
||||
module.exports = all;
|
||||
139
src/backend/renderTextWithStyles.js
Normal file
139
src/backend/renderTextWithStyles.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
|
||||
function getI18n() {
|
||||
try {
|
||||
const { i18n } = require("../views/main_views");
|
||||
return i18n;
|
||||
} catch (_) {
|
||||
return i18nBase['en'] || {};
|
||||
}
|
||||
}
|
||||
|
||||
function renderTextPreview(text, maxLength = 220) {
|
||||
if (!text) return ''
|
||||
|
||||
let preview = String(text)
|
||||
|
||||
preview = preview
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/^>.*$/gm, '')
|
||||
.replace(/^#{1,6}\s*/gm, '')
|
||||
.replace(/^- /gm, '')
|
||||
.replace(/^\d+\. /gm, '')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (preview.length > maxLength) {
|
||||
preview = preview.slice(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
function renderTextWithStyles(text) {
|
||||
if (!text) return ''
|
||||
const i18n = getI18n()
|
||||
|
||||
let html = String(text)
|
||||
|
||||
html = html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
html = html
|
||||
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
|
||||
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
|
||||
.replace(/^---$/gim, '<hr/>')
|
||||
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
|
||||
|
||||
html = html
|
||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/gim, '<code>$1</code>')
|
||||
|
||||
html = html
|
||||
.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>`
|
||||
)
|
||||
|
||||
html = html
|
||||
.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>`
|
||||
)
|
||||
|
||||
const escAttr = (s) => String(s).replace(/"/g, '"').replace(/'/g, ''')
|
||||
html = html
|
||||
.replace(/#(\w+)/g, (_, tag) =>
|
||||
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
|
||||
)
|
||||
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
|
||||
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
|
||||
)
|
||||
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
|
||||
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
|
||||
)
|
||||
|
||||
const lines = html.split('\n')
|
||||
let result = ''
|
||||
let inUL = false
|
||||
let inOL = false
|
||||
|
||||
for (let line of lines) {
|
||||
if (/^- /.test(line)) {
|
||||
if (!inUL) {
|
||||
result += '<ul>'
|
||||
inUL = true
|
||||
}
|
||||
result += `<li>${line.replace(/^- /, '')}</li>`
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d+\. /.test(line)) {
|
||||
if (!inOL) {
|
||||
result += '<ol>'
|
||||
inOL = true
|
||||
}
|
||||
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
|
||||
continue
|
||||
}
|
||||
|
||||
if (inUL) {
|
||||
result += '</ul>'
|
||||
inUL = false
|
||||
}
|
||||
|
||||
if (inOL) {
|
||||
result += '</ol>'
|
||||
inOL = false
|
||||
}
|
||||
|
||||
result += line + '<br>'
|
||||
}
|
||||
|
||||
if (inUL) result += '</ul>'
|
||||
if (inOL) result += '</ol>'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { renderTextWithStyles, renderTextPreview }
|
||||
92
src/backend/renderUrl.js
Normal file
92
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 };
|
||||
45
src/backend/sanitizeHtml.js
Normal file
45
src/backend/sanitizeHtml.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use strict";
|
||||
|
||||
const { JSDOM } = require('../server/node_modules/jsdom');
|
||||
const DOMPurify = require('../server/node_modules/dompurify');
|
||||
const window = new JSDOM('').window;
|
||||
const purify = DOMPurify(window);
|
||||
|
||||
const stripDangerousTags = (input) => {
|
||||
if (typeof input !== 'string') return '';
|
||||
return purify.sanitize(input, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br',
|
||||
'b', 'strong', 'i', 'em', 'u',
|
||||
'ul', 'ol', 'li',
|
||||
'blockquote',
|
||||
'code', 'pre'
|
||||
],
|
||||
ALLOWED_ATTR: [],
|
||||
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
|
||||
FORBID_ATTR: ['style']
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeHtml = (input) => {
|
||||
if (typeof input !== 'string') return '';
|
||||
return purify.sanitize(input, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_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'
|
||||
],
|
||||
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']
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { stripDangerousTags, sanitizeHtml };
|
||||
228
src/backend/smartContractPdf.js
Normal file
228
src/backend/smartContractPdf.js
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const wrap = (txt, max = 82) => {
|
||||
const out = [];
|
||||
for (const raw of String(txt || '').split('\n')) {
|
||||
let line = raw;
|
||||
while (line.length > max) {
|
||||
let cut = line.lastIndexOf(' ', max);
|
||||
if (cut <= 0) cut = max;
|
||||
out.push(line.slice(0, cut));
|
||||
line = line.slice(cut).replace(/^\s+/, '');
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const readJpegDims = (buf) => {
|
||||
let i = 2;
|
||||
while (i < buf.length) {
|
||||
if (buf[i] !== 0xFF) return null;
|
||||
const marker = buf[i + 1];
|
||||
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
const c = buf[i + 9];
|
||||
return { w, h, c };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function buildSmartContractPdf({ transfer, block, viewerId }) {
|
||||
const pageW = 612;
|
||||
const pageH = 792;
|
||||
const marginX = 50;
|
||||
const headerH = 90;
|
||||
const footerH = 40;
|
||||
const bodyTop = pageH - headerH - 24;
|
||||
const bodyBottom = footerH + 10;
|
||||
const lineH = 14;
|
||||
|
||||
let logoBuf = null;
|
||||
let logoDims = null;
|
||||
try {
|
||||
logoBuf = fs.readFileSync(LOGO_PATH);
|
||||
logoDims = readJpegDims(logoBuf);
|
||||
} catch (_) {}
|
||||
|
||||
const t = transfer || {};
|
||||
const b = block || {};
|
||||
const fmt = v => (v === undefined || v === null) ? '' : String(v);
|
||||
const fmtAmount = () => {
|
||||
const cat = String(t.category || 'ECONOMIC').toUpperCase();
|
||||
const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO';
|
||||
return `${Number(t.amount || 0).toFixed(6)} ${unit}`;
|
||||
};
|
||||
const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '';
|
||||
const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : [];
|
||||
const required = t.from === t.to ? 1 : 2;
|
||||
const confirmedCount = confirmedBy.length;
|
||||
const tags = Array.isArray(t.tags) ? t.tags.join(', ') : '';
|
||||
|
||||
const sections = [];
|
||||
sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` });
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'OASIS IDs' });
|
||||
sections.push({ kind: 'kv', label: 'From', value: fmt(t.from) });
|
||||
sections.push({ kind: 'kv', label: 'To', value: fmt(t.to) });
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'TERMS' });
|
||||
sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() });
|
||||
if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() });
|
||||
sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) });
|
||||
if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) });
|
||||
if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags });
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'CONFIRMATIONS' });
|
||||
sections.push({ kind: 'kv', label: 'Required', value: String(required) });
|
||||
sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) });
|
||||
if (confirmedBy.length) {
|
||||
for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) });
|
||||
}
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' });
|
||||
if (b && b.id) {
|
||||
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) });
|
||||
if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) });
|
||||
if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() });
|
||||
if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) });
|
||||
if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` });
|
||||
} else {
|
||||
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) });
|
||||
}
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'METADATA' });
|
||||
sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) });
|
||||
if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) });
|
||||
if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) });
|
||||
|
||||
const lines = [];
|
||||
for (const s of sections) {
|
||||
if (s.kind === 'kv') {
|
||||
const txt = `${s.label}: ${s.value}`;
|
||||
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
|
||||
} else {
|
||||
lines.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
|
||||
const pages = [];
|
||||
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
|
||||
if (!pages.length) pages.push([]);
|
||||
|
||||
const objects = [];
|
||||
const addObj = body => { objects.push(body); return objects.length; };
|
||||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
||||
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
|
||||
logoXObjId = addObj({ dict, stream: logoBuf });
|
||||
}
|
||||
|
||||
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||||
const footerLeft = `Generated: ${exportDate}`;
|
||||
|
||||
const pageIds = [];
|
||||
const contentIds = [];
|
||||
|
||||
pages.forEach((pg, pgIdx) => {
|
||||
const parts = [];
|
||||
if (logoXObjId) {
|
||||
const logoH = 60;
|
||||
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
|
||||
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
|
||||
}
|
||||
const titleX = (logoXObjId ? marginX + 80 : marginX);
|
||||
const titleY = pageH - 45;
|
||||
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Smart Contract')}) Tj\nET`);
|
||||
const prefix = 'Issued to: ';
|
||||
const prefixW = prefix.length * 5.4;
|
||||
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
|
||||
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(viewerId || ''))}) Tj\nET`);
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
|
||||
|
||||
let y = bodyTop;
|
||||
for (const ln of pg) {
|
||||
if (ln.kind === 'title') {
|
||||
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.kind === 'subtitle') {
|
||||
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.kind === 'section') {
|
||||
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
|
||||
} else if (ln.kind === 'kv') {
|
||||
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
}
|
||||
y -= lineH;
|
||||
}
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
|
||||
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
|
||||
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
|
||||
const pageLabelW = pageLabel.length * 4.8;
|
||||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
pageIds.push(pid);
|
||||
});
|
||||
|
||||
for (let i = 0; i < pageIds.length; i++) {
|
||||
const resources = logoXObjId
|
||||
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
|
||||
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
|
||||
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
|
||||
}
|
||||
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
||||
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
|
||||
|
||||
const chunks = [];
|
||||
const offsets = [0];
|
||||
let byteLen = 0;
|
||||
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
|
||||
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets.push(byteLen);
|
||||
const obj = objects[i];
|
||||
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
|
||||
push(obj.stream);
|
||||
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
|
||||
} else {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
|
||||
}
|
||||
}
|
||||
const xrefStart = byteLen;
|
||||
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||||
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
|
||||
push(Buffer.from(xref, 'binary'));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
module.exports = { buildSmartContractPdf };
|
||||
121
src/backend/updater.js
Normal file
121
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
116
src/backend/vote_tally.js
Normal file
116
src/backend/vote_tally.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
const buildVoteTally = (messages) => {
|
||||
const nodes = new Map();
|
||||
const ballots = [];
|
||||
|
||||
for (const m of messages) {
|
||||
const v = m && m.value;
|
||||
const c = v && v.content;
|
||||
if (!c) continue;
|
||||
if (c.type === 'votesVote') { if (c.target) ballots.push({ target: c.target, author: v.author, choice: c.choice }); continue; }
|
||||
if (c.type !== 'votes') continue;
|
||||
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts: v.timestamp || m.timestamp || 0 });
|
||||
}
|
||||
|
||||
const prev = new Map();
|
||||
for (const [k, n] of nodes) {
|
||||
const t = n.content.replaces;
|
||||
if (typeof t === 'string' && nodes.has(t)) prev.set(k, t);
|
||||
}
|
||||
const childrenOf = new Map();
|
||||
for (const [k, p] of prev) {
|
||||
if (!childrenOf.has(p)) childrenOf.set(p, []);
|
||||
childrenOf.get(p).push(k);
|
||||
}
|
||||
const rootOf = (k) => { let x = k, g = 0; while (prev.has(x) && g++ < 100000) x = prev.get(x); return x; };
|
||||
|
||||
const groups = new Map();
|
||||
for (const k of nodes.keys()) { const r = rootOf(k); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(k); }
|
||||
|
||||
const tallyByKey = new Map();
|
||||
for (const [root, keys] of groups.entries()) {
|
||||
const rootNode = nodes.get(root);
|
||||
if (!rootNode) continue;
|
||||
const options = Array.isArray(rootNode.content.options) ? rootNode.content.options : [];
|
||||
let votesMap = Object.assign({}, rootNode.content.votes || {});
|
||||
let voters = Array.isArray(rootNode.content.voters) ? rootNode.content.voters.slice() : [];
|
||||
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
|
||||
|
||||
let cur = root;
|
||||
let g = 0;
|
||||
let creatorChoice = null;
|
||||
let creatorCounted = false;
|
||||
while (g++ < 100000) {
|
||||
const kids = (childrenOf.get(cur) || []).map(k => nodes.get(k)).filter(Boolean).sort((a, b) => a.ts - b.ts);
|
||||
if (!kids.length) break;
|
||||
let advanced = false;
|
||||
for (const kid of kids) {
|
||||
const kVotes = Object.assign({}, kid.content.votes || {});
|
||||
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters.slice() : [];
|
||||
for (const o of options) if (!(o in kVotes)) kVotes[o] = 0;
|
||||
if (kid.author === rootNode.author) {
|
||||
if (!creatorCounted && kVoters.includes(rootNode.author) && !voters.includes(rootNode.author)) {
|
||||
creatorCounted = true;
|
||||
let inc = null;
|
||||
let incCount = 0;
|
||||
for (const o of options) {
|
||||
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
|
||||
if (d >= 1) { inc = o; incCount++; }
|
||||
}
|
||||
if (incCount === 1) creatorChoice = inc;
|
||||
}
|
||||
votesMap = kVotes;
|
||||
voters = kVoters;
|
||||
cur = kid.key;
|
||||
advanced = true;
|
||||
break;
|
||||
}
|
||||
if (voters.includes(kid.author)) continue;
|
||||
if (kVoters.length !== voters.length + 1) continue;
|
||||
const added = kVoters.filter(x => !voters.includes(x));
|
||||
if (added.length !== 1 || added[0] !== kid.author) continue;
|
||||
let deltas = 0;
|
||||
let deltaOk = true;
|
||||
for (const o of options) {
|
||||
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
|
||||
if (d === 0) continue;
|
||||
if (d === 1) { deltas++; continue; }
|
||||
deltaOk = false;
|
||||
break;
|
||||
}
|
||||
if (!deltaOk || deltas !== 1) continue;
|
||||
votesMap = kVotes;
|
||||
voters = kVoters;
|
||||
cur = kid.key;
|
||||
advanced = true;
|
||||
break;
|
||||
}
|
||||
if (!advanced) break;
|
||||
}
|
||||
|
||||
if (voters.includes(rootNode.author)) {
|
||||
voters = voters.filter(x => x !== rootNode.author);
|
||||
if (creatorChoice && (Number(votesMap[creatorChoice]) || 0) > 0) votesMap[creatorChoice] = votesMap[creatorChoice] - 1;
|
||||
}
|
||||
|
||||
const voterSet = new Set(voters);
|
||||
let totalVotes = voters.length;
|
||||
const chainKeys = new Set(keys);
|
||||
for (const b of ballots) {
|
||||
if (!chainKeys.has(b.target)) continue;
|
||||
if (b.author === rootNode.author) continue;
|
||||
if (!b.author || voterSet.has(b.author)) continue;
|
||||
if (!options.includes(b.choice)) continue;
|
||||
voterSet.add(b.author);
|
||||
voters.push(b.author);
|
||||
votesMap[b.choice] = (votesMap[b.choice] || 0) + 1;
|
||||
totalVotes += 1;
|
||||
}
|
||||
|
||||
const tally = { votes: votesMap, voters, totalVotes };
|
||||
for (const k of keys) tallyByKey.set(k, tally);
|
||||
}
|
||||
|
||||
return tallyByKey;
|
||||
};
|
||||
|
||||
module.exports = { buildVoteTally };
|
||||
17
src/backend/wallet_addresses.js
Normal file
17
src/backend/wallet_addresses.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const STORAGE_DIR = path.join(__dirname, "..", "configs");
|
||||
const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
|
||||
|
||||
function ensure() {
|
||||
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
|
||||
}
|
||||
function readAll() { ensure(); return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); }
|
||||
function writeAll(m) { fs.writeFileSync(ADDR_PATH, JSON.stringify(m, null, 2)); }
|
||||
|
||||
async function getAddress(userId) { const m = readAll(); return m[userId] || null; }
|
||||
async function setAddress(userId, address) { const m = readAll(); m[userId] = address; writeAll(m); return true; }
|
||||
|
||||
module.exports = { getAddress, setAddress };
|
||||
Loading…
Add table
Add a link
Reference in a new issue