upstream: integrar Oasis 1.3.1

Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
This commit is contained in:
oasis-bot 2026-10-18 02:52:17 +02:00
parent 8d838dd8b1
commit c332afd6ea
265 changed files with 80970 additions and 29167 deletions

File diff suppressed because it is too large Load diff

View file

@ -5,11 +5,13 @@ 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) {
}
let sharpLib;
const getSharp = () => {
if (sharpLib === undefined) {
try { sharpLib = require('../server/node_modules/sharp'); } catch (_) { sharpLib = null; }
}
return sharpLib;
};
/**
* Quitar metadatos de una imagen SIN sharp.
@ -161,7 +163,8 @@ const stripMetadataPure = (buffer) => {
const stripImageMetadata = async (buffer) => {
// Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la
// orientacion girando los pixeles de verdad.
if (typeof sharp === "function") {
const sharp = getSharp();
if (sharp) {
try {
return await sharp(buffer).rotate().toBuffer();
} catch {
@ -198,7 +201,7 @@ const stripPdfMetadata = (buffer) => {
}
};
const MAX_BLOB_SIZE = 50 * 1024 * 1024;
const MAX_BLOB_SIZE = 75 * 1024 * 1024;
class FileTooLargeError extends Error {
constructor(fileName, fileSize) {
@ -346,6 +349,52 @@ function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
});
}
const contentDisposition = (type, name) => {
const clean = String(name || 'file').replace(/["\r\n\\/]/g, '').trim() || 'file';
const ascii = clean.replace(/[^\x20-\x7e]/g, '_');
if (ascii === clean) return `${type}; filename="${clean}"`;
return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())}`;
};
const sendBlobBuffer = (ctx, buffer) => {
const size = buffer.length;
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;
}
};
const serveBlob = async function (ctx) {
const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
const raw = decodeURIComponent(encodedParam);
@ -385,8 +434,6 @@ const serveBlob = async function (ctx) {
return;
}
const size = buffer.length;
let mime = 'application/octet-stream';
try {
const ft = await FileType.fromBuffer(buffer);
@ -394,7 +441,7 @@ const serveBlob = async function (ctx) {
} catch {}
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
const head = buffer.slice(0, 128).toString('ascii');
const head = buffer.slice(0, 512).toString('ascii');
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
}
@ -412,48 +459,17 @@ const serveBlob = async function (ctx) {
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';
const wantsDownload = String(ctx.query.download || '') === '1';
const extByMime = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'video/mp4': 'mp4', 'video/webm': 'webm', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'application/pdf': 'pdf', 'application/x-bittorrent': 'torrent' };
const ext = extByMime[mime] || String(mime.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : (wantsDownload ? `oasis-${safeRaw.replace(/^&/, '').slice(0, 8)}.${ext}` : safeRaw));
const disposition = isSvg || wantsDownload ? 'attachment' : 'inline';
ctx.type = mime;
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
ctx.set('Content-Disposition', contentDisposition(disposition, 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;
}
sendBlobBuffer(ctx, buffer);
};
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, oggMime, FileTooLargeError };
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, oggMime, FileTooLargeError, stripImageMetadata };

View file

@ -1,280 +0,0 @@
const { buildDocumentPdf } = require('./pdfDocument');
const fmtDate = v => {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? String(v) : d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
};
const fmtDay = v => {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10);
};
const asList = v => (Array.isArray(v) ? v : []);
const txt = v => (v == null ? '' : String(v));
const humanLabel = (key) => String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, c => c.toUpperCase());
const pushMeta = (out, item) => {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'METADATA' });
if (item.id) out.push({ kind: 'kv', label: 'Content ID', value: item.id });
const author = item.author || item.organizer || item.createdBy || item.from || '';
if (author) out.push({ kind: 'kv', label: 'Author', value: author });
if (item.createdAt) out.push({ kind: 'kv', label: 'Created At', value: fmtDate(item.createdAt) });
if (item.updatedAt) out.push({ kind: 'kv', label: 'Updated At', value: fmtDate(item.updatedAt) });
};
const pushOpinions = (out, item) => {
const op = item.opinions && typeof item.opinions === 'object' ? item.opinions : {};
const entries = Object.entries(op).filter(([, n]) => Number(n) > 0);
if (!entries.length) return;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'OPINIONS' });
for (const [cat, n] of entries) out.push({ kind: 'kv', label: humanLabel(cat), value: String(n) });
};
const pushTags = (out, item) => {
const tags = asList(item.tags).filter(Boolean);
if (tags.length) out.push({ kind: 'kv', label: 'Tags', value: tags.join(', ') });
};
const reportSections = (report) => {
const out = [];
out.push({ kind: 'title', text: `Report: ${txt(report.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CLASSIFICATION' });
out.push({ kind: 'kv', label: 'Category', value: txt(report.category).toUpperCase() });
out.push({ kind: 'kv', label: 'Severity', value: txt(report.severity).toUpperCase() });
out.push({ kind: 'kv', label: 'Status', value: txt(report.status).toUpperCase() });
pushTags(out, report);
if (txt(report.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: report.description });
}
const tpl = report.template && typeof report.template === 'object' ? report.template : {};
const tplEntries = Object.entries(tpl).filter(([, v]) => txt(v).trim());
if (tplEntries.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DETAILS' });
for (const [k, v] of tplEntries) out.push({ kind: 'kv', label: humanLabel(k), value: v });
}
const confirmations = asList(report.confirmations);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CONFIRMATIONS' });
out.push({ kind: 'kv', label: 'Confirmed', value: String(confirmations.length) });
for (const c of confirmations) out.push({ kind: 'kv', label: 'Confirmed by', value: c });
pushOpinions(out, report);
pushMeta(out, report);
return out;
};
const voteSections = (vote) => {
const out = [];
out.push({ kind: 'title', text: `Votation: ${txt(vote.question) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'TERMS' });
out.push({ kind: 'kv', label: 'Status', value: txt(vote.status).toUpperCase() });
if (vote.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(vote.deadline) });
pushTags(out, vote);
const votes = vote.votes && typeof vote.votes === 'object' ? vote.votes : {};
const total = Number(vote.totalVotes) || asList(vote.voters).length;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'RESULTS' });
out.push({ kind: 'kv', label: 'Total votes', value: String(total) });
for (const [choice, n] of Object.entries(votes)) {
const count = Number(n) || 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
out.push({ kind: 'kv', label: choice, value: `${count} (${pct}%)` });
}
pushOpinions(out, vote);
pushMeta(out, vote);
return out;
};
const eventSections = (event) => {
const out = [];
out.push({ kind: 'title', text: `Event: ${txt(event.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SCHEDULE' });
out.push({ kind: 'kv', label: 'Date', value: fmtDate(event.date) });
out.push({ kind: 'kv', label: 'Status', value: txt(event.status).toUpperCase() });
if (event.location) out.push({ kind: 'kv', label: 'Location', value: event.location });
if (event.mapUrl) out.push({ kind: 'kv', label: 'Map', value: event.mapUrl });
if (Number(event.price) > 0) out.push({ kind: 'kv', label: 'Price', value: `${Number(event.price)} ECO` });
out.push({ kind: 'kv', label: 'Privacy', value: txt(event.isPublic).toUpperCase() });
if (event.url) out.push({ kind: 'kv', label: 'Url', value: event.url });
pushTags(out, event);
if (txt(event.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: event.description });
}
const attendees = asList(event.attendees);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'ATTENDEES' });
out.push({ kind: 'kv', label: 'Total', value: String(attendees.length) });
for (const a of attendees) out.push({ kind: 'kv', label: 'Attendee', value: a });
pushOpinions(out, event);
pushMeta(out, event);
return out;
};
const taskSections = (task) => {
const out = [];
out.push({ kind: 'title', text: `Task: ${txt(task.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SCHEDULE' });
out.push({ kind: 'kv', label: 'Status', value: txt(task.status).toUpperCase() });
out.push({ kind: 'kv', label: 'Priority', value: txt(task.priority).toUpperCase() });
if (task.startTime) out.push({ kind: 'kv', label: 'Starts', value: fmtDate(task.startTime) });
if (task.endTime) out.push({ kind: 'kv', label: 'Ends', value: fmtDate(task.endTime) });
if (task.location) out.push({ kind: 'kv', label: 'Location', value: task.location });
out.push({ kind: 'kv', label: 'Privacy', value: txt(task.isPublic).toUpperCase() });
pushTags(out, task);
if (txt(task.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: task.description });
}
const assignees = asList(task.assignees);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'ASSIGNEES' });
out.push({ kind: 'kv', label: 'Total', value: String(assignees.length) });
for (const a of assignees) out.push({ kind: 'kv', label: 'Assignee', value: a });
pushOpinions(out, task);
pushMeta(out, task);
return out;
};
const calendarSections = (calendar, extra = {}) => {
const dates = asList(extra.dates);
const notesByDate = extra.notesByDate && typeof extra.notesByDate === 'object' ? extra.notesByDate : {};
const out = [];
out.push({ kind: 'title', text: `Calendar: ${txt(calendar.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SUMMARY' });
out.push({ kind: 'kv', label: 'Status', value: calendar.isClosed ? 'CLOSED' : txt(calendar.status).toUpperCase() });
if (calendar.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(calendar.deadline) });
if (calendar.mapUrl) out.push({ kind: 'kv', label: 'Map', value: calendar.mapUrl });
out.push({ kind: 'kv', label: 'Participants', value: String(asList(calendar.participants).length) });
out.push({ kind: 'kv', label: 'Dates', value: String(dates.length) });
const noteTotal = Object.values(notesByDate).reduce((n, arr) => n + asList(arr).length, 0);
out.push({ kind: 'kv', label: 'Notes', value: String(noteTotal) });
pushTags(out, calendar);
const participants = asList(calendar.participants);
if (participants.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'PARTICIPANTS' });
for (const p of participants) out.push({ kind: 'kv', label: 'Participant', value: p });
}
if (dates.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DATES' });
for (const d of dates) {
const day = fmtDay(d && d.date) || txt(d && d.date);
out.push({ kind: 'kv', label: day, value: txt(d && d.label) });
for (const note of asList(notesByDate[d && d.key])) {
const noteText = txt(note && note.text);
if (noteText) out.push({ kind: 'text', text: ` - ${noteText}` });
}
}
}
pushMeta(out, calendar);
return out;
};
const cvSections = (cv) => {
const out = [];
out.push({ kind: 'title', text: `Curriculum: ${txt(cv.name) || txt(cv.author) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'PROFILE' });
if (cv.location) out.push({ kind: 'kv', label: 'Location', value: cv.location });
if (cv.status) out.push({ kind: 'kv', label: 'Status', value: txt(cv.status).toUpperCase() });
if (cv.preferences) out.push({ kind: 'kv', label: 'Preferences', value: txt(cv.preferences).toUpperCase() });
if (cv.languages) out.push({ kind: 'kv', label: 'Languages', value: cv.languages });
if (txt(cv.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: cv.description });
}
const blocks = [
['PERSONAL', cv.personalExperiences, cv.personalSkills],
['EDUCATION', cv.educationExperiences, cv.educationalSkills],
['PROFESSIONAL', cv.professionalExperiences, cv.professionalSkills],
['OASIS', cv.oasisExperiences, cv.oasisSkills]
];
for (const [heading, experiences, skills] of blocks) {
const skillList = asList(skills).filter(Boolean);
const body = txt(experiences).trim();
if (!body && !skillList.length) continue;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: heading });
if (body) out.push({ kind: 'text', text: body });
if (skillList.length) out.push({ kind: 'kv', label: 'Skills', value: skillList.join(', ') });
}
pushMeta(out, cv);
return out;
};
const BUILDERS = {
reports: { title: 'OASIS - Report', sections: reportSections, name: item => item.title },
votes: { title: 'OASIS - Votation', sections: voteSections, name: item => item.question },
events: { title: 'OASIS - Event', sections: eventSections, name: item => item.title },
tasks: { title: 'OASIS - Task', sections: taskSections, name: item => item.title },
calendars: { title: 'OASIS - Calendar', sections: calendarSections, name: item => item.title },
cv: { title: 'OASIS - Curriculum', sections: cvSections, name: item => item.name || item.author }
};
const isSupported = kind => Object.prototype.hasOwnProperty.call(BUILDERS, kind);
const pdfFilename = (kind, item) => {
const b = BUILDERS[kind];
const raw = b ? txt(b.name(item || {})) : '';
const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
return `oasis-${kind}-${slug || 'document'}.pdf`;
};
const buildContentPdf = (kind, item, extra = {}, viewerId = null) => {
const b = BUILDERS[kind];
if (!b) throw new Error('Unsupported pdf kind');
return buildDocumentPdf({
title: b.title,
issuedToLabel: 'Issued to',
issuedTo: viewerId || null,
sections: b.sections(item || {}, extra)
});
};
module.exports = { buildContentPdf, pdfFilename, isSupported };

View file

@ -1,19 +1,15 @@
const fs = require("fs");
const path = require("path");
const TEMPLATE = path.join(__dirname, "../configs/content_favorites.json");
const storePath = () => {
try {
return require("../server/ssb_config").statePath("content_favorites.json") || TEMPLATE;
} catch (_) { return TEMPLATE; }
};
const storePath = () => require("../configs/state-manager").statePath("content_favorites.json");
const DEFAULT = {
emergencies: [],
audios: [],
blogs: [],
bookmarks: [],
calendars: [],
campaigns: [],
chats: [],
documents: [],
events: [],
@ -21,20 +17,27 @@ const DEFAULT = {
housing: [],
images: [],
jobs: [],
logistics: [],
logs: [],
mailing: [],
maps: [],
pads: [],
rooms: [],
podcasts: [],
polls: [],
projects: [],
reports: [],
school: [],
shops: [],
market: [],
shopProducts: [],
tasks: [],
torrents: [],
files: [],
transfers: [],
videos: [],
votes: []
votes: [],
wiki: []
};
const safeArr = (v) => (Array.isArray(v) ? v : []);

View file

@ -49,4 +49,79 @@ const dedupeByPreferring = (items, keyFn, scoreFn) => {
return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v));
};
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm };
const versionGroups = (entries) => {
const byKey = new Map();
for (const e of (entries || [])) if (e && e.key) byKey.set(e.key, e);
const memo = new Map();
const rootOf = (key) => {
if (memo.has(key)) return memo.get(key);
const path = [];
const seen = new Set([key]);
let cur = key;
let root = null;
while (root === null) {
if (memo.has(cur)) { root = memo.get(cur); break; }
const e = byKey.get(cur);
const p = e && e.replaces;
if (!p || !byKey.has(p) || seen.has(p)) { root = cur; break; }
path.push(cur);
seen.add(p);
cur = p;
}
memo.set(cur, root);
for (const k of path) memo.set(k, root);
return root;
};
const isOrphan = (key) => {
const e = byKey.get(key);
return !!(e && e.replaces && !byKey.has(e.replaces));
};
const isForeignCopy = (key) => {
const e = byKey.get(key);
return !!(e && isOrphan(key) && typeof e.claimedAuthor === 'string' && e.claimedAuthor && e.claimedAuthor !== e.author);
};
const groups = new Map();
for (const k of byKey.keys()) {
const r = rootOf(k);
if (!groups.has(r)) groups.set(r, []);
groups.get(r).push(k);
}
const later = (a, b) => (a.seq || 0) > (b.seq || 0) || ((a.seq || 0) === (b.seq || 0) && (a.ts || 0) >= (b.ts || 0));
for (const [r, keys] of Array.from(groups.entries())) {
if (!isForeignCopy(r)) continue;
const owner = byKey.get(r).claimedAuthor;
let first = null;
for (const k of keys) { const e = byKey.get(k); if (e.author === owner && (!first || later(byKey.get(first), e))) first = k; }
if (!first) continue;
groups.delete(r);
groups.set(first, keys);
for (const k of keys) memo.set(k, first);
}
const ownerTip = new Map();
for (const [r, keys] of groups) {
const re = byKey.get(r);
for (const k of keys) {
const e = byKey.get(k);
if (!re || e.author !== re.author) continue;
const prev = ownerTip.has(r) ? byKey.get(ownerTip.get(r)) : null;
if (!prev || later(e, prev)) ownerTip.set(r, k);
}
}
const isForeign = (key) => {
const e = byKey.get(key);
const re = byKey.get(rootOf(key));
return !!(e && re && e.author !== re.author);
};
const roots = new Set();
for (const r of groups.keys()) if (!isForeignCopy(r)) roots.add(r);
const tipOf = (key) => { const r = rootOf(key); return ownerTip.get(r) || r; };
return { rootOf, tipOf, roots, groups, isOrphan, isForeign, isForeignCopy };
};
const versionEntry = (m) => {
const v = (m && m.value) || {};
const c = v.content || {};
return { key: m && m.key, author: v.author, seq: v.sequence || 0, ts: v.timestamp || (m && m.timestamp) || 0, replaces: typeof c.replaces === 'string' ? c.replaces : null, claimedAuthor: typeof c.author === 'string' ? c.author : null };
};
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm, versionGroups, versionEntry };

View file

@ -0,0 +1,80 @@
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const { getConfig } = require('../configs/config-manager.js');
const REPEAT_MS = 60000;
const ICON = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const recent = new Map();
const onPath = (bin) => (process.env.PATH || '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin)));
const i18nNow = () => {
const all = require('../client/assets/translations/i18n');
let lang = 'en';
try { lang = getConfig().language || 'en'; } catch (_) {}
return { ...all.en, ...(all[lang] || {}) };
};
const displayName = (id, name) => {
const n = String(name || '').trim();
if (!n || n === 'Redacted' || n === String(id || '').replace(/^@/, '').slice(0, 8)) return String(id || '');
return n.startsWith('@') ? n : `@${n}`;
};
const MOBILE_QUEUE = path.join(__dirname, '..', '..', '.notifications.json');
const queueForAndroid = (keyword, body, line, key) => {
try {
let list = [];
try { list = JSON.parse(fs.readFileSync(MOBILE_QUEUE, 'utf8')); } catch (_) { list = []; }
if (!Array.isArray(list)) list = [];
const t = i18nNow();
const k = String(keyword || '');
const route = k === t.phoneTitle ? '/phone' : k === t.inbox ? '/inbox' : k === t.emergenciesTitle ? '/emergencies' : '/';
const entry = { id: Date.now() + '-' + Math.random().toString(36).slice(2, 8), keyword: k || 'Oasis', body, line, route, key: key ? String(key) : null, ts: Date.now() };
if (entry.key && entry.key.indexOf('call:') === 0) {
const ringing = String(t.phoneIsCalling || '');
entry.caller = ringing && body.endsWith(ringing) ? body.slice(0, body.length - ringing.length).trim() : body;
entry.accept = String(t.phoneAnswer || '');
entry.reject = String(t.phoneReject || '');
}
list.push(entry);
while (list.length > 20) list.shift();
fs.writeFileSync(MOBILE_QUEUE, JSON.stringify(list));
return true;
} catch (_) {
return false;
}
};
const notify = (keyword, text, key = null, opts = {}) => {
const onMobile = process.env.OASIS_MOBILE === '1';
if (!onMobile && !onPath('notify-send')) return false;
const body = String(text || '').replace(/\s+/g, ' ').trim().replace(/[.。!…]+$/, '').slice(0, 200);
const line = `${String(keyword || 'Oasis').toUpperCase()}: ${body}...`;
const now = Date.now();
const dedupe = key || line;
for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k);
if (recent.has(dedupe)) return false;
recent.set(dedupe, now);
if (onMobile) return queueForAndroid(keyword, body, line, key);
const actions = Array.isArray(opts.actions) ? opts.actions.filter(x => x && x.key && x.label) : [];
const extra = [
...(opts.timeoutMs > 0 ? ['-t', String(Math.round(opts.timeoutMs))] : []),
...actions.flatMap(x => ['-A', `${x.key}=${x.label}`])
];
try {
const p = spawn('notify-send', ['-a', 'Oasis', ...(fs.existsSync(ICON) ? ['-i', ICON] : []), ...extra, line], { stdio: ['ignore', actions.length ? 'pipe' : 'ignore', 'ignore'] });
p.on('error', () => {});
if (actions.length && typeof opts.onAction === 'function') {
let out = '';
p.stdout.on('data', (d) => { out += d; });
p.on('close', () => { const chosen = out.trim(); if (actions.some(x => x.key === chosen)) opts.onAction(chosen); });
}
return true;
} catch (_) {
return false;
}
};
module.exports = { notify, i18nNow, displayName };

View file

@ -1,206 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const { escapePdf } = require('./pdfDocument');
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 /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
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, 'latin1')} >>\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 };

195
src/backend/long_text.js Normal file
View file

@ -0,0 +1,195 @@
const crypto = require('crypto');
const CHUNK_TYPE = 'textChunk';
const TEXT_CAP = 60000;
const CONTENT_BYTES = { plain: 6500, boxed: 4500, sealed: 3300 };
const MIN_HEAD_BYTES = 200;
const CACHE_MAX = 400;
const KEY_RE = /^%[A-Za-z0-9+/]{43}=?\.sha256$/;
const KEY_SAMPLE = '%' + 'A'.repeat(44) + '.sha256';
const byteLength = (s) => Buffer.byteLength(String(s == null ? '' : s), 'utf8');
const contentBytes = (content) => { try { return byteLength(JSON.stringify(content)); } catch (_) { return Infinity; } };
const isChunkKey = (k) => typeof k === 'string' && KEY_RE.test(k);
const tooLong = (text) => String(text == null ? '' : text).length > TEXT_CAP;
const cutAt = (text, maxBytes) => {
let i = 0, bytes = 0;
while (i < text.length) {
const cp = text.codePointAt(i);
const len = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
if (bytes + len > maxBytes) break;
bytes += len;
i += cp >= 0x10000 ? 2 : 1;
}
if (i >= text.length) return text.length;
const min = Math.floor(i / 2);
const para = text.lastIndexOf('\n\n', i);
if (para > min) return para + 2;
const line = text.lastIndexOf('\n', i);
if (line > min) return line + 1;
const space = text.lastIndexOf(' ', i);
if (space > min) return space + 1;
return Math.max(i, 1);
};
const splitText = (text, maxBytes, firstMax = maxBytes) => {
const s = String(text == null ? '' : text);
const out = [];
let rest = s;
let limit = Math.max(1, firstMax);
while (rest.length) {
const cut = cutAt(rest, limit);
out.push(rest.slice(0, cut));
rest = rest.slice(cut);
limit = Math.max(1, maxBytes);
}
return out.length ? out : [''];
};
const chunkTemplate = (extra) => ({ type: CHUNK_TYPE, text: '', index: 999, total: 999, group: 'g'.repeat(16), ...extra });
const chunkBudget = (extra, maxBytes) => maxBytes - contentBytes(chunkTemplate(extra));
const headBudget = (content, field, maxBytes, count) => maxBytes - contentBytes({ ...content, [field]: '', chunks: new Array(count).fill(KEY_SAMPLE) });
const needsChunking = (content, maxBytes = CONTENT_BYTES.plain) => contentBytes(content) > maxBytes;
const planPieces = (content, field, { maxBytes = CONTENT_BYTES.plain, extra = {} } = {}) => {
const text = String(content[field] == null ? '' : content[field]);
if (tooLong(text)) throw new Error('Text too long');
if (!needsChunking(content, maxBytes)) return null;
const perChunk = chunkBudget(extra, maxBytes);
if (perChunk < MIN_HEAD_BYTES) throw new Error('Text does not fit');
let count = 0;
let pieces = null;
for (let guard = 0; guard < 12; guard++) {
const head = headBudget(content, field, maxBytes, count);
if (head < MIN_HEAD_BYTES) throw new Error('Text does not fit');
pieces = splitText(text, perChunk, head);
if (pieces.length - 1 <= count) return pieces;
count = pieces.length - 1;
}
return pieces;
};
const publishChunks = async (pieces, publish, { extra = {}, group } = {}) => {
const g = group || crypto.randomBytes(8).toString('hex');
const total = pieces.length;
const keys = [];
for (let i = 0; i < pieces.length; i++) {
const r = await publish({ type: CHUNK_TYPE, text: pieces[i], index: i + 1, total, group: g, ...extra });
const key = r && typeof r === 'object' ? r.key : r;
if (!isChunkKey(key)) throw new Error('Chunk publish failed');
keys.push(key);
}
return keys;
};
const chunkContent = async (content, field, { maxBytes = CONTENT_BYTES.plain, extra = {}, publish } = {}) => {
const base = { ...content };
delete base.chunks;
const pieces = planPieces(base, field, { maxBytes, extra });
if (!pieces) return base;
const keys = await publishChunks(pieces.slice(1), publish, { extra });
return { ...base, [field]: pieces[0], chunks: keys };
};
const chunkOf = (author, c) => {
if (!c || typeof c !== 'object' || typeof c.text !== 'string') return null;
if (c.type !== CHUNK_TYPE && c.k !== CHUNK_TYPE) return null;
return { author, text: c.text, index: Number(c.index) || 0, total: Number(c.total) || 0, group: String(c.group || '') };
};
const joinText = (head, keys, lookup, author) => {
let out = String(head == null ? '' : head);
if (!Array.isArray(keys) || !keys.length) return out;
let group = null;
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!isChunkKey(k)) break;
const ch = lookup(k);
if (!ch || (author && ch.author !== author)) break;
if (group === null) group = ch.group; else if (ch.group !== group) break;
if (ch.index !== i + 1 || ch.total !== keys.length) break;
out += ch.text;
}
return out;
};
const hasChunks = (content) => !!content && Array.isArray(content.chunks) && content.chunks.length > 0;
const resolveField = (content, field, author, lookup) => {
if (!hasChunks(content)) return content;
return { ...content, [field]: joinText(content[field], content.chunks, lookup, author) };
};
const indexChunks = (messages, decode) => {
const index = new Map();
for (const m of messages || []) {
const v = m && m.value;
if (!v || !m.key) continue;
const raw = v.content;
const c = decode ? decode(raw, m) : raw;
const ch = chunkOf(v.author, c);
if (ch) index.set(m.key, ch);
}
return index;
};
const lookupIn = (index) => (k) => index.get(k) || null;
const caches = new WeakMap();
const cacheFor = (ssb) => {
let c = caches.get(ssb);
if (!c) { c = new Map(); caches.set(ssb, c); }
return c;
};
const remember = (cache, key, value) => {
if (cache.has(key)) cache.delete(key);
cache.set(key, value);
while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return value;
};
const fetchChunk = async (ssb, key, unbox) => {
const cache = cacheFor(ssb);
if (cache.has(key)) { const v = cache.get(key); cache.delete(key); cache.set(key, v); return v; }
const raw = await new Promise((resolve) => { try { ssb.get(key, (err, m) => resolve(err ? null : m)); } catch (_) { resolve(null); } });
if (!raw) return null;
let content = raw.content;
if (typeof content === 'string' && unbox) { try { content = unbox(content); } catch (_) { content = null; } }
const ch = chunkOf(raw.author, content);
return ch ? remember(cache, key, ch) : null;
};
const joinChunks = async (ssb, content, author, { field = 'text', unbox = null } = {}) => {
if (!hasChunks(content)) return content;
const found = new Map();
for (const k of content.chunks) {
if (!isChunkKey(k)) break;
const ch = await fetchChunk(ssb, k, unbox);
if (!ch) break;
found.set(k, ch);
}
return resolveField(content, field, author, lookupIn(found));
};
module.exports = {
CHUNK_TYPE,
TEXT_CAP,
CONTENT_BYTES,
byteLength,
contentBytes,
tooLong,
splitText,
needsChunking,
planPieces,
publishChunks,
chunkContent,
chunkOf,
joinText,
hasChunks,
resolveField,
indexChunks,
lookupIn,
joinChunks
};

View file

@ -46,5 +46,31 @@ const all = [...positive, ...constructive, ...moderation];
all.positive = positive;
all.constructive = constructive;
all.moderation = moderation;
all.messageTypes = {
bookmark: 'bookmarkOpinion',
votes: 'votesOpinion',
poll: 'pollOpinion',
transfer: 'transferOpinion',
feed: 'feedOpinion',
image: 'imageOpinion',
audio: 'audioOpinion',
video: 'videoOpinion',
document: 'documentOpinion',
torrent: 'torrentOpinion',
file: 'fileOpinion',
industry: 'industryOpinion',
project: 'projectOpinion',
report: 'reportOpinion',
task: 'taskOpinion',
event: 'eventOpinion',
shopProduct: 'shopOpinion',
housing: 'housingOpinion',
market: 'marketOpinion',
schoolCourse: 'schoolOpinion',
podcast: 'podcastOpinion',
podcastEpisode: 'podcastOpinion',
campaign: 'campaignOpinion',
logisticsRoute: 'logisticsOpinion'
};
module.exports = all;

1485
src/backend/pdf.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,199 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const WIN_ANSI_HIGH = {
'\u20AC': '\x80', '\u201A': '\x82', '\u0192': '\x83', '\u201E': '\x84',
'\u2026': '\x85', '\u2020': '\x86', '\u2021': '\x87', '\u02C6': '\x88',
'\u2030': '\x89', '\u0160': '\x8A', '\u2039': '\x8B', '\u0152': '\x8C',
'\u017D': '\x8E', '\u2018': '\x91', '\u2019': '\x92', '\u201C': '\x93',
'\u201D': '\x94', '\u2022': '\x95', '\u2013': '\x96', '\u2014': '\x97',
'\u02DC': '\x98', '\u2122': '\x99', '\u0161': '\x9A', '\u203A': '\x9B',
'\u0153': '\x9C', '\u017E': '\x9E', '\u0178': '\x9F'
};
const escapePdf = s => String(s == null ? '' : s)
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\u0152\u0153\u0160\u0161\u0178\u017D\u017E\u0192\u02C6\u02DC\u2013\u2014\u2018\u2019\u201A\u201C\u201D\u201E\u2020\u2021\u2022\u2026\u2030\u2039\u203A\u20AC\u2122]/g,
(c) => WIN_ANSI_HIGH[c] || '?')
.replace(/[^\x20-\xFF\x80-\x9F]/g, '?')
.replace(/\\/g, '\\\\')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)');
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt == null ? '' : 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;
};
const flattenSections = (sections) => {
const lines = [];
for (const s of Array.isArray(sections) ? sections : []) {
if (!s) continue;
if (s.kind === 'kv') {
const txt = `${s.label}: ${s.value == null ? '' : s.value}`;
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
} else if (s.kind === 'text') {
for (const w of wrap(s.text, 82)) lines.push({ kind: 'kv', text: w });
} else {
lines.push({ kind: s.kind, text: s.text });
}
}
return lines;
};
function buildDocumentPdf({ title, issuedToLabel, issuedTo, sections } = {}) {
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 lines = flattenSections(sections);
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 /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
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(title || 'OASIS')}) Tj\nET`);
if (issuedTo) {
const prefix = `${issuedToLabel || '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(issuedTo))}) 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, 'latin1')} >>\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 = { buildDocumentPdf, escapePdf, wrap };

View file

@ -11,7 +11,7 @@ const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship
};
const announceSeenPath = () => {
try { return require('../server/ssb_config').statePath(ANNOUNCE_SEEN_FILE); } catch (_) { return null; }
try { return require('../configs/state-manager').statePath(ANNOUNCE_SEEN_FILE); } catch (_) { return null; }
};
const readAnnounceSeen = () => {

View file

@ -0,0 +1,325 @@
const { a, img, video, audio, span, strong, em, u, s: strike, code, div, textarea } = require("../server/node_modules/hyperaxe");
const i18nBase = require("../client/assets/translations/i18n");
const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model");
let zoomSeq = 0;
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
const ESCAPE_RE = /\\([\\*_~`#>\-\[\]!])/g;
const CODE_BLOCK_RE = /```([\s\S]*?)```[ \t]*(?:\n(?:[ \t]*\n)*|$)/g;
const RULE_RE = /^[ \t]*---[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const HEADER_CLOSED_RE = /^[ \t]*(#{1,6})[ \t]*([^\n]{1,200}?)[ \t]*\1[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const HEADER_SPACED_RE = /^[ \t]*(#{1,6})[ \t]+([^\n]{1,200}?)[ \t]*#*[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const QUOTE_RE = /^[ \t]*>[ \t]?([^\n]*)(?:\n(?:[ \t]*\n)*|$)/gm;
const BULLET_RE = /^([ \t]*)-[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
const NUMBER_RE = /^([ \t]*)(\d{1,3})([.)])[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
const BOLD_RE = /\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?<!\s)\*\*/g;
const UNDERLINE_RE = /(?<![A-Za-z0-9])__(?!\s)((?:[^_\n]|_(?!_))+?)(?<!\s)__(?![A-Za-z0-9])/g;
const STRIKE_RE = /~~(?!\s)([^~\n]+?)(?<!\s)~~/g;
const ITALIC_RE = /\*(?!\s)((?:[^*\n]|\*\*)+?)(?<!\s)\*(?!\*)/g;
const INLINE_CODE_RE = /`([^`\n]+)`/g;
const BLOB_IMAGE_RE = /!\[([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
const BLOB_VIDEO_RE = /\[video:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
const BLOB_AUDIO_RE = /\[audio:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
const BLOB_PDF_RE = /\[pdf:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
const BLOB_TORRENT_RE = /\[torrent:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256)\s*\)/g;
const BLOB_FILE_RE = /(?<!!)\[(?!(?:video|audio|pdf|torrent):)([^\]\n]{1,160})\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const MD_MENTION_RE = /\[@([^\]\[\n]{1,120})\]\(@?([A-Za-z0-9+/=.\-]{1,120}\.ed25519)\)/g;
const RAW_MENTION_RE = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
const BARE_MENTION_RE = /(^|[^\w\u0080-\uFFFF\[\]/@])@([\w\u0080-\uFFFF.\-]{1,64})(?=$|[^\w\u0080-\uFFFF/+=])/g;
let mentionResolver = null;
const setMentionResolver = (fn) => { mentionResolver = typeof fn === 'function' ? fn : null; };
const bareMentionName = (token) => (/\.ed25519$/i.test(token) ? '' : token.replace(/[.\-]+$/, ''));
const MSG_REF_RE = /%[A-Za-z0-9+/=]{44}\.sha256/g;
const MD_LINK_RE = /!?\[([^\]\n]{1,160})\]\((https?:\/\/[^)\s]+|\/(?![\/\\])[^)\s]*)\)/g;
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`]+/gi;
const EMAIL_RE = /\b[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@(?:[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,24}\b/g;
const HASHTAG_RE = /#[\w\u0080-\uFFFF]{1,32}(?![\w\u0080-\uFFFF])/g;
const URL_TAIL_RE = /[.,;:!?»"')\]}>]+$/;
const SELF_HOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/i;
const INTERNAL_PATHS = [
'agenda','ai','audios','author','backup','banking','blockexplorer','blogs','bookmarks','calendars','campaigns',
'chats','cipher','courts','cv','dev','docs','emergencies','events','favorites','feed','files','forum','games','graphos',
'hashtag','housing','images','industry','inbox','inhabitants','invites','jobs','larp','logistics','logs','mailing',
'maps','market','melody','mentions','modules','multiverse','opinions','pads','parliament','rooms','peers','pixelia','pm',
'podcasts','polls','popular','profile','projects','publish','reports','school','search','settings','shops','spread',
'stats','tags','tasks','thread','torrents','transfers','trending','tribes','tribe','videos','votes','votations','wallet','wiki'
];
const INTERNAL_PATH_SET = new Set(INTERNAL_PATHS);
const SSB_ID_RE = /^(?:%[A-Za-z0-9+/=]{20,}\.sha256|@[A-Za-z0-9+/=]{20,}\.ed25519|&[A-Za-z0-9+/=]{20,}\.sha256)$/;
const internalTargetOf = (url) => {
let parsed = null;
try { parsed = new URL(url); } catch (_) { return null; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
const segments = parsed.pathname.split('/').filter(Boolean).map(seg => { try { return decodeURIComponent(seg); } catch (_) { return seg; } });
if (!segments.length || !INTERNAL_PATH_SET.has(segments[0].toLowerCase())) return null;
const sameHost = SELF_HOST_RE.test(url);
const hasSsbId = segments.slice(1).some(seg => SSB_ID_RE.test(seg));
if (!sameHost && !hasSsbId) return null;
const id = segments.slice(1).find(seg => SSB_ID_RE.test(seg)) || segments[1] || '';
return { href: `${parsed.pathname}${parsed.search}${parsed.hash}`, kind: segments[0].toLowerCase(), id };
};
const shortInternalId = (id) => {
const value = String(id || '');
if (!value) return '';
const sigil = /^[%@&]/.test(value) ? value[0] : '';
const body = sigil ? value.slice(1) : value;
return body.length > 8 ? `${sigil}${body.slice(0, 8)}…` : `${sigil}${body}`;
};
const MAX_DEPTH = 8;
const headerLevel = (hashes) => Math.min(3, String(hashes || '#').length);
const indentLevel = (indent) => {
const width = String(indent || '').replace(/\t/g, ' ').length;
return Math.min(4, Math.floor(width / 2) + 1);
};
const codeBlockBody = (raw) => {
const s = String(raw);
const nl = s.indexOf('\n');
if (nl === -1) return s;
return s.slice(nl + 1).replace(/\n[ \t]*$/, '');
};
function renderStyledText(value, opts = {}) {
if (typeof value !== 'string') return value === null || value === undefined ? [] : [String(value)];
const text = value.indexOf('\r') === -1 ? value : value.replace(/\r\n?/g, '\n');
const depth = Number(opts.depth) || 0;
if (depth > MAX_DEPTH) return [text];
const allMatches = [];
const push = (m, extra) => allMatches.push({ index: m.index, length: m[0].length, ...extra });
for (const m of text.matchAll(ESCAPE_RE)) push(m, { type: 'escaped', body: m[1] });
for (const m of text.matchAll(WIKILINK_RE)) push(m, { type: 'wikilink', target: linkTarget(m[1]), label: (m[2] || linkTarget(m[1])).trim() });
for (const m of text.matchAll(CODE_BLOCK_RE)) push(m, { type: 'code-block', body: codeBlockBody(m[1]) });
for (const m of text.matchAll(RULE_RE)) push(m, { type: 'rule' });
for (const m of text.matchAll(HEADER_CLOSED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
for (const m of text.matchAll(HEADER_SPACED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
for (const m of text.matchAll(QUOTE_RE)) push(m, { type: 'quote', body: m[1] });
for (const m of text.matchAll(BULLET_RE)) push(m, { type: 'bullet', level: indentLevel(m[1]), body: m[2] });
for (const m of text.matchAll(NUMBER_RE)) push(m, { type: 'number', level: indentLevel(m[1]), marker: `${m[2]}${m[3]}`, body: m[4] });
for (const m of text.matchAll(BLOB_IMAGE_RE)) push(m, { type: 'blob-image', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_VIDEO_RE)) push(m, { type: 'blob-video', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_AUDIO_RE)) push(m, { type: 'blob-audio', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_PDF_RE)) push(m, { type: 'blob-pdf', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_TORRENT_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_FILE_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
for (const m of text.matchAll(MD_MENTION_RE)) push(m, { type: 'md-mention', name: m[1], feedId: m[2] });
for (const m of text.matchAll(RAW_MENTION_RE)) push(m, { type: 'raw-mention', feedId: m[1] });
for (const m of text.matchAll(BARE_MENTION_RE)) {
const name = bareMentionName(m[2]);
if (name) allMatches.push({ index: m.index + m[1].length, length: name.length + 1, type: 'bare-mention', name });
}
for (const m of text.matchAll(MSG_REF_RE)) push(m, { type: 'msg-ref', id: m[0] });
for (const m of text.matchAll(MD_LINK_RE)) push(m, { type: 'md-link', label: m[1], href: m[2] });
for (const m of text.matchAll(BOLD_RE)) push(m, { type: 'bold', body: m[1] });
for (const m of text.matchAll(UNDERLINE_RE)) push(m, { type: 'underline', body: m[1] });
for (const m of text.matchAll(STRIKE_RE)) push(m, { type: 'strike', body: m[1] });
for (const m of text.matchAll(ITALIC_RE)) push(m, { type: 'italic', body: m[1] });
for (const m of text.matchAll(INLINE_CODE_RE)) push(m, { type: 'inline-code', body: m[1] });
for (const m of text.matchAll(URL_RE)) {
const trimmed = m[0].replace(URL_TAIL_RE, '');
if (trimmed) allMatches.push({ index: m.index, length: trimmed.length, type: 'url', text: trimmed });
}
for (const m of text.matchAll(EMAIL_RE)) push(m, { type: 'email', text: m[0] });
for (const m of text.matchAll(HASHTAG_RE)) push(m, { type: 'hashtag', tag: m[0].slice(1) });
allMatches.sort((x, y) => x.index - y.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 blobHref = (id) => `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`;
const linksOn = opts.links !== false;
const internalOn = opts.internalLinks !== false;
const plain = (value) => opts.plainUrlClass ? span({ class: opts.plainUrlClass }, value) : value;
const inner = (body) => renderStyledText(body, { ...opts, depth: depth + 1 });
const result = [];
let cursor = 0;
for (const m of filtered) {
if (cursor < m.index) result.push(text.slice(cursor, m.index));
if (m.type === 'escaped') {
result.push(m.body);
} else if (m.type === 'msg-ref') {
result.push(internalOn ? a({ href: `/thread/${encodeURIComponent(m.id)}`, class: 'styled-link' }, m.id.slice(0, 10) + '...') : m.id);
} else if (m.type === 'wikilink') {
result.push(typeof opts.wikiLink === 'function'
? opts.wikiLink(m.target, slugify(m.target), m.label)
: internalOn
? a({ href: `/wiki/${encodeURIComponent(slugify(m.target))}`, class: 'wiki-link' }, m.label)
: m.label);
} else if (m.type === 'code-block') {
result.push(span({ class: 'rt-code-block' }, m.body));
} else if (m.type === 'rule') {
result.push(span({ class: 'rt-rule' }));
} else if (m.type === 'header') {
result.push(span({ class: `rt-header rt-header-${m.level}` }, ...inner(m.body)));
} else if (m.type === 'quote') {
result.push(span({ class: 'rt-quote' }, ...inner(m.body)));
} else if (m.type === 'bullet') {
result.push(span({ class: `rt-item rt-item-${m.level}` }, ...inner(m.body)));
} else if (m.type === 'number') {
result.push(span({ class: `rt-item rt-item-number rt-item-${m.level}` }, `${m.marker} `, ...inner(m.body)));
} else if (m.type === 'bold') {
result.push(strong(...inner(m.body)));
} else if (m.type === 'underline') {
result.push(u(...inner(m.body)));
} else if (m.type === 'strike') {
result.push(strike(...inner(m.body)));
} else if (m.type === 'italic') {
result.push(em(...inner(m.body)));
} else if (m.type === 'inline-code') {
result.push(code({ class: 'rt-code' }, m.body));
} else if (m.type === 'blob-image') {
const imageSrc = blobHref(m.blob);
if (opts.zoomImages) {
zoomSeq = (zoomSeq + 1) % 1000000;
const zoomId = `rtzoom-${zoomSeq}-${String(m.blob).replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
result.push(a({ href: `#${zoomId}`, id: `${zoomId}-src`, class: 'zoom-link' }, img({ src: imageSrc, alt: m.name || '', class: 'post-image' })));
result.push(span({ id: zoomId, class: 'lightbox' },
a({ href: `${imageSrc}${imageSrc.includes('?') ? '&' : '?'}download=1`, class: 'lightbox-download', title: getI18n().fileShareDownload || 'Download' }, '\u2913'),
a({ href: `#${zoomId}-src`, class: 'lightbox-close' }, '\u00d7'),
img({ src: imageSrc, alt: m.name || '', class: 'lightbox-image' })
));
} else {
result.push(img({ src: imageSrc, alt: m.name || '', class: 'post-image' }));
}
} else if (m.type === 'blob-video') {
result.push(video({ controls: true, class: 'post-video', src: blobHref(m.blob) }));
} else if (m.type === 'blob-audio') {
result.push(audio({ controls: true, class: 'post-audio', src: blobHref(m.blob) }));
} else if (m.type === 'blob-pdf') {
const i18n = getI18n();
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || i18n.pdfFallbackLabel || 'PDF'));
} else if (m.type === 'blob-file') {
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || m.blob.slice(1, 9)));
} else if (m.type === 'md-mention') {
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
} else if (m.type === 'raw-mention') {
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...') : '@' + m.feedId.slice(0, 8) + '...');
} else if (m.type === 'bare-mention') {
const feedId = internalOn && mentionResolver ? mentionResolver(m.name) : null;
result.push(feedId ? a({ href: `/author/${encodeURIComponent(feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
} else if (m.type === 'md-link') {
const external = /^https?:\/\//.test(m.href) && !SELF_HOST_RE.test(m.href);
result.push(external
? (linksOn ? a({ href: m.href, class: 'styled-link', target: '_blank', rel: 'noopener noreferrer' }, ...inner(m.label)) : plain(m.label))
: (internalOn ? a({ href: m.href, class: 'styled-link' }, ...inner(m.label)) : m.label));
} else if (m.type === 'url') {
const schemeMatch = m.text.match(/^(https?):\/\//i);
const href = schemeMatch ? `${schemeMatch[1].toLowerCase()}://${m.text.slice(schemeMatch[0].length)}` : `https://${m.text}`;
const internalTarget = internalTargetOf(href);
if (!linksOn) result.push(plain(m.text));
else if (internalTarget) {
result.push(internalOn
? a({ href: internalTarget.href, class: 'internal-link' },
span({ class: 'internal-link-kind' }, internalTarget.kind.toUpperCase()),
internalTarget.id ? span({ class: 'internal-link-id' }, shortInternalId(internalTarget.id)) : null
)
: plain(internalTarget.href));
}
else if (SELF_HOST_RE.test(href)) result.push(a({ href }, m.text));
else result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
} else if (m.type === 'email') {
result.push(linksOn ? a({ href: `mailto:${m.text}` }, m.text) : plain(m.text));
} else if (m.type === 'hashtag') {
const tagHref = typeof opts.hashtagHref === 'function' ? opts.hashtagHref(m.tag) : (internalOn ? `/search?query=%23${encodeURIComponent(m.tag)}` : null);
result.push(tagHref ? a({ href: tagHref, class: 'tag-link tag-inline' }, `#${m.tag}`) : `#${m.tag}`);
}
cursor = m.index + m.length;
}
if (cursor < text.length) result.push(text.slice(cursor));
return result;
}
const safeExternalHref = (url) => {
const value = String(url || '').trim();
const lower = value.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('mailto:')) return value;
return '';
};
const escapeHtml = (value) => String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
function renderStyledHtml(text, opts = {}) {
if (text === null || text === undefined || text === '') return '';
return renderStyledText(String(text), opts)
.map(node => (node && typeof node === 'object' && node.outerHTML) ? node.outerHTML : escapeHtml(node))
.join('');
}
function plainText(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/```[\s\S]*?```/g, '')
.replace(/^[ \t]*>[ \t]?/gm, '')
.replace(/^[ \t]*---[ \t]*$/gm, '')
.replace(/^[ \t]*#{1,6}[ \t]*/gm, '')
.replace(/[ \t]*#{1,6}[ \t]*$/gm, '')
.replace(/^[ \t]*- /gm, '')
.replace(/^[ \t]*\d{1,3}[.)] /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!?\[[^\]\n]{0,160}\]\(\s*&[^)\s\n]{1,120}\.sha256\s*\)/g, '')
.replace(/\[(?:video|audio|pdf):[^\]\n]{0,160}\]\([^)\n]{0,200}\)/g, '')
.replace(/\[\[([^\]|\n]{1,120})(?:\|([^\]\n]{1,120}))?\]\]/g, (m, target, label) => label || target)
.replace(/!?\[([^\]\n]{1,160})\]\(([^)\s\n]{0,200})\)/g, (m, label, href) => href ? `${label} (${href})` : label)
.replace(/\\([\\*_~`#>\-\[\]!])/g, '$1')
.trim();
}
function renderTextPreview(text, maxLength = 220) {
const preview = plainText(text).replace(/\n+/g, ' ').trim();
return preview.length > maxLength ? preview.slice(0, maxLength) + '...' : preview;
}
const FORMAT_MARKS = [
{ mark: '**bold**', style: 'bold' },
{ mark: '*italic*', style: 'italic' },
{ mark: '__underline__', style: 'underline' },
{ mark: '~~strike~~', style: 'strike' },
{ mark: '`code`', style: 'code' },
{ mark: '### title ###', style: 'header' },
{ mark: '- list', style: 'item' },
{ mark: '> quote', style: 'quote' },
{ mark: '[[wiki]]', style: 'wiki' }
];
function renderFormatBar() {
const i18n = getI18n();
const marks = [];
FORMAT_MARKS.forEach((m, i) => {
if (i) marks.push(span({ class: 'rt-sep' }, '|'));
marks.push(span({ class: `rt-mark rt-mark-${m.style}` }, m.mark));
});
return div({ class: 'rt-toolbar' },
span({ class: 'rt-toolbar-label' }, `${i18n.formatBarLabel || 'Format'}:`),
...marks
);
}
function richTextarea(attrs, ...children) {
return [renderFormatBar(), textarea(attrs, ...children)];
}
module.exports = { renderStyledText, renderStyledHtml, renderTextPreview, plainText, escapeHtml, safeExternalHref, renderFormatBar, richTextarea, setMentionResolver, INTERNAL_PATHS };

View file

@ -1,139 +0,0 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
`<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
)
.replace(/\[video:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></video>`
)
.replace(/\[audio:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></audio>`
)
.replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
)
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, '&quot;').replace(/'/g, '&#39;')
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 }

View file

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

View file

@ -0,0 +1,99 @@
const MSG_KEY_RE = /^%[A-Za-z0-9+/]{43}=\.sha256$/;
const isMsgKey = (value) => typeof value === 'string' && MSG_KEY_RE.test(value);
const pickMsgKeys = (value) => {
const list = Array.isArray(value) ? value : (value == null ? [] : [value]);
return Array.from(new Set(list.filter(isMsgKey)));
};
const isLocalPath = (value) => {
if (typeof value !== 'string' || !value.startsWith('/')) return false;
if (value.startsWith('//') || value.startsWith('/\\')) return false;
if (/[\r\n\0]/.test(value)) return false;
return true;
};
const SHARED_RETURN_PAGES = ['/activity', '/agenda', '/search', '/trending', '/favorites', '/opinions', '/mentions', '/melody', '/author', '/inhabitant', '/tribe'];
const isSharedReturnPage = (rt) => SHARED_RETURN_PAGES.some(p => rt === p || (rt.startsWith(p) && /^[?#/]/.test(rt.slice(p.length))));
const safeReturnTo = (ctx, fallback, allowedPrefixes) => {
const rt = ctx?.request?.body?.returnTo || ctx?.query?.returnTo;
if (!isLocalPath(rt)) return fallback;
if (isSharedReturnPage(rt)) return rt;
return Array.isArray(allowedPrefixes) && allowedPrefixes.some(p => rt.startsWith(p)) ? rt : fallback;
};
const refererPath = (referer, host) => {
if (!referer) return null;
try {
const u = new URL(referer);
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.host !== host) return null;
const target = u.pathname + u.search + u.hash;
return isLocalPath(target) ? target : null;
} catch (_) {
return null;
}
};
const safeRefererRedirect = (ctx, fallback = '/') => {
const target = refererPath(ctx.request.header.referer, ctx.host);
ctx.redirect(target || fallback);
};
const isClearnetPath = (request) => {
const url = String((request && request.url) || '');
return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?');
};
const isTrustedRequest = (request, validHosts) => {
if (isClearnetPath(request)) return request.method === 'GET';
if (!Array.isArray(validHosts) || !validHosts.includes(request.hostname)) return false;
if (request.method === 'GET') return true;
const origin = request.header && request.header.origin;
const source = origin && origin !== 'null' ? origin : (request.header && request.header.referer);
if (source == null) return false;
try {
const u = new URL(source);
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || !validHosts.includes(u.hostname)) return false;
if (u.host !== String(request.host || '')) return false;
if (u.pathname.startsWith('/blob/')) return false;
} catch (_) {
return false;
}
return true;
};
const publicModeGuard = ({ isPublic, onBlocked }) => async (ctx, next) => {
if (isPublic && ctx.method !== 'GET') { onBlocked(ctx); return; }
await next();
};
const buildCsp = (isClearnet, { frames = false } = {}) => (isClearnet
? [
"default-src 'self'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"media-src 'self' blob:",
"connect-src 'self'",
"frame-src 'none'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
]
: [
"default-src 'self'",
"script-src 'self' http://localhost:3000/js",
"style-src 'self'",
"img-src 'self'",
"media-src 'self' blob:",
"worker-src 'self' blob:",
frames ? "frame-src 'self'" : "frame-src 'none'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
]).join('; ');
module.exports = { isMsgKey, pickMsgKeys, isLocalPath, safeReturnTo, refererPath, safeRefererRedirect, isClearnetPath, isTrustedRequest, publicModeGuard, buildCsp };

View file

@ -1,14 +1,32 @@
"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 SAFE_SRC = /^\/(?:blob|image)\/[^\s"'<>]+$/i;
const SAFE_HREF = /^(?:https?:\/\/|\/(?!\/)|#|mailto:)/i;
const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/;
let purifier = null;
const getPurify = () => {
if (purifier) return purifier;
const { JSDOM } = require('../server/node_modules/jsdom');
const DOMPurify = require('../server/node_modules/dompurify');
const purify = DOMPurify(new JSDOM('').window);
purify.addHook('uponSanitizeAttribute', (node, data) => {
const name = String(data.attrName || '').toLowerCase();
const value = String(data.attrValue || '').trim();
if (name === 'src' && !SAFE_SRC.test(value)) data.keepAttr = false;
if (name === 'href' && !SAFE_HREF.test(value)) data.keepAttr = false;
if (name === 'class') {
const kept = value.split(/\s+/).filter(c => SAFE_CLASS.test(c));
if (kept.length) data.attrValue = kept.join(' '); else data.keepAttr = false;
}
});
purifier = purify;
return purifier;
};
const dropControlChars = (html) => String(html).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
const stripDangerousTags = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
return dropControlChars(getPurify().sanitize(input, {
ALLOWED_TAGS: [
'p', 'br',
'b', 'strong', 'i', 'em', 'u',
@ -19,13 +37,12 @@ const stripDangerousTags = (input) => {
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 },
return dropControlChars(getPurify().sanitize(input, {
ALLOWED_TAGS: [
'p', 'br', 'hr',
'b', 'strong', 'i', 'em', 'u', 's', 'del',
@ -39,7 +56,7 @@ const sanitizeHtml = (input) => {
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 };

View file

@ -1,228 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const { escapePdf } = require('./pdfDocument');
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 /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
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, 'latin1')} >>\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 };

View file

@ -1,12 +1,50 @@
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
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json';
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json';
let printed = false;
const repoRoot = join(__dirname, '..', '..');
const updateFlagFile = join(__dirname, '../server/.update_required');
const git = (args, cwd = repoRoot) => new Promise((resolve) => {
require('child_process').execFile('git', args, { cwd, timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (err, out) => resolve(err ? '' : String(out || '').trim()));
});
const commitOf = (line) => {
const [hash, date, ...rest] = String(line || '').split('\t');
return hash ? { hash, date: date || '', subject: rest.join('\t') } : null;
};
const cleanOrigin = (url) => String(url || '').replace(/\/\/[^@\/]*@/, '//').replace(/\.git$/, '');
async function collectDetails(root = repoRoot) {
if (!existsSync(join(root, '.git'))) return null;
const installed = commitOf(await git(['log', '-1', '--format=%h%x09%cI%x09%s'], root));
await git(['fetch', '--quiet'], root);
const upstream = (await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], root)) || 'origin/master';
const available = commitOf(await git(['log', '-1', '--format=%h%x09%cI%x09%s', upstream], root));
const commits = (await git(['log', '--format=%h%x09%cI%x09%s', '-n', '10', `HEAD..${upstream}`], root)).split('\n').map(commitOf).filter(Boolean);
const count = Number(await git(['rev-list', '--count', `HEAD..${upstream}`], root)) || commits.length;
const stat = await git(['diff', '--shortstat', 'HEAD', upstream], root);
const num = (re) => { const m = stat.match(re); return m ? Number(m[1]) : 0; };
return {
installed, available, commits, count,
files: num(/(\d+) files? changed/), insertions: num(/(\d+) insertions?/), deletions: num(/(\d+) deletions?/),
origin: cleanOrigin(await git(['remote', 'get-url', 'origin'], root))
};
}
exports.collectDetails = collectDetails;
exports.readUpdateInfo = () => {
if (!existsSync(updateFlagFile)) return null;
try {
const info = JSON.parse(readFileSync(updateFlagFile, 'utf8'));
return info && info.required ? info : { required: true };
} catch (_) {
return { required: true };
}
};
async function extractVersionFromText(text) {
try {
@ -33,11 +71,22 @@ async function diffVersion(body, callback) {
const updateFlagPath = join(__dirname, "../server/.update_required");
if (remoteVersion !== localVersion) {
writeFileSync(updateFlagPath, JSON.stringify({ required: true }));
const alreadyKnown = existsSync(updateFlagPath);
writeFileSync(updateFlagPath, JSON.stringify({ required: true, version: remoteVersion, installedVersion: localVersion }));
collectDetails().then((details) => {
if (details && existsSync(updateFlagPath)) writeFileSync(updateFlagPath, JSON.stringify({ required: true, version: remoteVersion, installedVersion: localVersion, details }));
}).catch(() => {});
if (!alreadyKnown) {
try {
const { notify, i18nNow } = require('./desktopNotify');
const i18n = i18nNow();
notify(i18n.notifyUpdateLabel, `${i18n.updateBannerText} (${remoteVersion})`);
} catch (_) {}
}
callback("required");
} else {
if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
callback(""); // no updates required
callback("");
}
} catch (error) {
console.error("Error comparing versions:", error.message);
@ -49,15 +98,8 @@ 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'
}
headers: { 'Accept': 'application/json, text/plain, */*' },
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
@ -72,20 +114,154 @@ async function checkMirror(callback) {
}
}
const networkAllowed = () => {
if (process.argv.includes('--offline') || process.env.OASIS_NETWORK_PAUSED === '1') return false;
try { if (require('../server/ssb_config').offline === true) return false; } catch (_) {}
try { if (require('../configs/config-manager.js').getConfig().networkPaused === true) return false; } catch (_) {}
return true;
};
const MOBILE_RELEASE_SOURCES = [
{ url: 'https://code.03c8.net/KrakensLab/oasis/releases', type: 'html' },
{ url: 'https://api.github.com/repos/epsylon/oasis/releases?per_page=20', type: 'json' }
];
const MOBILE_APK_RE = /^oasis-v(\d+\.\d+\.\d+)\.apk$/i;
const MOBILE_APK_HTML_RE = /oasis-v(\d+\.\d+\.\d+)\.apk/i;
const ECOIN_RELEASES_API = 'https://api.github.com/repos/epsylon/ecoin/releases?per_page=20';
const ECOIN_RELEASES_PAGE = 'https://github.com/epsylon/ecoin/releases';
const ECOIN_APK_RE = /^ecoin-wallet-v(\d+(?:\.\d+)*)\.apk$/i;
const MOBILE_FETCH_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-Language': 'en-US,en;q=0.9'
};
let ecoinWalletApk = '';
const versionParts = (v) => String(v || '').split('.').map(n => parseInt(n, 10) || 0);
const isNewerVersion = (remote, local) => {
const a = versionParts(remote), b = versionParts(local);
const len = Math.max(a.length, b.length, 3);
for (let i = 0; i < len; i++) {
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
}
return false;
};
const localMobileVersion = () => {
const fromEnv = String(process.env.OASIS_ANDROID_VERSION || '').split('-')[0].trim();
if (fromEnv) return fromEnv;
try {
return JSON.parse(readFileSync(localpackage, 'utf8')).version || '';
} catch (error) {
return '';
}
};
const pickReleaseFromJson = (releases) => {
for (const rel of Array.isArray(releases) ? releases : []) {
if (!rel || rel.draft) continue;
for (const asset of Array.isArray(rel.assets) ? rel.assets : []) {
const match = MOBILE_APK_RE.exec(String((asset && asset.name) || ''));
if (match) {
return {
version: match[1],
page: String(rel.html_url || ''),
apk: String(asset.browser_download_url || '')
};
}
}
}
return null;
};
const pickReleaseFromHtml = (html, pageUrl) => {
const match = MOBILE_APK_HTML_RE.exec(String(html || ''));
return match ? { version: match[1], page: pageUrl, apk: '' } : null;
};
const fetchMobileRelease = async (source) => {
const isHtml = source.type === 'html';
const response = await fetch(source.url, {
method: 'GET',
headers: { ...MOBILE_FETCH_HEADERS, 'Accept': isHtml ? 'text/html,application/xhtml+xml' : 'application/json, text/plain, */*' }
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
return isHtml
? pickReleaseFromHtml(await response.text(), source.url)
: pickReleaseFromJson(await response.json());
};
const refreshEcoinWalletApk = async () => {
try {
const response = await fetch(ECOIN_RELEASES_API, {
method: 'GET',
headers: { ...MOBILE_FETCH_HEADERS, 'Accept': 'application/json, text/plain, */*' }
});
if (!response.ok) return;
const releases = await response.json();
let best = null;
for (const rel of Array.isArray(releases) ? releases : []) {
if (!rel || rel.draft) continue;
for (const asset of Array.isArray(rel.assets) ? rel.assets : []) {
const match = ECOIN_APK_RE.exec(String((asset && asset.name) || ''));
if (!match || !asset.browser_download_url) continue;
if (!best || isNewerVersion(match[1], best.version)) best = { version: match[1], apk: String(asset.browser_download_url) };
}
}
if (best) ecoinWalletApk = best.apk;
} catch (error) {}
};
exports.ecoinWalletDownloadUrl = () => {
if (String(process.env.OASIS_ANDROID_INSTALLER || '') === 'com.android.vending') return ECOIN_RELEASES_PAGE;
if (!ecoinWalletApk) refreshEcoinWalletApk();
return ecoinWalletApk || ECOIN_RELEASES_PAGE;
};
exports.getMobileRemoteVersion = async () => {
await refreshEcoinWalletApk();
if (String(process.env.OASIS_ANDROID_INSTALLER || '') === 'com.android.vending') return;
const localVersion = localMobileVersion();
if (!localVersion) return;
const updateFlagPath = join(__dirname, '../server/.update_required');
const found = [];
for (const source of MOBILE_RELEASE_SOURCES) {
try {
const candidate = await fetchMobileRelease(source);
if (candidate) found.push(candidate);
} catch (error) {}
}
if (!found.length) return;
const top = found.reduce((best, r) => (isNewerVersion(r.version, best.version) ? r : best), found[0]);
const sameVersion = found.filter(r => r.version === top.version);
const withApk = sameVersion.find(r => r.apk);
const release = { version: top.version, page: sameVersion[0].page, apk: withApk ? withApk.apk : '' };
if (isNewerVersion(release.version, localVersion)) {
try {
writeFileSync(updateFlagPath, JSON.stringify({
required: true,
version: release.version,
installedVersion: localVersion,
page: release.page || release.apk,
apk: release.apk
}));
} catch (error) {}
console.log(`\noasis@version: Oasis ${release.version} is available at ${release.apk || release.page}\n`);
} else if (existsSync(updateFlagPath)) {
try { unlinkSync(updateFlagPath); } catch (error) {}
}
};
exports.getRemoteVersion = async () => {
if (existsSync('../../.git')) {
if (process.env.OASIS_MOBILE === '1') return exports.getMobileRemoteVersion();
if (!networkAllowed()) return;
if (existsSync(join(__dirname, '..', '..', '.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'
}
headers: { 'Accept': 'application/json, text/plain, */*' },
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {

View file

@ -1,116 +1,119 @@
const buildVoteTally = (messages) => {
const nodes = new Map();
const ballots = [];
const INSTITUTIONAL_VOTE_TAG = /^(gov|govMethod|courtsCase|courtsMethod):/;
for (const m of messages) {
const msOf = (v) => {
const t = Date.parse(v);
return Number.isFinite(t) ? t : null;
};
const isLater = (a, b) => a.ts > b.ts || (a.ts === b.ts && a.seq > b.seq);
const legacyChoice = (kid, parent, options) => {
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters : [];
const pVoters = Array.isArray(parent.content.voters) ? parent.content.voters : [];
if (!kVoters.includes(kid.author) || pVoters.includes(kid.author)) return null;
const kVotes = kid.content.votes || {};
const pVotes = parent.content.votes || {};
let choice = null;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(pVotes[o]) || 0);
if (d === 0) continue;
if (d !== 1 || choice !== null) return null;
choice = o;
}
return choice;
};
const buildVoteResults = (messages) => {
const nodes = new Map();
const ballotsByTarget = new Map();
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; }
const ts = Number(v.timestamp || m.timestamp || 0) || 0;
const seq = Number(v.sequence || 0) || 0;
if (c.type === 'votesVote') {
if (typeof c.target !== 'string' || !v.author) continue;
if (!ballotsByTarget.has(c.target)) ballotsByTarget.set(c.target, []);
ballotsByTarget.get(c.target).push({ author: v.author, choice: c.choice, ts, seq });
continue;
}
if (c.type !== 'votes') continue;
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts: v.timestamp || m.timestamp || 0 });
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts, seq });
}
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);
if (typeof t === 'string' && t !== k && nodes.has(t)) prev.set(k, t);
}
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();
const results = 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;
const creator = rootNode.author;
const tags = Array.isArray(rootNode.content.tags) ? rootNode.content.tags : [];
const frozen = tags.some(t => INSTITUTIONAL_VOTE_TAG.test(String(t)));
let current = rootNode;
if (!frozen) {
for (const k of keys) {
const n = nodes.get(k);
if (n.author === creator && isLater(n, current)) current = n;
}
if (!advanced) break;
}
const options = Array.isArray(current.content.options) ? current.content.options.filter(o => typeof o === 'string') : [];
const deadline = typeof current.content.deadline === 'string' ? current.content.deadline : null;
const opensAt = rootNode.ts;
const closesAt = deadline ? msOf(deadline) : null;
const cast = [];
for (const k of keys) {
for (const b of ballotsByTarget.get(k) || []) cast.push(b);
const n = nodes.get(k);
if (n.author === creator || !prev.has(k)) continue;
const choice = legacyChoice(n, nodes.get(prev.get(k)), options);
if (choice !== null) cast.push({ author: n.author, choice, ts: n.ts, seq: n.seq });
}
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;
const latest = new Map();
for (const b of cast) {
if (!b.author || b.author === creator) 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;
if (b.ts < opensAt) continue;
if (closesAt !== null && b.ts > closesAt) continue;
const known = latest.get(b.author);
if (!known || isLater(b, known)) latest.set(b.author, b);
}
const tally = { votes: votesMap, voters, totalVotes };
for (const k of keys) tallyByKey.set(k, tally);
const votes = {};
for (const o of options) votes[o] = 0;
const voters = [];
for (const b of [...latest.values()].sort((a, b) => a.ts - b.ts || a.seq - b.seq)) {
votes[b.choice] = (votes[b.choice] || 0) + 1;
voters.push(b.author);
}
const result = { votes, voters, totalVotes: voters.length, creator, rootId: root, deadline, options };
for (const k of keys) results.set(k, result);
}
return results;
};
const buildVoteTally = (messages) => {
const shared = new Map();
const tallyByKey = new Map();
for (const [k, r] of buildVoteResults(messages)) {
if (!shared.has(r)) shared.set(r, { votes: r.votes, voters: r.voters, totalVotes: r.totalVotes });
tallyByKey.set(k, shared.get(r));
}
return tallyByKey;
};
module.exports = { buildVoteTally };
module.exports = { buildVoteTally, buildVoteResults };

View file

@ -1,17 +0,0 @@
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 };