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:
parent
8d838dd8b1
commit
c332afd6ea
265 changed files with 80970 additions and 29167 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 : []);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
80
src/backend/desktopNotify.js
Normal file
80
src/backend/desktopNotify.js
Normal 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 };
|
||||
|
|
@ -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
195
src/backend/long_text.js
Normal 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
|
||||
};
|
||||
|
|
@ -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
1485
src/backend/pdf.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 };
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
325
src/backend/renderStyledText.js
Normal file
325
src/backend/renderStyledText.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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 };
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
html = html
|
||||
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
|
||||
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
|
||||
.replace(/^---$/gim, '<hr/>')
|
||||
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
|
||||
|
||||
html = html
|
||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/gim, '<code>$1</code>')
|
||||
|
||||
html = html
|
||||
.replace(/!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
|
||||
`<img src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" alt="${alt}" class="post-image" />`
|
||||
)
|
||||
.replace(/\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
|
||||
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}"></video>`
|
||||
)
|
||||
.replace(/\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
|
||||
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}"></audio>`
|
||||
)
|
||||
.replace(/\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
|
||||
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
|
||||
)
|
||||
|
||||
html = html
|
||||
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
|
||||
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
|
||||
)
|
||||
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
|
||||
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
|
||||
)
|
||||
|
||||
const escAttr = (s) => String(s).replace(/"/g, '"').replace(/'/g, ''')
|
||||
html = html
|
||||
.replace(/#(\w+)/g, (_, tag) =>
|
||||
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
|
||||
)
|
||||
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
|
||||
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
|
||||
)
|
||||
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
|
||||
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
|
||||
)
|
||||
|
||||
const lines = html.split('\n')
|
||||
let result = ''
|
||||
let inUL = false
|
||||
let inOL = false
|
||||
|
||||
for (let line of lines) {
|
||||
if (/^- /.test(line)) {
|
||||
if (!inUL) {
|
||||
result += '<ul>'
|
||||
inUL = true
|
||||
}
|
||||
result += `<li>${line.replace(/^- /, '')}</li>`
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d+\. /.test(line)) {
|
||||
if (!inOL) {
|
||||
result += '<ol>'
|
||||
inOL = true
|
||||
}
|
||||
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
|
||||
continue
|
||||
}
|
||||
|
||||
if (inUL) {
|
||||
result += '</ul>'
|
||||
inUL = false
|
||||
}
|
||||
|
||||
if (inOL) {
|
||||
result += '</ol>'
|
||||
inOL = false
|
||||
}
|
||||
|
||||
result += line + '<br>'
|
||||
}
|
||||
|
||||
if (inUL) result += '</ul>'
|
||||
if (inOL) result += '</ol>'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { renderTextWithStyles, renderTextPreview }
|
||||
|
|
@ -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 };
|
||||
99
src/backend/request_guards.js
Normal file
99
src/backend/request_guards.js
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue