upstream: integrar Oasis 1.3.1

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

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

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

View file

@ -16,7 +16,7 @@ android {
targetSdk = 35
// el pipeline los pasa por entorno; a mano valen los de aqui
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
versionName = System.getenv("OASIS_VERSION_NAME") ?: "0.9.5"
versionName = System.getenv("OASIS_VERSION_NAME") ?: "1.3.1"
ndk { abiFilters += listOf("arm64-v8a") }
}

View file

@ -8,6 +8,13 @@ process.env.CHLORIDE_JS = '1';
const path = require('path');
const fs = require('fs');
try {
const androidInfo = JSON.parse(fs.readFileSync(path.join(__dirname, '.android.json'), 'utf8'));
if (androidInfo.versionName) process.env.OASIS_ANDROID_VERSION = String(androidInfo.versionName);
if (androidInfo.versionCode) process.env.OASIS_ANDROID_VERSION_CODE = String(androidInfo.versionCode);
process.env.OASIS_ANDROID_INSTALLER = String(androidInfo.installer || '');
} catch (e) {}
const tmpDir = path.join(process.env.HOME, 'tmp');
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
process.env.TMPDIR = tmpDir;

View file

@ -1,6 +1,6 @@
{
"name": "@krakenslab/oasis",
"version": "0.9.5",
"version": "1.3.1",
"description": "Oasis - Social Networking Utopia",
"repository": {
"type": "git",

105
src/AI/ai_client.js Normal file
View file

@ -0,0 +1,105 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { spawn } = require('child_process');
const axiosMod = require('../server/node_modules/axios');
const axios = axiosMod.default || axiosMod;
const net = require('net');
const BASE_PORT = Number(process.env.OASIS_AI_PORT) || 4001;
let PORT = BASE_PORT;
const portIsFree = (port) => new Promise((resolve) => {
const probe = net.createServer();
probe.once('error', () => resolve(false));
probe.once('listening', () => probe.close(() => resolve(true)));
probe.listen(port, '127.0.0.1');
});
const pickPort = async () => {
for (let p = BASE_PORT; p < BASE_PORT + 20; p++) {
if (await portIsFree(p)) return p;
}
return BASE_PORT;
};
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
const ASK_TIMEOUT_MS = 300000;
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
const STATUS_TIMEOUT_MS = 3000;
const token = crypto.randomBytes(24).toString('hex');
let child = null;
let started = false;
const isModelInstalled = () => { try { return fs.existsSync(MODEL_PATH); } catch (_) { return false; } };
let starting = null;
const start = () => {
if (started) return starting || Promise.resolve();
started = true;
if (process.env.OASIS_TEST) return Promise.resolve();
starting = pickPort().then((port) => { PORT = port; spawnService(); }).catch(() => { spawnService(); }).finally(() => { starting = null; });
return starting;
};
const spawnService = () => {
try {
child = spawn(process.execPath, [path.join(__dirname, 'ai_service.mjs')], {
stdio: ['ignore', 'ignore', DEBUG ? 'inherit' : 'ignore'],
env: { ...process.env, OASIS_AI_TOKEN: token, OASIS_AI_PORT: String(PORT), OASIS_AI_PARENT_PID: String(process.pid), OASIS_AI_MODEL: MODEL_PATH }
});
child.on('exit', () => { child = null; started = false; });
} catch (_) { child = null; started = false; }
};
const stop = () => {
if (!child) return;
try { child.kill('SIGTERM'); } catch (_) {}
child = null;
started = false;
};
const headers = () => ({ 'x-oasis-ai-token': token });
const status = async () => {
if (!isModelInstalled()) return { installed: false, ready: false, loading: false, error: 'model_missing' };
await start();
try {
const r = await axios.get(`http://127.0.0.1:${PORT}/status`, { headers: headers(), timeout: STATUS_TIMEOUT_MS, proxy: false });
return { installed: true, ...(r.data || {}) };
} catch (_) {
return { installed: true, ready: false, loading: true, error: null };
}
};
const CONNECT_RETRIES = 300;
const CONNECT_RETRY_MS = 1000;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const ask = async ({ system = '', history = [], context = [], input = '', lang = 'en', maxTokens } = {}) => {
if (!isModelInstalled()) throw new Error('model_missing');
await start();
let lastErr = null;
for (let attempt = 0; attempt < CONNECT_RETRIES; attempt++) {
try {
const r = await axios.post(`http://127.0.0.1:${PORT}/ai`, { system, history, context, input, lang, maxTokens }, { headers: headers(), timeout: ASK_TIMEOUT_MS, proxy: false });
if (!r.data || typeof r.data.answer !== 'string') throw new Error('bad_answer');
return r.data.answer;
} catch (e) {
lastErr = e;
const httpStatus = e && e.response && e.response.status;
const retryable = (e && e.code === 'ECONNREFUSED') || httpStatus === 503 || httpStatus === 403;
if (!retryable) break;
await sleep(CONNECT_RETRY_MS);
}
}
throw lastErr || new Error('not_ready');
};
process.on('exit', stop);
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig, () => { stop(); process.exit(0); });
}
module.exports = { start, stop, status, ask, isModelInstalled, MODEL_PATH, MODEL_FILE, port: () => PORT };

168
src/AI/ai_service.mjs Normal file
View file

@ -0,0 +1,168 @@
import path from 'path';
import fs from 'fs';
import os from 'os';
import http from 'http';
import { fileURLToPath } from 'url';
import { getLlama, LlamaChatSession } from './node_modules/node-llama-cpp/dist/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = Number(process.env.OASIS_AI_PORT) || 4001;
const TOKEN = String(process.env.OASIS_AI_TOKEN || '');
const PARENT_PID = Number(process.env.OASIS_AI_PARENT_PID) || 0;
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
const GENERATION_TIMEOUT_MS = 120000;
const MAX_TOKENS_DEFAULT = 400;
const MAX_INPUT_CHARS = 5000;
const MAX_CONTEXT_CHARS = 6000;
const state = { loading: false, ready: false, error: null, model: null, contextSize: 0, threads: 0 };
let llama, model, context;
let queue = Promise.resolve();
let loadingPromise = null;
const pickThreads = () => Math.max(1, Math.min(os.cpus().length - 1, 6));
const pickContextSize = () => {
const gb = os.totalmem() / (1024 ** 3);
if (gb < 6) return 2048;
if (gb < 12) return 4096;
return 8192;
};
async function initModel() {
if (state.ready) return;
if (loadingPromise) return loadingPromise;
state.loading = true;
loadingPromise = loadModel().finally(() => { loadingPromise = null; });
return loadingPromise;
}
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
const log = (msg) => { if (!DEBUG) return; try { process.stderr.write(`[ai-service] ${msg}\n`); } catch (_) {} };
async function loadModel() {
const t0 = Date.now();
try {
if (!fs.existsSync(MODEL_PATH)) throw new Error(`model_missing:${MODEL_PATH}`);
log(`loading ${path.basename(MODEL_PATH)}`);
llama = await getLlama({ gpu: false });
log(`llama ready (${Date.now() - t0} ms)`);
model = await llama.loadModel({ modelPath: MODEL_PATH });
log(`model loaded (${Date.now() - t0} ms)`);
state.threads = pickThreads();
state.contextSize = pickContextSize();
context = await model.createContext({ contextSize: state.contextSize, threads: state.threads });
log(`context ready: ${state.contextSize} tokens, ${state.threads} threads (${Date.now() - t0} ms)`);
state.model = path.basename(MODEL_PATH);
state.ready = true;
state.error = null;
} catch (e) {
state.error = String((e && e.message) || e);
log(`load failed: ${state.error}`);
} finally {
state.loading = false;
}
}
const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
const buildHistory = ({ system, history, context: ctxLines }) => {
const items = [];
const sys = [clip(system, 2000)];
const lines = Array.isArray(ctxLines) ? ctxLines.map(x => clip(x, 800)).filter(Boolean) : [];
if (lines.length) {
let block = '';
for (const l of lines) { if ((block + l).length > MAX_CONTEXT_CHARS) break; block += `- ${l}\n`; }
sys.push(`Knowledge from the Oasis network (use it when relevant, never invent beyond it):\n${block}`);
}
items.push({ type: 'system', text: sys.join('\n\n') });
for (const turn of (Array.isArray(history) ? history : []).slice(-8)) {
if (!turn || typeof turn.text !== 'string' || !turn.text.trim()) continue;
if (turn.type === 'user') items.push({ type: 'user', text: clip(turn.text, MAX_INPUT_CHARS) });
else if (turn.type === 'model') items.push({ type: 'model', response: [clip(turn.text, MAX_INPUT_CHARS)] });
}
return items;
};
async function answer(body) {
await initModel();
if (!state.ready) throw new Error(state.error || 'not_ready');
const input = clip(body.input, MAX_INPUT_CHARS).trim();
if (!input) throw new Error('empty_input');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GENERATION_TIMEOUT_MS);
const sequence = context.getSequence();
const session = new LlamaChatSession({ contextSequence: sequence });
try {
session.setChatHistory(buildHistory(body));
const text = await session.prompt(input, {
maxTokens: Math.max(32, Math.min(1024, Number(body.maxTokens) || MAX_TOKENS_DEFAULT)),
temperature: 0.6,
signal: controller.signal,
stopOnAbortSignal: true
});
return String(text || '').trim();
} finally {
clearTimeout(timer);
try { session.dispose(); } catch (_) {}
try { sequence.dispose(); } catch (_) {}
}
}
const readJson = (req) => new Promise((resolve, reject) => {
let raw = '';
req.on('data', (c) => { raw += c; if (raw.length > 200000) { reject(new Error('body_too_large')); req.destroy(); } });
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch (e) { reject(e); } });
req.on('error', reject);
});
const send = (res, status, obj) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
};
const server = http.createServer(async (req, res) => {
if (TOKEN && req.headers['x-oasis-ai-token'] !== TOKEN) return send(res, 403, { error: 'forbidden' });
if (req.method === 'GET' && req.url === '/status') {
if (!state.ready && !state.loading && !state.error) initModel();
return send(res, 200, { ...state });
}
if (req.method === 'POST' && req.url === '/ai') {
let body;
try { body = await readJson(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
const job = queue.then(() => answer(body));
queue = job.catch(() => {});
try {
const text = await job;
return send(res, 200, { answer: text });
} catch (e) {
const msg = String((e && e.message) || e);
const status = msg.startsWith('model_missing') ? 503 : (msg === 'not_ready' ? 503 : 500);
log(`answer failed: ${msg}`);
return send(res, status, { error: msg, ...state });
}
}
send(res, 404, { error: 'not_found' });
});
let listenAttempts = 0;
server.on('error', (e) => {
if (e && e.code === 'EADDRINUSE' && listenAttempts < 20) {
listenAttempts += 1;
setTimeout(() => server.listen(PORT, '127.0.0.1'), 1000);
return;
}
process.exit(1);
});
server.listen(PORT, '127.0.0.1', () => { initModel(); });
const shutdown = () => { try { server.close(); } catch (_) {} process.exit(0); };
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
if (PARENT_PID > 0) {
setInterval(() => {
try { process.kill(PARENT_PID, 0); } catch (_) { shutdown(); }
}, 5000).unref();
}

234
src/AI/buildAIContext.js Normal file
View file

@ -0,0 +1,234 @@
const { readTyped } = require('../models/typed_log')
const { getConfig } = require('../configs/config-manager.js')
const logLimit = getConfig().ssbLogStream?.limit || 1000
let cooler = null
let ssb = null
let opening = null
function getCooler() {
let ssbPath = null
try { ssbPath = require.resolve('../server/SSB_server.js') } catch {}
if (ssbPath && require.cache[ssbPath]) {
if (!cooler) {
const gui = require('../client/gui.js')
cooler = gui({ offline: false })
}
return cooler
}
return null
}
function useCooler(c) {
cooler = c || null
ssb = null
opening = null
}
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true'
const debug = (msg) => { if (DEBUG) console.error(`[ai] ${msg}`) }
async function openSsb() {
const c = cooler || getCooler()
if (!c) return null
if (ssb && ssb.closed === false) return ssb
if (!opening) {
debug('opening the SSB connection for the network knowledge')
opening = c.open().then(x => { debug('SSB connection open'); return (ssb = x) }).finally(() => { opening = null })
}
await opening
return ssb
}
const clip = (s, n) => String(s || '').slice(0, n)
const squash = s => String(s || '').replace(/\s+/g, ' ').trim()
const compact = s => squash(clip(s, 160))
const normalize = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w\u0080-\uFFFF\s]+/g, '').trim()
function fieldsForSnippet(type, c) {
if (type === 'aiExchange') return [c?.question, clip(squash(c?.answer || ''), 120)]
return []
}
async function publishExchange({ q, a, ctx = [], tokens = {}, lang = '', tags = [], rating = 0 }) {
const s = await openSsb()
if (!s) return null
const safeLang = String(lang || '').trim().slice(0, 8).toLowerCase()
const safeTags = Array.isArray(tags)
? Array.from(new Set(tags.map(t => String(t || '').trim().slice(0, 32)).filter(Boolean))).slice(0, 10)
: []
const safeRating = Math.max(0, Math.min(5, Math.round(Number(rating) || 0)))
const content = {
type: 'aiExchange',
question: clip(String(q || ''), 2000),
answer: clip(String(a || ''), 5000),
ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
timestamp: Date.now()
}
if (safeLang) content.lang = safeLang
if (safeTags.length) content.tags = safeTags
if (safeRating > 0) content.rating = safeRating
return new Promise((resolve, reject) => {
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
})
}
async function publishExchangeVote({ targetId, helpful = true }) {
const s = await openSsb()
if (!s || !targetId) return null
const content = {
type: 'aiExchangeVote',
target: String(targetId),
helpful: !!helpful,
timestamp: Date.now()
}
return new Promise((resolve, reject) => {
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
})
}
async function buildContext(maxItems = 100) {
const s = await openSsb()
if (!s) return ''
const msgs = (await readTyped(s, ['aiExchange'], { limit: logLimit }).catch(() => null) || []).reverse()
const lines = []
for (const { value } of msgs) {
const c = value && value.content || {}
if (c.type !== 'aiExchange') continue
const d = new Date(value.timestamp || 0).toISOString().slice(0, 10)
const q = compact(c.question)
const a = compact(c.answer)
lines.push(`[${d}] (AIExchange) Q: ${q} | A: ${a}`)
if (lines.length >= maxItems) break
}
if (lines.length === 0) return ''
return `## AIEXCHANGE\n\n${lines.join('\n')}`
}
const fs = require('fs')
const VECTORS_FILE = 'AI-vectors.json'
const VECTORS_MAX = 4000
const RATING_WEIGHT = 0.03
const VOTE_WEIGHT = 0.02
const CONTEXT_MIN_SCORE = 0.35
const DIRECT_ANSWER_MIN = 0.92
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
const exchangeText = (c) => clip(squash(c.question), 300)
async function trustedAuthors(s) {
const me = s.id
const msgs = await readTyped(s, ['contact'], { limit: logLimit }).catch(() => null) || []
const latest = new Map()
for (const m of msgs) {
const v = m && m.value || {}
const c = v.content || {}
if (v.author !== me || typeof c.contact !== 'string') continue
const ts = Number(v.timestamp) || 0
const prev = latest.get(c.contact)
if (prev && prev.ts > ts) continue
latest.set(c.contact, { ts, on: c.following === true && c.blocking !== true })
}
const out = new Set([me])
for (const [id, st] of latest) if (st.on) out.add(id)
return out
}
async function listExchanges() {
const s = await openSsb()
if (!s) return { exchanges: [], votes: new Map() }
const msgs = await readTyped(s, ['aiExchange', 'aiExchangeVote'], { limit: logLimit }).catch(() => null) || []
const trusted = await trustedAuthors(s)
const exchanges = []
const votes = new Map()
for (const m of msgs) {
const c = m && m.value && m.value.content || {}
if (!trusted.has(m.value.author)) continue
if (c.type === 'aiExchange' && c.question && c.answer) {
exchanges.push({ key: m.key, author: m.value.author, ts: m.value.timestamp || m.timestamp || 0, question: String(c.question), answer: String(c.answer), rating: Number(c.rating) || 0, tags: Array.isArray(c.tags) ? c.tags : [], lang: c.lang || '' })
} else if (c.type === 'aiExchangeVote' && c.target) {
const k = String(c.target)
const per = votes.get(k) || new Map()
per.set(m.value.author, c.helpful !== false ? 1 : -1)
votes.set(k, per)
}
}
return { exchanges, votes }
}
const netVotes = (votes, key) => { const per = votes.get(key); if (!per) return 0; let n = 0; for (const v of per.values()) n += v; return n }
async function rankExchanges(question, { embed, cosine, k = 5 } = {}) {
const { exchanges, votes } = await listExchanges()
if (!exchanges.length) return []
const scored = []
if (typeof embed === 'function' && typeof cosine === 'function') {
const qv = await embed(question).catch(() => null)
if (qv) {
const cache = readVectors()
let dirty = false
const newest = exchanges.slice().sort((a, b) => b.ts - a.ts).slice(0, VECTORS_MAX)
for (const ex of newest) {
let v = cache[ex.key]
if (!Array.isArray(v)) {
v = await embed(exchangeText(ex)).catch(() => null)
if (!v) continue
cache[ex.key] = v
dirty = true
}
const sim = cosine(qv, v)
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
}
if (dirty) {
const keep = new Set(newest.map(x => x.key))
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
writeVectors(cache)
}
}
}
if (!scored.length) {
const want = normalize(question)
const terms = want.split(' ').filter(t => t.length > 2)
for (const ex of exchanges) {
const text = normalize(ex.question + ' ' + ex.answer)
const hits = terms.filter(t => text.includes(t)).length
if (!hits) continue
const sim = normalize(ex.question) === want ? 1 : hits / Math.max(1, terms.length)
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
}
}
scored.sort((a, b) => b.score - a.score)
return scored.filter(x => x.similarity >= CONTEXT_MIN_SCORE).slice(0, k)
}
async function rankContext(question, opts = {}) {
const top = await rankExchanges(question, opts)
return top.map(ex => `Q: ${compact(ex.question)} | A: ${clip(squash(ex.answer), 400)}`)
}
async function getBestTrainedAnswer(question, opts = {}) {
const top = await rankExchanges(question, { ...opts, k: 1 })
const best = top[0]
if (!best || best.similarity < DIRECT_ANSWER_MIN || best.votes < 0) return null
return { answer: String(best.answer || '').trim(), ctx: [`Q: ${compact(best.question)}`], key: best.key }
}
async function exportFineTuning({ system = '' } = {}) {
const { exchanges, votes } = await listExchanges()
const lines = []
for (const ex of exchanges.sort((a, b) => a.ts - b.ts)) {
if (netVotes(votes, ex.key) < 0) continue
const messages = []
if (system) messages.push({ role: 'system', content: system })
messages.push({ role: 'user', content: ex.question })
messages.push({ role: 'assistant', content: ex.answer })
lines.push(JSON.stringify({ messages, meta: { key: ex.key, author: ex.author, lang: ex.lang, tags: ex.tags, rating: ex.rating, helpful: netVotes(votes, ex.key), ts: ex.ts } }))
}
return lines.join('\n') + (lines.length ? '\n' : '')
}
module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer, rankContext, rankExchanges, listExchanges, exportFineTuning, useCooler, DIRECT_ANSWER_MIN, CONTEXT_MIN_SCORE }

138
src/AI/embedder.js Normal file
View file

@ -0,0 +1,138 @@
const path = require('path')
const fs = require('fs')
const { fork } = require('child_process')
const MODEL_DIR = path.join(__dirname, 'embeddings')
const MODEL_FILE = path.join(MODEL_DIR, 'onnx', 'model_quantized.onnx')
const EMBED_TIMEOUT_MS = 30000
let worker = null
let workerReady = false
let permanentFail = false
let unavailableReason = null
let seq = 0
const pending = new Map()
const isInstalled = () => {
try {
return fs.existsSync(MODEL_FILE)
} catch (_) {
return false
}
}
const dot = (a, b) => {
let s = 0
for (let i = 0; i < a.length; i++) s += a[i] * b[i]
return s
}
const norm = (a) => Math.sqrt(dot(a, a)) || 1
const cosine = (a, b) => dot(a, b) / (norm(a) * norm(b))
const settlePending = () => {
for (const p of pending.values()) p.resolve(null)
pending.clear()
}
const ensureWorker = () => {
if (worker || permanentFail) return
const child = fork(__filename, ['--embed-worker'], { silent: true })
if (child.stdout) child.stdout.resume()
if (child.stderr) child.stderr.resume()
child.on('message', (msg) => {
const p = pending.get(msg && msg.id)
if (!p) return
pending.delete(msg.id)
workerReady = true
p.resolve(Array.isArray(msg.vec) ? msg.vec : null)
})
child.on('error', () => {})
child.on('exit', () => {
settlePending()
if (worker === child) {
worker = null
if (!workerReady) {
permanentFail = true
unavailableReason = 'embedder_native_crash'
}
workerReady = false
}
})
worker = child
}
const embed = async (text, { timeoutMs = EMBED_TIMEOUT_MS } = {}) => {
if (!isInstalled() || permanentFail) return null
ensureWorker()
const child = worker
if (!child) return null
const id = ++seq
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id)
try { child.kill() } catch (_) {}
resolve(null)
}
}, Math.max(1000, Number(timeoutMs) || EMBED_TIMEOUT_MS))
pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v) } })
try {
child.send({ id, text: String(text || '').trim() })
} catch (_) {
pending.delete(id)
clearTimeout(timer)
resolve(null)
}
})
}
const status = () => ({
installed: isInstalled(),
loaded: workerReady,
unavailableReason
})
module.exports = { embed, cosine, isInstalled, status, MODEL_DIR, MODEL_FILE }
if (process.argv[2] === '--embed-worker') {
let pipelinePromise = null
const ensurePipeline = async () => {
if (pipelinePromise) return pipelinePromise
if (!isInstalled()) return null
pipelinePromise = (async () => {
let mod
try {
const url = require('url')
const transformersPath = path.join(__dirname, 'node_modules', '@xenova', 'transformers', 'src', 'transformers.js')
mod = await import(url.pathToFileURL(transformersPath).href)
} catch (_) {
return null
}
const { pipeline, env } = mod
env.allowRemoteModels = false
env.localModelPath = path.join(__dirname)
env.cacheDir = path.join(__dirname, '.cache')
try {
return await pipeline('feature-extraction', 'embeddings', { quantized: true })
} catch (_) {
return null
}
})()
const fe = await pipelinePromise
if (!fe) pipelinePromise = null
return fe
}
process.on('message', async (msg) => {
let vec = null
try {
const fe = await ensurePipeline()
if (fe) {
const out = await fe(String((msg && msg.text) || ''), { pooling: 'mean', normalize: true })
vec = Array.from(out.data)
}
} catch (_) {}
try { process.send({ id: msg && msg.id, vec }) } catch (_) {}
})
}

397
src/AI/intents.js Normal file
View file

@ -0,0 +1,397 @@
const INTENTS = [
{ key: 'funds', description: 'my balance, how much ECO do I have, my wallet funds, my ECOin address', keywords: ['balance', 'saldo', 'funds', 'fondos', 'wallet', 'cartera', 'address', 'dirección', 'my ecoin', 'mi ecoin', 'fons', 'moneder', 'carteira', 'adreça', 'enderezo', 'el meu ecoin', 'o meu ecoin'] },
{ key: 'exchange', description: 'the value of one ECO, ECO price, ECOin exchange rate, how much is an ECO worth, ECO per hour, supply and inflation of ECOin', keywords: ['value of', 'valor de', 'eco value', 'ecoin value', 'valor del eco', 'price', 'precio', 'worth', 'vale un eco', 'cuánto vale', 'exchange', 'cotización', 'cambio', 'supply', 'suministro', 'inflation', 'inflación', 'eco/h', '1 eco', 'un eco', 'preu', 'prezo', 'quant val', 'canto vale', 'cotització', 'canvi', 'oferta total', 'inflació'] },
{ key: 'ubi', description: 'my UBI, universal basic income, can I claim the UBI, when did I claim, how much UBI did I receive', keywords: ['ubi', 'rbu', 'basic income', 'renta básica', 'claim', 'reclamar', 'cobrar', 'renda bàsica', 'renda básica'] },
{ key: 'karma', description: 'my karma score, my taxes, eco tax, arch tax, engagement score', keywords: ['karma', 'tax', 'impuesto', 'engagement', 'impost', 'imposto', 'taxa eco', 'taxa arch'] },
{ key: 'agenda', description: 'my agenda, what do I have coming up, upcoming dates, next days, schedule', keywords: ['agenda', 'schedule', 'próximo', 'upcoming', 'coming', 'esta semana', 'this week', 'calendar', 'calendario', 'aquesta setmana', 'propers dies', 'calendari', 'axenda', 'próximos días'] },
{ key: 'events', description: 'upcoming events, next event, events near me, what events are there', keywords: ['event', 'evento', 'esdeveniment'] },
{ key: 'tasks', description: 'my open tasks, pending tasks, to-do, what do I have to do', keywords: ['task', 'tarea', 'to-do', 'todo', 'pendiente', 'tasca', 'tarefa', 'pendent', 'tasques'] },
{ key: 'transfers', description: 'pending transfers, contracts waiting for my confirmation, payments to confirm', keywords: ['transfer', 'transferencia', 'contract', 'contrato', 'confirm', 'confirmar', 'payment', 'pago', 'contracte', 'pagament', 'pagamento'] },
{ key: 'relationships', description: 'who follows me, who do I follow, my followers, my mutual contacts, friends', keywords: ['follow', 'sigue', 'seguidor', 'follower', 'mutual', 'friend', 'amigo', 'contacto', 'segueix', 'segue', 'amic', 'contacte', 'mutus'] },
{ key: 'inbox', description: 'my inbox, unread messages, private messages, notifications, mentions', keywords: ['inbox', 'unread', 'no leído', 'mensaje', 'message', 'pm', 'notification', 'notificación', 'mention', 'mención', 'no llegit', 'non lido', 'missatge', 'mensaxe', 'notificació', 'bústia', 'buzón', 'caixa de entrada', 'menció'] },
{ key: 'government', description: 'current government, parliament, who rules, governance cycle, LARP house', keywords: ['government', 'gobierno', 'parliament', 'parlamento', 'rules', 'gobierna', 'larp', 'house', 'casa', 'govern', 'goberno', 'parlament', 'governa', 'goberna'] },
{ key: 'emergencies', description: 'active emergencies, alerts, is there any emergency', keywords: ['emergency', 'emergencia', 'alert', 'alerta', 'emergència', 'emerxencia', 'emergències'] },
{ key: 'jobs', description: 'open job offers, jobs for me, work, salary', keywords: ['job', 'trabajo', 'empleo', 'offer', 'oferta', 'salary', 'salario', 'vacante', 'feina', 'treball', 'ocupació', 'traballo', 'emprego', 'salari', 'feines'] },
{ key: 'market', description: 'items for sale in the market, second hand, auctions, what can I buy', keywords: ['market', 'mercado', 'buy', 'comprar', 'vender', 'item', 'auction', 'subasta', 'mercat', 'vendre', 'subhasta', 'poxa', 'subhastes'] },
{ key: 'tribes', description: 'my tribes, which tribes am I in, tribe members', keywords: ['tribe', 'tribu', 'tribo'] },
{ key: 'network', description: 'network status, inhabitants count, peers online, last sync, population', keywords: ['network', 'red', 'peer', 'inhabitant', 'habitante', 'population', 'población', 'online', 'sync', 'sincron', 'xarxa', 'rede', 'habitant', 'població', 'poboación'] },
{ key: 'me', description: 'who am I, my identity, my id, my name, my profile, oasis version', keywords: ['who am i', 'quién soy', 'my id', 'mi id', 'identity', 'identidad', 'profile', 'perfil', 'version', 'versión', 'qui sóc', 'quen son', 'el meu id', 'o meu id', 'identitat', 'versió'] },
{ key: 'shops', description: 'products in the shops, what do the shops sell, shop catalogue, buy a product', keywords: ['shop', 'tienda', 'product', 'producto', 'catálogo', 'catalog', 'botiga', 'tenda', 'producte', 'produto', 'catàleg', 'botigues'] },
{ key: 'school', description: 'courses in the school, lessons, my courses, enrol, learn, certificates', keywords: ['school', 'escuela', 'course', 'curso', 'lesson', 'lección', 'learn', 'aprender', 'certificate', 'certificado', 'student', 'estudiante', 'escola', ' curs', 'lliçó', 'aprendre', 'certificat', 'estudiant', 'estudante'] },
{ key: 'projects', description: 'crowdfunding projects, projects looking for funding, pledges, project goals', keywords: ['project', 'proyecto', 'crowdfunding', 'pledge', 'aportación', 'financiar', 'funding', 'projecte', 'proxecto', 'aportació', 'achega', 'finançar'] },
{ key: 'housing', description: 'housing, places offered, rooms, homes, rent a place, accommodation', keywords: ['housing', 'vivienda', 'room', 'habitación', 'rent', 'alquil', 'accommodation', 'alojamiento', 'habitatge', 'vivenda', 'habitació', 'lloguer', 'aluguer', 'allotjament', 'aloxamento'] },
{ key: 'industry', description: 'industry facilities, factories, builds, production, what is being built', keywords: ['industry', 'industria', 'facility', 'fábrica', 'factory', 'build', 'construir', 'production', 'producción', 'indústria', 'fàbrica', 'producció', 'produción'] },
{ key: 'campaigns', description: 'campaigns, petitions, signatures, collective demands', keywords: ['campaign', 'campaña', 'petition', 'petición', 'signature', 'firma', 'demand', 'campanya', 'petició', 'signatura', 'sinatura', 'campanyes', 'signatures'] },
{ key: 'logistics', description: 'logistics routes, deliveries, transport, shipping, packages, bookings', keywords: ['logistic', 'logística', 'route', 'ruta', 'delivery', 'entrega', 'transport', 'envío', 'shipping', 'package', 'paquete', 'loxística', 'lliurament', 'enviament', 'paquet'] },
{ key: 'calendars', description: 'calendars, dates, upcoming dates in my calendars, deadlines, reminders', keywords: ['calendar', 'calendario', 'date', 'fecha', 'deadline', 'recordatorio', 'reminder', 'calendari', 'termini', 'prazo', 'recordatori'] },
{ key: 'votes', description: 'votations, polls, open votes, voting, ballots, what can I vote on', keywords: ['vote', 'votación', 'votar', 'poll', 'encuesta', 'ballot', 'referendum', 'votació', 'enquesta', 'enquisa', 'enquestes'] },
{ key: 'wiki', description: 'wiki pages, knowledge base, documentation written by inhabitants', keywords: ['wiki'] },
{ key: 'chats', description: 'my chats, group chats, conversations, chat rooms', keywords: ['chat', 'conversación', 'sala', 'xat', 'conversa'] },
{ key: 'mailing', description: 'mailing lists, subscriptions, newsletters, discussion lists', keywords: ['mailing', 'lista de correo', 'newsletter', 'subscri', 'llista de correu'] },
{ key: 'podcasts', description: 'podcasts, audio shows, episodes, radio', keywords: ['podcast', 'episode', 'episodio', 'radio', 'episodi'] },
{ key: 'media', description: 'audios, videos, images, documents, files, media library, how many files', keywords: ['audio', 'video', 'vídeo', 'image', 'imagen', 'photo', 'foto', 'document', 'documento', 'file', 'archivo', 'media', 'gallery', 'galería', 'torrent', 'àudio', 'imatge', 'imaxe', 'fitxer', 'arxiu', 'ficheiro', 'arquivo', 'galeria'] },
{ key: 'blogs', description: 'blogs, blog entries, articles, latest posts written by inhabitants', keywords: ['blog', 'entrada', 'article', 'artículo', 'artigo'] },
{ key: 'reports', description: 'reports, complaints, flagged content, incidents', keywords: ['report', 'reporte', 'denuncia', 'complaint', 'queja', 'flag', 'incident', 'denúncia', 'queixa', 'informe'] },
{ key: 'games', description: 'games, hall of fame, scores, best players, play', keywords: ['game', 'juego', 'play', 'jugar', 'score', 'puntuación', 'hall of fame', 'ranking', ' joc', 'xogo', 'xogar', 'puntuació'] },
{ key: 'tags', description: 'tags, hashtags, topics, most used tags, what is the network talking about', keywords: ['tag', 'etiqueta', 'hashtag', 'topic', 'tema', 'trending', 'tendencia', 'tendència', 'etiquetes'] },
{ key: 'favorites', description: 'my favorites, things I starred, saved items, bookmarks', keywords: ['favorite', 'favorito', 'starred', 'saved', 'guardado', 'bookmark', 'marcador', 'preferit', 'favorit', 'desat', 'gardado'] },
{ key: 'modules', description: 'which modules are enabled, what features does this node have, oasis modules', keywords: ['module', 'módulo', 'feature', 'funcionalidad', 'enabled', 'activado', 'disabled', 'desactivado', 'plugin', 'mòdul', 'funcionalitat', 'activat', 'desactivat'] },
{ key: 'help', description: 'what can you do, help, how can you help me, what do you know, commands', keywords: ['what can you', 'qué puedes', 'help', 'ayuda', 'how can you', 'cómo puedes', 'what do you know', 'qué sabes', 'capabilities', 'què pots', 'que podes', 'ajuda', 'axuda', 'com pots', 'como podes', 'què saps'] }
];
const KEYWORD_MIN = 1;
const EMBED_MIN = 0.55;
const EMBED_MARGIN = 0.05;
const norm = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const detectByKeywords = (question) => {
const q = ' ' + norm(question) + ' ';
let best = null;
for (const it of INTENTS) {
const hits = it.keywords.filter(k => q.includes(norm(k))).length;
if (hits >= KEYWORD_MIN && (!best || hits > best.hits)) best = { key: it.key, hits };
}
return best ? best.key : null;
};
let intentVectors = null;
const detect = async (question, { embed, cosine } = {}) => {
const byKeywords = detectByKeywords(question);
if (byKeywords) return byKeywords;
if (typeof embed !== 'function' || typeof cosine !== 'function') return null;
const qv = await embed(question).catch(() => null);
if (!qv) return null;
if (!intentVectors) {
const out = [];
for (const it of INTENTS) { const v = await embed(it.description).catch(() => null); if (v) out.push({ key: it.key, v }); }
intentVectors = out;
}
const scored = intentVectors.map(it => ({ key: it.key, s: cosine(qv, it.v) })).sort((a, b) => b.s - a.s);
const best = scored[0];
const second = scored[1];
if (!best || best.s < EMBED_MIN) return null;
if (second && best.s - second.s < EMBED_MARGIN) return null;
return best.key;
};
const fmtEco = (n) => `${Number(n || 0).toFixed(6)} ECO`;
const fmtDate = (d) => { const t = new Date(d || 0); return Number.isFinite(t.getTime()) && t.getTime() > 0 ? t.toISOString().replace('T', ' ').slice(0, 16) : ''; };
const titleOf = (c) => String(c.title || c.name || c.question || c.concept || c.subject || '').trim();
const dateOf = (c) => c.date || c.startTime || c.startDate || c.deadline || c.endTime || c.createdAt || null;
const line = (text, href) => ({ text, href: href || null });
const RUNNERS = {
async funds({ models, me }) {
const out = [];
const data = await models.banking.listBanking('overview', me).catch(() => null);
const summary = (data && data.summary) || {};
const addr = await models.banking.getUserAddress(me).catch(() => null);
if (summary.hasValidWallet) out.push(line(`Wallet balance: ${fmtEco(summary.userBalance)}`, '/wallet'));
else out.push(line('Wallet: not connected (Settings › Wallet)', '/settings#wallet'));
out.push(line(addr ? `ECOin address: ${addr}` : 'ECOin address: none published', '/banking?filter=addresses'));
if (data && data.exchange && data.exchange.isSynced) out.push(line(`ECO value (1h): ${Number(data.exchange.ecoValue || 0).toFixed(4)} ECO`, '/banking?filter=exchange'));
if (Number.isFinite(Number(summary.industryBalance)) && Number(summary.industryBalance) !== 0) out.push(line(`Industry balance: ${fmtEco(summary.industryBalance)}`, '/industry'));
return out;
},
async exchange({ models, me }) {
const data = await models.banking.listBanking('exchange', me).catch(() => null);
const ex = (data && data.exchange) || {};
if (!ex.isSynced) return [line('ECO value: unknown right now, the wallet is not synced with the ECOin network', '/banking?filter=exchange')];
const hours = Number(ex.ecoTimeMs || 0) / 3600000;
const out = [
line(`Value of 1 ECO right now: ${Number(ex.ecoValue || 0).toFixed(4)} ECO per hour of UBI (1 ECO ≈ ${hours > 0 ? hours.toFixed(2) + ' hours' : 'n/a'})`, '/banking?filter=exchange'),
line(`ECOin total supply: ${fmtEco(ex.totalSupply)} · current supply: ${fmtEco(ex.currentSupply)}`, '/banking?filter=exchange'),
line(`Inflation: ${Number(ex.inflationFactor || 0).toFixed(2)}% annual · ${Number(ex.inflationMonthly || 0).toFixed(2)}% monthly`, '/banking?filter=exchange')
];
if (ex.pubsSupply != null) out.push(line(`PUBs pool supply: ${fmtEco(ex.pubsSupply)}${ex.holdingSupply != null ? ` · holding supply: ${fmtEco(ex.holdingSupply)}` : ''}`, '/banking?filter=exchange'));
return out;
},
async ubi({ models, me }) {
const out = [];
const data = await models.banking.listBanking('ubi', me).catch(() => null);
const summary = (data && data.summary) || {};
const bank = await models.banking.getBankingData(me).catch(() => ({}));
if (summary.epochId) out.push(line(`Current epoch: ${summary.epochId}`, '/banking?filter=ubi'));
if (summary.alreadyClaimed) out.push(line('This month: already claimed', '/banking?filter=ubi'));
else if (summary.alreadyRefused) out.push(line('This month: refused', '/banking?filter=ubi'));
else if (data && data.pendingUBI) out.push(line(`This month: ${fmtEco(data.pendingUBI.amount)} available to claim`, '/banking?filter=ubi'));
else out.push(line(`This month: nothing to claim yet (${summary.ubiAvailability === 'NO_FUNDS' ? 'PUB without funds' : 'no allocation'})`, '/banking?filter=ubi'));
if (bank.estimatedUBI != null) out.push(line(`Estimated UBI: ${fmtEco(bank.estimatedUBI)}`, '/banking?filter=ubi'));
out.push(line(`Total received: ${fmtEco(bank.totalClaimed)}${bank.lastClaimedDate ? ` (last: ${fmtDate(bank.lastClaimedDate)})` : ''}`, '/banking?filter=ubi'));
const pubs = Array.isArray(data && data.ubiPubs) ? data.ubiPubs : [];
if (pubs.length) out.push(line(`PUBs announcing UBI: ${pubs.length}`, '/banking?filter=ubi'));
return out;
},
async karma({ models, me }) {
const bank = await models.banking.getBankingData(me).catch(() => ({}));
return [
line(`Karma score: ${Number(bank.karmaScore || 0).toFixed(2)}`, '/banking?filter=overview'),
line(`ECOin tax: ${fmtEco(bank.userEcoinTax)} · ARCH tax: ${fmtEco(bank.userArchTax)} · total: ${fmtEco(bank.userTotalTax)}`, '/banking?filter=taxes'),
line(`Estimated UBI after taxes: ${fmtEco(bank.estimatedUBI)}`, '/banking?filter=ubi')
];
},
async agenda({ models, hrefFor }) {
const items = await models.agenda.listAgenda('all').catch(() => []);
const now = Date.now();
const soon = (Array.isArray(items) ? items : [])
.map(it => ({ it, t: new Date(dateOf(it) || 0).getTime() }))
.filter(x => Number.isFinite(x.t) && x.t >= now - 3600000)
.sort((a, b) => a.t - b.t)
.slice(0, 8);
if (!soon.length) return [line('Agenda: nothing upcoming', '/agenda')];
return soon.map(({ it, t }) => line(`${fmtDate(t)} · ${titleOf(it) || it.type || 'item'}${it.type ? ` (${it.type})` : ''}`, hrefFor(it.type, it.id || it.tipId)));
},
async events({ models, hrefFor }) {
const events = await models.events.listAll(null, 'all').catch(() => []);
const now = Date.now();
const upcoming = (Array.isArray(events) ? events : [])
.map(e => ({ e, t: new Date(e.date || 0).getTime() }))
.filter(x => Number.isFinite(x.t) && x.t >= now - 3600000 && String(x.e.status || '').toUpperCase() !== 'CLOSED')
.sort((a, b) => a.t - b.t).slice(0, 8);
if (!upcoming.length) return [line('Events: none upcoming', '/events')];
return upcoming.map(({ e, t }) => line(`${fmtDate(t)} · ${titleOf(e)}${e.location ? ` · ${e.location}` : ''}${Number(e.price) > 0 ? ` · ${fmtEco(e.price)}` : ''}`, hrefFor('event', e.id)));
},
async tasks({ models, me, hrefFor }) {
const tasks = await models.tasks.listAll().catch(() => []);
const mine = (Array.isArray(tasks) ? tasks : []).filter(t => String(t.status || '').toUpperCase() === 'OPEN' && (t.author === me || (Array.isArray(t.assignees) && t.assignees.includes(me))));
if (!mine.length) return [line('Tasks: nothing open assigned to you', '/tasks')];
return mine.slice(0, 10).map(t => line(`${titleOf(t)}${t.endTime ? ` · due ${fmtDate(t.endTime)}` : ''}`, hrefFor('task', t.id)));
},
async transfers({ models, me, hrefFor }) {
const all = await models.transfers.listAll('all').catch(() => []);
const pending = (Array.isArray(all) ? all : []).filter(t => String(t.status || '').toUpperCase() === 'UNCONFIRMED' && (t.to === me || t.from === me) && !(Array.isArray(t.confirmedBy) && t.confirmedBy.includes(me)));
if (!pending.length) return [line('Transfers: nothing waiting for your confirmation', '/transfers?filter=pending')];
return pending.slice(0, 10).map(t => line(`${fmtEco(t.amount)} · ${t.concept || t.id} · ${t.from === me ? 'to' : 'from'} ${t.from === me ? t.to : t.from}`, hrefFor('transfer', t.id)));
},
async relationships({ models, me }) {
const users = await models.inhabitants.listInhabitants({ filter: 'all', includeInactive: true }).catch(() => []);
const followers = [], following = [], mutuals = [];
for (const u of (Array.isArray(users) ? users : []).slice(0, 400)) {
if (!u || !u.id || u.id === me) continue;
const rel = await models.friend.getRelationship(u.id).catch(() => null);
if (!rel) continue;
const name = u.name || u.id.slice(0, 9);
if (rel.following && rel.followsMe) mutuals.push(name);
else if (rel.following) following.push(name);
else if (rel.followsMe) followers.push(name);
}
const list = (arr) => arr.slice(0, 12).join(', ') + (arr.length > 12 ? '…' : '');
return [
line(`Mutual contacts: ${mutuals.length}${mutuals.length ? ` · ${list(mutuals)}` : ''}`, '/inhabitants?filter=mutuals'),
line(`You follow: ${following.length + mutuals.length}${following.length ? ` · also ${list(following)}` : ''}`, '/inhabitants?filter=following'),
line(`Follow you: ${followers.length + mutuals.length}${followers.length ? ` · also ${list(followers)}` : ''}`, '/inhabitants?filter=followers')
];
},
async inbox({ sharedState }) {
return [
line(`Unread private messages: ${Number(sharedState.getInboxPmCount ? sharedState.getInboxPmCount() : 0) || 0}`, '/inbox?filter=pms'),
line(`Unread bot notifications: ${Number(sharedState.getInboxNotifCount ? sharedState.getInboxNotifCount() : 0) || 0}`, '/inbox?filter=notifications'),
line(`Unread mentions: ${Number(sharedState.getMentionsCount ? sharedState.getMentionsCount() : 0) || 0}`, '/mentions')
];
},
async government({ models, me, nameOf }) {
const out = [];
const card = await models.parliament.getLatestGovernmentCard().catch(() => null);
if (card) {
const leader = card.leaderId ? await nameOf(card.leaderId) : '';
out.push(line(`Government: ${String(card.method || '').toUpperCase()}${leader ? ` · leader ${leader}` : ''}${card.end ? ` · cycle ends ${fmtDate(card.end)}` : ''}`, '/parliament'));
} else out.push(line('Government: no cycle published yet', '/parliament'));
if (models.larp && typeof models.larp.getUserHouse === 'function') {
const house = await models.larp.getUserHouse(me).catch(() => null);
if (house) out.push(line(`Your LARP house: ${house}`, '/larp'));
}
return out;
},
async emergencies({ models, me, hrefFor }) {
const all = await models.emergencies.listAll({ filter: 'all', viewerId: me }).catch(() => []);
const active = (Array.isArray(all) ? all : []).filter(e => String(e.status || '').toUpperCase() !== 'RESOLVED').slice(0, 8);
if (!active.length) return [line('Emergencies: none active', '/emergencies')];
return active.map(e => line(`${titleOf(e)}${e.status ? ` · ${e.status}` : ''}`, hrefFor('emergency', e.id)));
},
async jobs({ models, me, hrefFor }) {
const jobs = await models.jobs.listJobs('ALL', me).catch(() => []);
const open = (Array.isArray(jobs) ? jobs : []).filter(j => String(j.status || 'OPEN').toUpperCase() !== 'CLOSED').slice(0, 8);
if (!open.length) return [line('Jobs: no open offers', '/jobs')];
return open.map(j => line(`${titleOf(j)}${Number(j.salary) > 0 ? ` · ${fmtEco(j.salary)}` : ''}`, hrefFor('job', j.id)));
},
async market({ models, hrefFor }) {
const items = await models.market.listAllItems('all').catch(() => []);
const forSale = (Array.isArray(items) ? items : []).filter(i => String(i.status || '').toUpperCase() === 'FOR SALE' || String(i.status || '').toUpperCase() === 'OPEN' || !i.status).slice(0, 8);
if (!forSale.length) return [line('Market: nothing for sale right now', '/market')];
return forSale.map(i => line(`${titleOf(i)}${i.price != null ? ` · ${fmtEco(i.price)}` : ''}`, hrefFor('market', i.id)));
},
async tribes({ models, me, hrefFor }) {
const all = await models.tribes.listAll().catch(() => []);
const mine = (Array.isArray(all) ? all : []).filter(t => Array.isArray(t.members) && t.members.includes(me));
if (!mine.length) return [line('Tribes: you are not in any tribe', '/tribes')];
return mine.slice(0, 10).map(t => line(`${titleOf(t)} · ${t.members.length} members`, hrefFor('tribe', t.id)));
},
async network({ sharedState }) {
const ts = sharedState.getLastSyncTs ? sharedState.getLastSyncTs() : null;
return [
line(`Inhabitants: ${Number(sharedState.getInhabitantCount ? sharedState.getInhabitantCount() : 0) || 0}`, '/inhabitants'),
line(`Tribes: ${Number(sharedState.getTribesCount ? sharedState.getTribesCount() : 0) || 0}`, '/tribes'),
line(`Peers online: ${Number(sharedState.getOnlinePeerCount ? sharedState.getOnlinePeerCount() : 0) || 0}`, '/peers'),
line(`Last sync: ${ts ? fmtDate(ts) : 'never'}`, '/peers'),
line(`ECO value (1h): ${sharedState.getEcoValue && sharedState.getEcoValue() ? sharedState.getEcoValue() + ' ECO' : 'unknown (wallet not synced)'}`, '/banking?filter=exchange')
];
},
async me({ me, nameOf, version }) {
const name = await nameOf(me);
return [
line(`You are ${name} (${me})`, '/profile'),
line(`Oasis version: ${version || 'unknown'}`, '/settings')
];
}
};
const listOf = (x) => Array.isArray(x) ? x : (x && Array.isArray(x.items) ? x.items : []);
const isOpen = (x) => { const s = String(x && x.status || 'OPEN').toUpperCase(); return !['CLOSED', 'RESOLVED', 'EXPIRED', 'DISSOLVED', 'CANCELLED', 'DELETED'].includes(s); };
const few = (arr, n = 8) => arr.slice(0, n);
Object.assign(RUNNERS, {
async shops({ models, hrefFor }) {
const items = listOf(await models.shops.listAllProducts({ filter: 'all' }).catch(() => []));
const open = few(items.filter(isOpen));
if (!open.length) return [line('Shops: no products on sale right now', '/shops')];
return open.map(p => line(`${titleOf(p)}${p.price != null ? ` · ${fmtEco(p.price)}` : ''}${Number(p.stock) > 0 ? ` · stock ${p.stock}` : ''}`, hrefFor('shopProduct', p.id || p.key)));
},
async school({ models, me, hrefFor }) {
const courses = listOf(await models.school.listCourses('ALL', me, {}).catch(() => []));
const mine = courses.filter(c => c.author === me || (Array.isArray(c.students) && c.students.includes(me)));
const open = few(courses.filter(isOpen));
const out = [line(`School: ${courses.length} courses, ${mine.length} of them yours (teaching or enrolled)`, '/school')];
return out.concat(open.map(c => line(`${titleOf(c)}${Number(c.price) > 0 ? ` · ${fmtEco(c.price)}` : ' · free'}${Array.isArray(c.students) ? ` · ${c.students.length} students` : ''}`, hrefFor('schoolCourse', c.id))));
},
async projects({ models, hrefFor }) {
const projects = listOf(await models.projects.listProjects('all', {}).catch(() => []));
const open = few(projects.filter(isOpen));
if (!open.length) return [line('Projects: none looking for funding right now', '/projects')];
return open.map(p => line(`${titleOf(p)}${p.goal != null ? ` · goal ${fmtEco(p.goal)}` : ''}${p.pledged != null ? ` · pledged ${fmtEco(p.pledged)}` : ''}${p.deadline ? ` · until ${fmtDate(p.deadline)}` : ''}`, hrefFor('project', p.id)));
},
async housing({ models, me, hrefFor }) {
const items = listOf(await models.housing.listHousing('ALL', me, {}).catch(() => []));
const open = few(items.filter(isOpen));
if (!open.length) return [line('Housing: no places offered right now', '/housing')];
return open.map(h => line(`${titleOf(h)}${h.price != null ? ` · ${fmtEco(h.price)}` : ''}${h.location ? ` · ${h.location}` : ''}`, hrefFor('housing', h.id)));
},
async industry({ models, me, hrefFor }) {
const facilities = listOf(await models.industry.listFacilities('ALL').catch(() => []));
const mine = facilities.filter(f => Array.isArray(f.members) && f.members.includes(me));
const out = [line(`Industry: ${facilities.length} facilities, you belong to ${mine.length}`, '/industry')];
return out.concat(few(facilities.filter(isOpen)).map(f => line(`${titleOf(f)}${Array.isArray(f.members) ? ` · ${f.members.length} members` : ''}`, hrefFor('industry', f.id))));
},
async campaigns({ models, me, hrefFor }) {
const items = listOf(await models.campaigns.listAll({ filter: 'all', viewerId: me }).catch(() => []));
const open = few(items.filter(isOpen));
if (!open.length) return [line('Campaigns: none open right now', '/campaigns')];
return open.map(c => line(`${titleOf(c)}${Array.isArray(c.signers) ? ` · ${c.signers.length} signatures` : ''}${c.goal ? ` of ${c.goal}` : ''}`, hrefFor('campaign', c.id)));
},
async logistics({ models, hrefFor }) {
const routes = listOf(await models.logistics.listAll({ filter: 'all' }).catch(() => []));
const open = few(routes.filter(isOpen));
if (!open.length) return [line('Logistics: no open routes right now', '/logistics')];
return open.map(r => line(`${titleOf(r)}${r.price != null && Number(r.price) > 0 ? ` · ${Number(r.price)} ${r.priceType === 'TIME' ? 'h' : 'ECO'}` : ''}${r.status ? ` · ${r.status}` : ''}`, hrefFor('logisticsRoute', r.id)));
},
async calendars({ models, me, hrefFor }) {
const cals = listOf(await models.calendars.listAll({ filter: 'all', viewerId: me }).catch(() => []));
if (!cals.length) return [line('Calendars: you are not in any calendar', '/calendars')];
const now = Date.now();
const soon = cals.map(c => ({ c, t: new Date(dateOf(c) || 0).getTime() })).filter(x => Number.isFinite(x.t) && x.t >= now - 3600000).sort((a, b) => a.t - b.t);
const out = [line(`Calendars: ${cals.length}`, '/calendars')];
return out.concat(few(soon).map(({ c, t }) => line(`${fmtDate(t)} · ${titleOf(c)}`, hrefFor('calendar', c.id))));
},
async votes({ models, hrefFor }) {
const votes = listOf(await models.votes.listAll('all').catch(() => []));
const polls = listOf(await models.polls.listAll('ALL', {}).catch(() => []));
const openV = few(votes.filter(isOpen), 5);
const openP = few(polls.filter(isOpen), 5);
if (!openV.length && !openP.length) return [line('Votes: nothing open to vote on right now', '/votes')];
return openV.map(v => line(`Votation: ${titleOf(v)}${v.deadline ? ` · until ${fmtDate(v.deadline)}` : ''}`, hrefFor('vote', v.id)))
.concat(openP.map(p => line(`Poll: ${titleOf(p)}`, hrefFor('poll', p.id))));
},
async wiki({ models, me, hrefFor }) {
const pages = listOf(await models.wiki.listPages({ viewerId: me }).catch(() => []));
if (!pages.length) return [line('Wiki: no pages yet', '/wiki')];
const out = [line(`Wiki: ${pages.length} pages`, '/wiki')];
return out.concat(few(pages, 6).map(p => line(titleOf(p), hrefFor('wikiPage', p.id || p.slug))));
},
async chats({ models, me, hrefFor }) {
const chats = listOf(await models.chats.listAll({ filter: 'all', viewerId: me }).catch(() => []));
const mine = chats.filter(c => c.author === me || (Array.isArray(c.members) && c.members.includes(me)) || (Array.isArray(c.participants) && c.participants.includes(me)));
if (!mine.length) return [line('Chats: you are not in any chat', '/chats')];
return few(mine).map(c => line(`${titleOf(c)}${Array.isArray(c.members) ? ` · ${c.members.length} members` : ''}`, hrefFor('chat', c.key || c.id)));
},
async mailing({ models, hrefFor }) {
const lists = listOf(await models.mailing.listAll({ filter: 'all' }).catch(() => []));
const active = few(lists.filter(l => String(l.status || 'ACTIVE').toUpperCase() === 'ACTIVE'));
if (!active.length) return [line('Mailing lists: none active', '/mailing')];
return active.map(l => line(`${titleOf(l)}${Array.isArray(l.subscribers) ? ` · ${l.subscribers.length} subscribers` : ''}`, hrefFor('mailingList', l.id)));
},
async podcasts({ models, me, hrefFor }) {
const channels = listOf(await models.podcasts.listAll({ filter: 'all', viewerId: me }).catch(() => []));
if (!channels.length) return [line('Podcasts: none yet', '/podcasts')];
return few(channels).map(c => line(`${titleOf(c)}${Array.isArray(c.episodes) ? ` · ${c.episodes.length} episodes` : ''}`, hrefFor('podcast', c.id)));
},
async media({ models }) {
const count = async (m) => m && typeof m.listAll === 'function' ? listOf(await m.listAll('all').catch(() => [])).length : 0;
const [audios, videos, images, documents, torrents, files] = await Promise.all([count(models.audios), count(models.videos), count(models.images), count(models.documents), count(models.torrents), count(models.files)]);
return [
line(`Audios: ${audios}`, '/audios'), line(`Videos: ${videos}`, '/videos'), line(`Images: ${images}`, '/images'),
line(`Documents: ${documents}`, '/documents'), line(`Files: ${files}`, '/files'), line(`Torrents: ${torrents}`, '/torrents')
];
},
async blogs({ models }) {
const blogs = listOf(await models.blog.listAll('all').catch(() => []));
if (!blogs.length) return [line('Blogs: no entries yet', '/blogs')];
const latest = blogs.slice().sort((a, b) => new Date(dateOf(b) || 0) - new Date(dateOf(a) || 0));
return [line(`Blogs: ${blogs.length} entries`, '/blogs')].concat(few(latest, 6).map(b => line(`${titleOf(b) || 'Untitled'}${dateOf(b) ? ` · ${fmtDate(dateOf(b))}` : ''}`, b.key || b.id ? `/blogs/${encodeURIComponent(b.key || b.id)}` : '/blogs')));
},
async reports({ models, hrefFor }) {
const reports = listOf(await models.reports.listAll().catch(() => []));
const open = few(reports.filter(isOpen));
if (!open.length) return [line('Reports: none open', '/reports')];
return open.map(r => line(`${titleOf(r)}${r.status ? ` · ${r.status}` : ''}`, hrefFor('report', r.id)));
},
async games({ models, nameOf }) {
const hall = listOf(await models.games.getHallOfFame().catch(() => []));
if (!hall.length) return [line('Games: no scores yet', '/games')];
const out = [];
for (const h of few(hall, 6)) out.push(line(`${h.game || 'game'} · ${await nameOf(h.author)} · ${h.score != null ? h.score : ''}`, '/games'));
return out;
},
async tags({ models }) {
const tags = listOf(await models.tags.listTags('all', '').catch(() => []));
if (!tags.length) return [line('Tags: none yet', '/tags')];
const top = tags.slice().sort((a, b) => (Number(b.count) || 0) - (Number(a.count) || 0));
return [line(`Tags in use: ${tags.length}`, '/tags')].concat(few(top, 10).map(t => line(`#${t.name || t.tag || t.id || t}${t.count != null ? ` · ${t.count}` : ''}`, `/tags/${encodeURIComponent(String(t.name || t.tag || t.id || t))}`)));
},
async favorites({ models }) {
const res = await models.favorites.listAll({}).catch(() => null);
const counts = res && res.counts && typeof res.counts === 'object' ? res.counts : {};
const keys = Object.keys(counts).filter(k => Number(counts[k]) > 0);
if (!keys.length) return [line('Favorites: nothing starred yet', '/favorites')];
return keys.map(k => line(`${k}: ${counts[k]}`, `/favorites?filter=${encodeURIComponent(k)}`));
},
async modules({ config }) {
const mods = (config && config.modules) || {};
const on = Object.keys(mods).filter(k => k.endsWith('Mod') && mods[k] === 'on').map(k => k.slice(0, -3));
const off = Object.keys(mods).filter(k => k.endsWith('Mod') && mods[k] !== 'on').map(k => k.slice(0, -3));
return [line(`Enabled modules (${on.length}): ${on.join(', ')}`, '/modules'), line(`Disabled modules (${off.length}): ${off.join(', ') || 'none'}`, '/modules')];
},
async help() {
return [
line('I answer from the live data of this node: your funds and ECO value, your UBI and karma, your agenda, events, tasks, transfers, who follows you, your inbox and mentions, the government, emergencies, jobs, market, shops, school, projects, housing, industry, campaigns, logistics, calendars, votes, wiki, chats, mailing lists, podcasts, media, blogs, reports, games, tags, favorites, the modules and the network status.', '/ai'),
line('Anything else I answer with the model, helped by the answers the network approved. Rate my answers with the stars so they are shared and I learn.', '/ai')
];
}
});
const run = async (key, deps) => {
const runner = RUNNERS[key];
if (!runner) return null;
try { return await runner(deps); } catch (_) { return null; }
};
module.exports = { INTENTS, detect, detectByKeywords, run, RUNNERS };

2677
src/AI/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
src/AI/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "@krakenslab/oasis-ai",
"version": "1.0.0",
"private": true,
"description": "AI stack of Oasis (assistant and embeddings); installed only when AI features are enabled",
"dependencies": {
"@xenova/transformers": "^2.17.2",
"onnxruntime-node": "1.19.2"
},
"optionalDependencies": {
"node-llama-cpp": "^3.10.0"
},
"overrides": {
"onnxruntime-node": "1.19.2",
"onnxruntime-common": "1.19.2",
"onnxruntime-web": "1.19.2"
}
}

275
src/AI/routes_index.js Normal file
View file

@ -0,0 +1,275 @@
const path = require('path')
const fs = require('fs')
const ROUTES = [
{ path: '/blogs', mod: 'blogsMod', description: 'blogs, blog posts, articles, written entries, long posts, public writing' },
{ path: '/polls', mod: 'pollsMod', description: 'polls, survey, ask a question with options, multiple choice, anonymous poll, count answers' },
{ path: '/polls?filter=CREATE', mod: 'pollsMod', description: 'create a poll, new survey, ask the network with options' },
{ path: '/blogs?filter=CREATE', mod: 'blogsMod', description: 'write a blog, publish a blog, compose an article, new blog entry' },
{ path: '/blogs?filter=TOP', mod: 'blogsMod', description: 'best blogs, most commented blogs, most valued blog entries' },
{ path: '/feed', mod: 'feedMod', description: 'feed, microblog, opinions, share thoughts, vote on posts, refeeds' },
{ path: '/forum', mod: 'forumMod', description: 'forum, discussions, threads, debates, conversation by category' },
{ path: '/inhabitants', mod: null, description: 'inhabitants, users, people, profiles, contacts, follow, block' },
{ path: '/inhabitants?filter=SUGGESTED', mod: null, description: 'suggested inhabitants, recommendations, who to follow, similar people, people you might know, friend suggestions' },
{ path: '/data', mod: null, description: 'matches, match skills, cross-data, big data of the network, affinities, cohesion coefficient, who shares my expertise, skill overlap, algorithmic suggestions' },
{ path: '/data?filter=INHABITANTS', mod: null, description: 'people with the same skills, professional matches, find collaborators' },
{ path: '/data?filter=JOBS', mod: null, description: 'jobs that match my skills, work suited to my profile' },
{ path: '/inhabitants?filter=CVs', mod: null, description: 'curriculums, CVs, resumes, professional profiles, people with experience, find expertise' },
{ path: '/inhabitants?filter=TOP%20KARMA', mod: null, description: 'top karma, most active inhabitants, highest reputation, leaderboard' },
{ path: '/inhabitants?filter=TOP%20ECO', mod: null, description: 'top eco, most ecological, least carbon footprint, sustainable users, efficient inhabitants' },
{ path: '/inhabitants?filter=TOP%20ACTIVITY', mod: null, description: 'top activity, most recently active inhabitants, fresh users, recently online' },
{ path: '/inhabitants?filter=contacts', mod: null, description: 'my contacts, who I follow, my network, friends list, mutuals' },
{ path: '/inhabitants?filter=GALLERY', mod: null, description: 'gallery of inhabitants, all avatars, visual list, photos' },
{ path: '/tribes', mod: 'tribesMod', description: 'tribes, groups, communities, private rooms, sub-tribes, governance, public tribes, browse all visible tribes' },
{ path: '/tribes?filter=mine', mod: 'tribesMod', description: 'my tribes, tribes I created, tribes I authored, my groups' },
{ path: '/tribes?filter=membership', mod: 'tribesMod', description: 'tribes I am a member of, my memberships, joined tribes, where I belong' },
{ path: '/larp', mod: 'larpMod', description: 'larp, role playing, nine houses, academia, solaris, arrakis, terraverde, unsystem, dogma, helix, quark, hermandad, governance cycle, ruling house, will test' },
{ path: '/larp?filter=houses', mod: 'larpMod', description: 'list of houses, all houses, browse houses, every house, choose a house, house directory, house grid' },
{ path: '/larp?filter=rules', mod: 'larpMod', description: 'larp faq, rules, governance wall, starting house, will test, invitation code, larp emblem' },
{ path: '/larp/academia', mod: 'larpMod', description: 'ACADEMIA house, aca, education, teachers, coordinators, newcomers, talent discovery, selection process, nosce te ipsum, take will test, leave house, return to academia' },
{ path: '/larp/solaris', mod: 'larpMod', description: 'SolarIS house, sol, governance, law, diplomacy, politics, rulers, lawyers, diplomats, communication, mediation' },
{ path: '/larp/arrakis', mod: 'larpMod', description: 'ARRakis house, arr, engineers, scientists, technicians, technology, invention, problem solving, building, prototypes, repairs, automation' },
{ path: '/larp/terraverde', mod: 'larpMod', description: 'TERRA.VErDE house, ter, farmers, ecologists, doctors, nutritionists, healthcare, food, nature, climate, biosphere, regeneration' },
{ path: '/larp/unsystem', mod: 'larpMod', description: 'UNSYSTem house, uns, chaos agents, trolls, punks, sabotage, tactical chaos, provocation, anti-system' },
{ path: '/larp/dogma', mod: 'larpMod', description: 'DogmA house, dog, thinkers, journalists, philosophers, information control, knowledge, archive, narratives, ai, memory' },
{ path: '/larp/helix', mod: 'larpMod', description: 'HeliX house, hlx, clowns, influencers, musicians, priests, entertainment, culture, humor, celebration, ritual, joy, festival' },
{ path: '/larp/quark', mod: 'larpMod', description: 'QuarK house, quk, athletes, soldiers, street people, protection, security, defense, survival, family, mutual aid' },
{ path: '/larp/hermandad', mod: 'larpMod', description: 'HERmanDAD house, hrm, architects, builders, investors, industrialists, construction, development, sustainability, logistics, production, supply chain' },
{ path: '/larp/test', mod: 'larpMod', description: 'will test, psychological test, take house test, assigned to house, find my house, profile questions' },
{ path: '/chats', mod: 'chatsMod', description: 'chats, messaging, encrypted rooms, group conversations' },
{ path: '/pads', mod: 'padsMod', description: 'pads, collaborative editor, shared notes, encrypted documents' },
{ path: '/rooms', mod: 'roomsMod', description: 'rooms, audio rooms, voice rooms, group audio, talk together, live conversation, salas, salas de audio' },
{ path: '/wiki', mod: 'wikiMod', description: 'wiki, knowledge base, shared pages, articles, documentation, glossary, versioned pages' },
{ path: '/campaigns', mod: 'campaignsMod', description: 'campaigns, petitions, signatures, sign a petition, collect signatures, change.org, goal, supporters' },
{ path: '/podcasts', mod: 'podcastsMod', description: 'podcasts, podcast channels, episodes, audio series, video series, listen, subscribe to a podcast, download a whole podcast' },
{ path: '/logistics', mod: 'logisticsMod', description: 'logistics, trips, rides, carpool, ride sharing, shipments, deliveries, carry goods, move something, transport, book a seat, route ratings' },
{ path: '/mailing', mod: 'mailingMod', description: 'mailing lists, mailing list, mailman, newsletter, list of subscribers, write to a list, list archive, threads' },
{ path: '/emergencies', mod: 'emergenciesMod', description: 'emergencies, alerts, warnings, weather warning, power outage, water cut, health alert, security, lost and found, neighborhood notice' },
{ path: '/calendars', mod: 'calendarsMod', description: 'calendar, events by date, schedule, reminders, recurring dates' },
{ path: '/maps', mod: 'mapsMod', description: 'maps, locations, markers, geography, places' },
{ path: '/events', mod: 'eventsMod', description: 'events, agenda, meetups, gatherings, RSVP' },
{ path: '/agenda', mod: 'agendaMod', description: 'agenda, scheduled items, upcoming, my dates, my tasks events transfers projects jobs market reports tribes' },
{ path: '/agenda?filter=discarded', mod: 'agendaMod', description: 'discarded agenda items, archived agenda, removed from agenda, hidden tasks' },
{ path: '/tasks', mod: 'tasksMod', description: 'tasks, todo, assignments, work items, priorities' },
{ path: '/projects', mod: 'projectsMod', description: 'projects, milestones, backers, crowdfunding, bounties' },
{ path: '/school', mod: 'schoolMod', description: 'school, courses, classes, lessons, learning, teach, study, education, enroll, students, certificates, diploma pdf, exams, course invite code, join course with code' },
{ path: '/industry', mod: 'industryMod', description: 'industry, facilities, factories, workshops, means of manufacturing, commons, cooperatives, blueprints, builds' },
{ path: '/industry?filter=BLUEPRINTS', mod: 'industryMod', description: 'blueprints, designs, recipes, open hardware designs, copyleft designs, bill of materials, materials list' },
{ path: '/industry?filter=BUILDS', mod: 'industryMod', description: 'builds, manufacturing runs, fabrication orders, batches, build status, contributions to a build' },
{ path: '/industry?filter=MEMBER', mod: 'industryMod', description: 'facilities I belong to, my industry memberships, where I am a worker' },
{ path: '/industry?filter=MINE', mod: 'industryMod', description: 'facilities I steward, industries I created' },
{ path: '/industry?filter=ACTIVE', mod: 'industryMod', description: 'working facilities, active factories, industries manufacturing right now' },
{ path: '/industry?filter=PAUSED', mod: 'industryMod', description: 'paused facilities, halted manufacturing, stopped industries' },
{ path: '/industry?filter=DISSOLVED', mod: 'industryMod', description: 'dissolved facilities, closed factories, ended cooperatives' },
{ path: '/industry?filter=RULES', mod: 'industryMod', description: 'industry rules, how facilities work, stewardship, quorum, majority, karma shares, distribution' },
{ path: '/housing', mod: 'housingMod', description: 'housing, homes, flats, apartments, rooms, lodging, accommodation, real estate, places to live' },
{ path: '/housing?filter=SALE', mod: 'housingMod', description: 'homes for sale, property for sale, real estate on sale, houses being sold' },
{ path: '/housing?filter=RENT', mod: 'housingMod', description: 'places to rent, rentals, monthly rent, flat to let, room to rent' },
{ path: '/housing?filter=COUCHSURFING', mod: 'housingMod', description: 'couchsurfing, free stay, hospitality, host me, sofa to sleep, guest room offered for free' },
{ path: '/housing?filter=REQUESTED', mod: 'housingMod', description: 'places I requested, my housing requests, stays I asked for' },
{ path: '/housing?filter=MINE', mod: 'housingMod', description: 'my places, housing listings I published, my properties' },
{ path: '/jobs', mod: 'jobsMod', description: 'jobs, work, hiring, salaries, vacancies, applications' },
{ path: '/market', mod: 'marketMod', description: 'market, marketplace, buy, sell, items, auctions, ECO' },
{ path: '/shops', mod: 'shopsMod', description: 'shops, stores, products, ecommerce, vendors' },
{ path: '/banking', mod: 'bankingMod', description: 'banking, wallet, ECO balance, send money, transfers, payments, UBI claim, karma score, eco tax penalty, ECOin value' },
{ path: '/transfers', mod: 'transfersMod', description: 'transfers, payments, money movements, ECO transactions, history, smart contracts, contract PDF, export contract' },
{ path: '/wallet', mod: 'walletMod', description: 'wallet, ECOin address, send and receive, QR code, balance' },
{ path: '/parliament', mod: 'parliamentMod', description: 'parliament, governance, government, proposals, laws, leaders, voting' },
{ path: '/courts', mod: 'courtsMod', description: 'courts, judges, accusations, mediators, justice, disputes' },
{ path: '/votes', mod: 'votesMod', description: 'votes, votations, ballots, decisions, polling, voting, polls, surveys, multi-option votes' },
{ path: '/opinions', mod: 'opinionsMod', description: 'opinions, reactions, ratings, sentiment, expressing views, interesting useful funny boring sad joyful angry confused inspiring frustrating curious sympathetic challenged surprised exited categories' },
{ path: '/trending', mod: 'trendingMod', description: 'trending, popular, hot, top voted, what is being discussed, most opinions, top spread, viral content' },
{ path: '/activity', mod: null, description: 'activity, recent actions, what is happening, my history, others actions, feed votes, opinions activity, spread activity' },
{ path: '/activity?filter=mine', mod: null, description: 'my activity, my actions, what I did, my history, what I published' },
{ path: '/activity?filter=today', mod: null, description: 'today activity, last 24h, recent today' },
{ path: '/reports', mod: 'reportsMod', description: 'reports, bug reports, abuse, incidents, severity, confirmations' },
{ path: '/audios', mod: 'audiosMod', description: 'audios, music, podcasts, voice recordings, sound files' },
{ path: '/audios?filter=bcs', mod: 'audiosMod', description: 'BCS audios, blockchain sonification audios, melody compositions published by inhabitants, transcode source audio, embedded steganography' },
{ path: '/audios?filter=favorites', mod: 'audiosMod', description: 'favorite audios, starred audios, saved music' },
{ path: '/videos', mod: 'videosMod', description: 'videos, films, clips, recordings, watch' },
{ path: '/images', mod: 'imagesMod', description: 'images, photos, pictures, gallery, memes' },
{ path: '/documents', mod: 'docsMod', description: 'documents, PDFs, files, papers, references' },
{ path: '/bookmarks', mod: 'bookmarksMod', description: 'bookmarks, links, saved websites, favorites' },
{ path: '/files', mod: 'filesMod', description: 'files, any file type, uploads, downloads, binaries, archives' },
{ path: '/torrents', mod: 'torrentsMod', description: 'torrents, magnet links, file sharing, downloads' },
{ path: '/tags', mod: 'tagsMod', description: 'tags, hashtags, topics, categories, labels' },
{ path: '/search', mod: null, description: 'search, find, query, lookup' },
{ path: '/inbox', mod: null, description: 'inbox, notifications, mentions, emergencies, messages addressed to me, received PM' },
{ path: '/inbox?filter=sent', mod: null, description: 'sent messages, outgoing PM, my sent private messages, what I wrote' },
{ path: '/inbox?filter=reminders', mod: null, description: 'reminders, task reminders, calendar reminders, automatic notifications' },
{ path: '/phone', mod: 'phoneMod', description: 'phone, call, calls, voice call, call someone, ring, telephone, voip, audio call, private audio message, PaM, voicemail, voice message, missed calls, call history' },
{ path: '/inbox?filter=notifications', mod: null, description: 'bot notifications, BankingBot, PoliticalBot, MarketBot, notices per bot, unread notices' },
{ path: '/inbox?filter=archived', mod: null, description: 'archived messages, archived PMs, put away conversations' },
{ path: '/pm', mod: null, description: 'private messages, direct messages, DMs, encrypted PM, compose new PM' },
{ path: '/mentions', mod: null, description: 'mentions, who mentioned me, tagged me, my mentions, posts mentioning me, tribe mentions' },
{ path: '/games', mod: 'gamesMod', description: 'games, play, mini-games, scoring, fun' },
{ path: '/ai', mod: 'aiMod', description: '42, AI assistant, ask the AI, chat with AI, oasis assistant, artificial intelligence, ai help, ai answers' },
{ path: '/pixelia', mod: 'pixeliaMod', description: 'pixelia, pixel canvas, draw, collaborative pixel art' },
{ path: '/cv', mod: null, description: 'cv, curriculum, resume, my profile, skills, experiences' },
{ path: '/backup', mod: 'backupMod', description: 'backup, export keys, import keys, recovery kit, full copy of my data, restore on another device, encrypted backup' },
{ path: '/cipher', mod: 'cipherMod', description: 'cipher, encrypt, decrypt, password, vault' },
{ path: '/dev', mod: 'devMod', description: 'source code, oasis codebase, file tree, browse files, read the code' },
{ path: '/dev/search', mod: 'devMod', description: 'grep the codebase, find a function, find a string in source files' },
{ path: '/dev/map', mod: 'devMod', description: 'module map, which file implements a module, models views routes tests' },
{ path: '/stats', mod: null, description: 'stats, statistics, KPIs, metrics, dashboard, carbon footprint' },
{ path: '/peers', mod: null, description: 'peers, connections, network, nodes, who am I connected to, LAN, refresh discovery, export peer list, import peer list, remove idle' },
{ path: '/invites', mod: 'invitesMod', description: 'invites, pub invitations, join code, follow PUB, federations, federated networks, import pubs, export pubs, unreachable pubs, redeem tribe invite code, join tribe by code, redeem course code, join school course by code, accept invitation' },
{ path: '/graphos', mod: 'graphosMod', description: 'graphos, network map, visualization, relationship graph' },
{ path: '/modules', mod: null, description: 'modules, features, enable disable plugins, settings' },
{ path: '/settings', mod: null, description: 'settings, preferences, language, theme, configuration' },
{ path: '/favorites', mod: 'favoritesMod', description: 'favorites, starred items, saved content, my bookmarks audios videos images documents files pads rooms chats calendars maps torrents' },
{ path: '/favorites?filter=recent', mod: 'favoritesMod', description: 'recent favorites, latest starred items' },
{ path: '/logs', mod: 'logsMod', description: 'logs, life log, personal records, journal, experiences' },
{ path: '/melody', mod: 'melodyMod', description: 'melody, sound of my blockchain, music, generate sound, audio of blocks, sonification, publish BCS, hidden message, steganography, embed text in waveform' },
{ path: '/melody?filter=all', mod: 'melodyMod', description: 'BCS from other inhabitants, all blockchain compositions, listen to peers melodies, transcode audio of others' },
{ path: '/profile', mod: null, description: 'my profile, my avatar, my page, my identity, my data, my clearnet link' },
{ path: '/profile/edit', mod: null, description: 'edit profile, edit avatar, change name, change description, visibility prefs, sensors, eco tax toggle, clearnet toggle, GPG fingerprint' },
{ path: '/blockexplorer', mod: null, description: 'blockexplorer, blockchain, blockchain explorer, blocks, ledger, chain, carbon footprint per block, chain history' },
{ path: '/fediverse', mod: 'fediverseMod', description: 'multiverse, fediverse, mastodon, telegram, activitypub, external social networks, federated timeline, telegram chats and channels, cross-post, fediverse handle, follow external accounts' },
{ path: '/stats?filter=ALL', mod: null, description: 'global stats, network kpis, total carbon footprint, total inhabitants, network size' },
{ path: '/stats?filter=MINE', mod: null, description: 'my stats, my carbon footprint, my activity numbers, personal kpis' },
{ path: '/tribes/create', mod: 'tribesMod', description: 'create tribe, new tribe, new group, start community, create private room' },
{ path: '/chats', mod: 'chatsMod', description: 'create chat, new chat, start conversation, new encrypted room' },
{ path: '/pads', mod: 'padsMod', description: 'create pad, new pad, new collaborative document, start shared note' },
{ path: '/rooms?filter=create', mod: 'roomsMod', description: 'create room, new room, open an audio room, start a voice room, crear sala' },
{ path: '/wiki?filter=create', mod: 'wikiMod', description: 'create wiki page, new wiki page, write article, document knowledge' },
{ path: '/campaigns?filter=CREATE', mod: 'campaignsMod', description: 'start a campaign, create a petition, collect signatures for a cause' },
{ path: '/podcasts?filter=CREATE', mod: 'podcastsMod', description: 'create podcast, new podcast channel, start a podcast, publish episodes' },
{ path: '/logistics?filter=CREATE', mod: 'logisticsMod', description: 'offer a ride, request a ride, offer transport, need to move something, publish a shipment, new route' },
{ path: '/mailing?filter=CREATE', mod: 'mailingMod', description: 'create mailing list, new mailing list, open a list, closed encrypted list' },
{ path: '/emergencies?filter=CREATE', mod: 'emergenciesMod', description: 'create emergency, raise emergency, raise alert, warn the network, report outage' },
{ path: '/calendars', mod: 'calendarsMod', description: 'create calendar, new calendar, start schedule' },
{ path: '/maps', mod: 'mapsMod', description: 'create map, new map, new offline map' },
{ path: '/events', mod: 'eventsMod', description: 'create event, new event, schedule meetup' },
{ path: '/projects', mod: 'projectsMod', description: 'create project, new project, start crowdfunding' },
{ path: '/school?filter=create', mod: 'schoolMod', description: 'create course, new course, teach a class, offer lessons' },
{ path: '/industry?filter=CREATE', mod: 'industryMod', description: 'create facility, new factory, new workshop, found a cooperative, means of manufacturing' },
{ path: '/housing?filter=CREATE', mod: 'housingMod', description: 'publish a place, offer accommodation, list a home, new housing listing, host someone' },
{ path: '/jobs', mod: 'jobsMod', description: 'create job, post job offer, new vacancy, hire' },
{ path: '/market', mod: 'marketMod', description: 'create market item, sell something, new auction, list for sale' },
{ path: '/shops', mod: 'shopsMod', description: 'create shop, open store, new vendor, list products' },
{ path: '/tasks', mod: 'tasksMod', description: 'create task, new todo, new assignment' },
{ path: '/reports', mod: 'reportsMod', description: 'create report, file bug, report issue, report abuse' }
]
const CACHE_FILE = path.join(__dirname, 'embeddings', 'routes_cache.json')
let cache = null
const buildCacheKey = () => ROUTES.map(r => r.path + '|' + r.description).join('\n')
const loadCache = () => {
try {
if (!fs.existsSync(CACHE_FILE)) return null
const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
if (data.key === buildCacheKey() && Array.isArray(data.entries)) return data.entries
return null
} catch (_) {
return null
}
}
const saveCache = (entries) => {
try {
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
fs.writeFileSync(CACHE_FILE, JSON.stringify({ key: buildCacheKey(), entries }, null, 2), 'utf8')
} catch (_) {}
}
const ensureIndex = async ({ embed }) => {
if (cache) return cache
const cached = loadCache()
if (cached) { cache = cached; return cache }
const entries = []
for (const r of ROUTES) {
const vec = await embed(r.description)
if (!vec) return null
entries.push({ path: r.path, mod: r.mod, vector: vec })
}
cache = entries
saveCache(entries)
return cache
}
const dot = (a, b) => {
let s = 0
const n = Math.min(a.length, b.length)
for (let i = 0; i < n; i++) s += a[i] * b[i]
return s
}
const descriptionByPath = (p) => {
const r = ROUTES.find(x => x.path === p)
return r ? r.description : ''
}
const dedupeByPath = (list) => {
const seen = new Set()
const out = []
for (const item of list) {
if (seen.has(item.path)) continue
seen.add(item.path)
out.push(item)
}
return out
}
const resolveBest = async (queryVector, { isModuleEnabled, threshold = 0.4, embed } = {}) => {
const idx = await ensureIndex({ embed })
if (!idx) return null
let best = null
for (const entry of idx) {
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
const score = dot(queryVector, entry.vector)
if (!best || score > best.score) best = { path: entry.path, score }
}
if (!best || best.score < threshold) return null
return best
}
const resolveTopK = async (queryVector, { isModuleEnabled, threshold = 0.35, embed } = {}, k = 5) => {
const idx = await ensureIndex({ embed })
if (!idx) return []
const all = []
for (const entry of idx) {
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
const score = dot(queryVector, entry.vector)
if (score < threshold) continue
all.push({ path: entry.path, mod: entry.mod, score, description: descriptionByPath(entry.path) })
}
all.sort((a, b) => b.score - a.score)
return dedupeByPath(all).slice(0, Math.max(1, k|0))
}
const KEYWORD_STOPWORDS = new Set([
'the', 'this', 'that', 'these', 'those', 'and', 'for', 'with', 'from', 'into', 'about', 'want', 'show', 'see', 'find', 'give', 'get', 'go', 'my', 'me', 'to', 'of', 'in', 'on', 'at', 'an', 'is', 'are', 'do', 'does',
'week', 'year', 'month', 'today', 'tomorrow', 'now', 'next', 'last',
'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'de', 'del', 'este', 'esta', 'esto', 'estos', 'estas', 'ese', 'esa', 'eso', 'que', 'quiero', 'ver', 'buscar', 'dame', 'ir', 'mi', 'mis', 'lo', 'se', 'al', 'en', 'con', 'para', 'por',
'semana', 'mes', 'hoy', 'ahora', 'proximo', 'próximo', 'proxima', 'próxima', 'ultimo', 'último', 'ultima', 'última',
'els', 'les', 'uns', 'unes', 'dels', 'aquest', 'aquesta', 'aquests', 'aquestes', 'això', 'aquell', 'aquella', 'vull', 'veure', 'cercar', 'anar', 'meu', 'meva', 'meus', 'meves', 'amb', 'per', 'als', 'on', 'què', 'com',
'setmana', 'avui', 'ara', 'proper', 'propera', 'darrer', 'darrera',
'os', 'as', 'unha', 'unhas', 'do', 'da', 'dos', 'das', 'isto', 'iso', 'estes', 'quero', 'miña', 'miñas', 'meu', 'meus', 'no', 'na', 'nos', 'nas', 'co', 'coa', 'onde', 'como',
'hoxe', 'agora'
])
const resolveKeywordTopK = ({ isModuleEnabled } = {}, query, k = 8) => {
let tokens = String(query || '').toLowerCase().split(/[^a-z0-9À-ſ]+/).filter(t => t && t.length >= 2)
const meaningful = tokens.filter(t => !KEYWORD_STOPWORDS.has(t))
if (meaningful.length) tokens = meaningful
if (!tokens.length) return []
const all = []
for (const entry of ROUTES) {
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
const haystack = (entry.description || '').toLowerCase() + ' ' + entry.path.toLowerCase()
let hits = 0
for (const t of tokens) {
if (haystack.includes(t)) hits += 1
}
if (hits === 0) continue
all.push({ path: entry.path, mod: entry.mod, score: hits / tokens.length, description: entry.description })
}
all.sort((a, b) => b.score - a.score)
return dedupeByPath(all).slice(0, Math.max(1, k|0))
}
module.exports = { ROUTES, ensureIndex, resolveBest, resolveTopK, resolveKeywordTopK }

77
src/AI/semantic_search.js Normal file
View file

@ -0,0 +1,77 @@
const fs = require('fs')
const { readTyped } = require('../models/typed_log')
const { buildValidatedTombstoneSet } = require('../models/tombstone_validator')
const { versionGroups, versionEntry } = require('../backend/dedupe')
const VECTORS_FILE = 'AI-search-vectors.json'
const MAX_ITEMS = 4000
const MIN_SCORE = 0.42
const TEXT_MAX = 400
const TYPES = ['post', 'event', 'job', 'project', 'market', 'shop', 'shopProduct', 'tribe', 'task', 'wikiPage', 'document', 'audio', 'video', 'image', 'bookmark', 'forum', 'campaign', 'emergency', 'podcast', 'schoolCourse', 'housing', 'logisticsRoute', 'mailingList', 'room', 'curriculum', 'blog', 'report', 'poll', 'torrent', 'file']
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
const squash = (s) => String(s || '').replace(/\s+/g, ' ').trim()
const textOf = (c) => {
if (!c || typeof c !== 'object') return ''
if (c.encryptedPayload || c.encryptedQuestion) return ''
const title = squash(c.title || c.name || c.question || c.subject || c.concept || '')
const body = squash(c.description || c.text || c.body || c.contentWarning || '')
const tags = Array.isArray(c.tags) ? c.tags.map(t => String(t || '')).join(' ') : ''
return `${title} ${body} ${tags}`.trim().slice(0, TEXT_MAX)
}
let indexing = null
const indexOnce = async (ssb, { embed, limit }) => {
const msgs = await readTyped(ssb, [...TYPES, 'tombstone'], { limit }).catch(() => [])
const tomb = buildValidatedTombstoneSet(msgs)
const content = msgs.filter(m => m && m.key && m.value && m.value.content && TYPES.includes(m.value.content.type))
const byKey = new Map(content.map(m => [m.key, m]))
const vg = versionGroups(content.map(versionEntry))
const usable = [...vg.roots].map(r => byKey.get(vg.tipOf(r)))
.filter(m => m && !tomb.has(m.key) && textOf(m.value.content))
.sort((a, b) => ((a.value.timestamp || 0) - (b.value.timestamp || 0)))
const newest = usable.slice(-MAX_ITEMS)
const cache = readVectors()
let dirty = false
const items = []
for (const m of newest) {
let v = cache[m.key]
if (!Array.isArray(v)) {
v = await embed(textOf(m.value.content)).catch(() => null)
if (!v) continue
cache[m.key] = v
dirty = true
}
items.push({ msg: m, vector: v })
}
if (dirty) {
const keep = new Set(newest.map(m => m.key))
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
writeVectors(cache)
}
return items
}
const search = async (ssb, query, { embed, cosine, limit = 1000, k = 20 } = {}) => {
if (typeof embed !== 'function' || typeof cosine !== 'function' || !ssb) return []
const q = squash(query)
if (!q) return []
const qv = await embed(q).catch(() => null)
if (!qv) return []
if (!indexing) indexing = indexOnce(ssb, { embed, limit }).finally(() => { indexing = null })
const items = await indexing.catch(() => [])
const scored = []
for (const it of items) {
const s = cosine(qv, it.vector)
if (s >= MIN_SCORE) scored.push({ msg: it.msg, score: s })
}
scored.sort((a, b) => b.score - a.score)
return scored.slice(0, k)
}
module.exports = { search, textOf, TYPES, MIN_SCORE }

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,206 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const { escapePdf } = require('./pdfDocument');
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
const splitSegments = (line) => {
const segs = [];
let last = 0;
const re = new RegExp(linkPattern.source, 'g');
let m;
while ((m = re.exec(line)) !== null) {
if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
segs.push({ t: m[0], l: true });
last = m.index + m[0].length;
}
if (last < line.length) segs.push({ t: line.slice(last), l: false });
return segs;
};
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildLogsPdf(entries, oasisId, opts = {}) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 22;
const bodyBottom = footerH + 10;
const lineH = 12;
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const allLines = [];
for (const e of entries) {
const ts = new Date(e.ts);
const when = ts.toISOString().replace('T', ' ').slice(0, 19);
allLines.push({ kind: 'header', text: `[${when}]:` });
allLines.push({ kind: 'blank', text: '' });
for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
allLines.push({ kind: 'blank', text: '' });
}
if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
const pages = [];
for (let i = 0; i < allLines.length; i += maxBodyLines) {
pages.push(allLines.slice(i, i + maxBodyLines));
}
if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
const logoX = marginX;
const logoY = pageH - headerH + 15;
parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Experience logs')}) Tj\nET`);
const inhabitantPrefix = 'Inhabitant: ';
const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
const charW = 6;
for (const ln of pg) {
if (ln.kind === 'header') {
parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.text) {
const segs = splitSegments(ln.text);
let x = marginX;
for (const s of segs) {
if (!s.t) continue;
const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
x += s.t.length * charW;
}
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) {
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildLogsPdf };

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

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

View file

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

1485
src/backend/pdf.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,199 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const WIN_ANSI_HIGH = {
'\u20AC': '\x80', '\u201A': '\x82', '\u0192': '\x83', '\u201E': '\x84',
'\u2026': '\x85', '\u2020': '\x86', '\u2021': '\x87', '\u02C6': '\x88',
'\u2030': '\x89', '\u0160': '\x8A', '\u2039': '\x8B', '\u0152': '\x8C',
'\u017D': '\x8E', '\u2018': '\x91', '\u2019': '\x92', '\u201C': '\x93',
'\u201D': '\x94', '\u2022': '\x95', '\u2013': '\x96', '\u2014': '\x97',
'\u02DC': '\x98', '\u2122': '\x99', '\u0161': '\x9A', '\u203A': '\x9B',
'\u0153': '\x9C', '\u017E': '\x9E', '\u0178': '\x9F'
};
const escapePdf = s => String(s == null ? '' : s)
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\u0152\u0153\u0160\u0161\u0178\u017D\u017E\u0192\u02C6\u02DC\u2013\u2014\u2018\u2019\u201A\u201C\u201D\u201E\u2020\u2021\u2022\u2026\u2030\u2039\u203A\u20AC\u2122]/g,
(c) => WIN_ANSI_HIGH[c] || '?')
.replace(/[^\x20-\xFF\x80-\x9F]/g, '?')
.replace(/\\/g, '\\\\')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)');
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt == null ? '' : txt).split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
const flattenSections = (sections) => {
const lines = [];
for (const s of Array.isArray(sections) ? sections : []) {
if (!s) continue;
if (s.kind === 'kv') {
const txt = `${s.label}: ${s.value == null ? '' : s.value}`;
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
} else if (s.kind === 'text') {
for (const w of wrap(s.text, 82)) lines.push({ kind: 'kv', text: w });
} else {
lines.push({ kind: s.kind, text: s.text });
}
}
return lines;
};
function buildDocumentPdf({ title, issuedToLabel, issuedTo, sections } = {}) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 24;
const bodyBottom = footerH + 10;
const lineH = 14;
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const lines = flattenSections(sections);
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
const pages = [];
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
if (!pages.length) pages.push([]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf(title || 'OASIS')}) Tj\nET`);
if (issuedTo) {
const prefix = `${issuedToLabel || 'Issued to'}: `;
const prefixW = prefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(issuedTo))}) Tj\nET`);
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
for (const ln of pg) {
if (ln.kind === 'title') {
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'subtitle') {
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'section') {
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
} else if (ln.kind === 'kv') {
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildDocumentPdf, escapePdf, wrap };

View file

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

View file

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

View file

@ -1,139 +0,0 @@
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderTextPreview(text, maxLength = 220) {
if (!text) return ''
let preview = String(text)
preview = preview
.replace(/```[\s\S]*?```/g, '')
.replace(/^>.*$/gm, '')
.replace(/^#{1,6}\s*/gm, '')
.replace(/^- /gm, '')
.replace(/^\d+\. /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[.*?\]\(.*?\)/g, '')
.replace(/\n+/g, ' ')
.trim()
if (preview.length > maxLength) {
preview = preview.slice(0, maxLength) + '...'
}
return preview
}
function renderTextWithStyles(text) {
if (!text) return ''
const i18n = getI18n()
let html = String(text)
html = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
html = html
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
.replace(/^---$/gim, '<hr/>')
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
html = html
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
.replace(/`([^`]+)`/gim, '<code>$1</code>')
html = html
.replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
`<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
)
.replace(/\[video:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></video>`
)
.replace(/\[audio:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></audio>`
)
.replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
)
html = html
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
)
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
)
const escAttr = (s) => String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
html = html
.replace(/#(\w+)/g, (_, tag) =>
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
)
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
)
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
)
const lines = html.split('\n')
let result = ''
let inUL = false
let inOL = false
for (let line of lines) {
if (/^- /.test(line)) {
if (!inUL) {
result += '<ul>'
inUL = true
}
result += `<li>${line.replace(/^- /, '')}</li>`
continue
}
if (/^\d+\. /.test(line)) {
if (!inOL) {
result += '<ol>'
inOL = true
}
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
continue
}
if (inUL) {
result += '</ul>'
inUL = false
}
if (inOL) {
result += '</ol>'
inOL = false
}
result += line + '<br>'
}
if (inUL) result += '</ul>'
if (inOL) result += '</ol>'
return result
}
module.exports = { renderTextWithStyles, renderTextPreview }

View file

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

View file

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

View file

@ -1,14 +1,32 @@
"use strict";
const { JSDOM } = require('../server/node_modules/jsdom');
const DOMPurify = require('../server/node_modules/dompurify');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const SAFE_SRC = /^\/(?:blob|image)\/[^\s"'<>]+$/i;
const SAFE_HREF = /^(?:https?:\/\/|\/(?!\/)|#|mailto:)/i;
const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/;
let purifier = null;
const getPurify = () => {
if (purifier) return purifier;
const { JSDOM } = require('../server/node_modules/jsdom');
const DOMPurify = require('../server/node_modules/dompurify');
const purify = DOMPurify(new JSDOM('').window);
purify.addHook('uponSanitizeAttribute', (node, data) => {
const name = String(data.attrName || '').toLowerCase();
const value = String(data.attrValue || '').trim();
if (name === 'src' && !SAFE_SRC.test(value)) data.keepAttr = false;
if (name === 'href' && !SAFE_HREF.test(value)) data.keepAttr = false;
if (name === 'class') {
const kept = value.split(/\s+/).filter(c => SAFE_CLASS.test(c));
if (kept.length) data.attrValue = kept.join(' '); else data.keepAttr = false;
}
});
purifier = purify;
return purifier;
};
const dropControlChars = (html) => String(html).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
const stripDangerousTags = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
return dropControlChars(getPurify().sanitize(input, {
ALLOWED_TAGS: [
'p', 'br',
'b', 'strong', 'i', 'em', 'u',
@ -19,13 +37,12 @@ const stripDangerousTags = (input) => {
ALLOWED_ATTR: [],
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
FORBID_ATTR: ['style']
});
}));
};
const sanitizeHtml = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
return dropControlChars(getPurify().sanitize(input, {
ALLOWED_TAGS: [
'p', 'br', 'hr',
'b', 'strong', 'i', 'em', 'u', 's', 'del',
@ -39,7 +56,7 @@ const sanitizeHtml = (input) => {
ALLOWED_ATTR: ['href', 'class', 'target', 'rel', 'src', 'alt', 'title', 'controls'],
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script', 'style', 'link', 'meta'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onsubmit', 'onchange', 'style']
});
}));
};
module.exports = { stripDangerousTags, sanitizeHtml };

View file

@ -1,228 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const { escapePdf } = require('./pdfDocument');
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildSmartContractPdf({ transfer, block, viewerId }) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 24;
const bodyBottom = footerH + 10;
const lineH = 14;
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const t = transfer || {};
const b = block || {};
const fmt = v => (v === undefined || v === null) ? '' : String(v);
const fmtAmount = () => {
const cat = String(t.category || 'ECONOMIC').toUpperCase();
const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO';
return `${Number(t.amount || 0).toFixed(6)} ${unit}`;
};
const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '';
const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : [];
const required = t.from === t.to ? 1 : 2;
const confirmedCount = confirmedBy.length;
const tags = Array.isArray(t.tags) ? t.tags.join(', ') : '';
const sections = [];
sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'OASIS IDs' });
sections.push({ kind: 'kv', label: 'From', value: fmt(t.from) });
sections.push({ kind: 'kv', label: 'To', value: fmt(t.to) });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'TERMS' });
sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() });
if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() });
sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) });
if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) });
if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'CONFIRMATIONS' });
sections.push({ kind: 'kv', label: 'Required', value: String(required) });
sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) });
if (confirmedBy.length) {
for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) });
}
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' });
if (b && b.id) {
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) });
if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) });
if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() });
if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) });
if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` });
} else {
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) });
}
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'METADATA' });
sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) });
if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) });
if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) });
const lines = [];
for (const s of sections) {
if (s.kind === 'kv') {
const txt = `${s.label}: ${s.value}`;
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
} else {
lines.push(s);
}
}
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
const pages = [];
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
if (!pages.length) pages.push([]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Smart Contract')}) Tj\nET`);
const prefix = 'Issued to: ';
const prefixW = prefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(viewerId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
for (const ln of pg) {
if (ln.kind === 'title') {
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'subtitle') {
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'section') {
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
} else if (ln.kind === 'kv') {
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildSmartContractPdf };

View file

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

View file

@ -1,116 +1,119 @@
const buildVoteTally = (messages) => {
const nodes = new Map();
const ballots = [];
const INSTITUTIONAL_VOTE_TAG = /^(gov|govMethod|courtsCase|courtsMethod):/;
for (const m of messages) {
const msOf = (v) => {
const t = Date.parse(v);
return Number.isFinite(t) ? t : null;
};
const isLater = (a, b) => a.ts > b.ts || (a.ts === b.ts && a.seq > b.seq);
const legacyChoice = (kid, parent, options) => {
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters : [];
const pVoters = Array.isArray(parent.content.voters) ? parent.content.voters : [];
if (!kVoters.includes(kid.author) || pVoters.includes(kid.author)) return null;
const kVotes = kid.content.votes || {};
const pVotes = parent.content.votes || {};
let choice = null;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(pVotes[o]) || 0);
if (d === 0) continue;
if (d !== 1 || choice !== null) return null;
choice = o;
}
return choice;
};
const buildVoteResults = (messages) => {
const nodes = new Map();
const ballotsByTarget = new Map();
for (const m of messages || []) {
const v = m && m.value;
const c = v && v.content;
if (!c) continue;
if (c.type === 'votesVote') { if (c.target) ballots.push({ target: c.target, author: v.author, choice: c.choice }); continue; }
const ts = Number(v.timestamp || m.timestamp || 0) || 0;
const seq = Number(v.sequence || 0) || 0;
if (c.type === 'votesVote') {
if (typeof c.target !== 'string' || !v.author) continue;
if (!ballotsByTarget.has(c.target)) ballotsByTarget.set(c.target, []);
ballotsByTarget.get(c.target).push({ author: v.author, choice: c.choice, ts, seq });
continue;
}
if (c.type !== 'votes') continue;
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts: v.timestamp || m.timestamp || 0 });
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts, seq });
}
const prev = new Map();
for (const [k, n] of nodes) {
const t = n.content.replaces;
if (typeof t === 'string' && nodes.has(t)) prev.set(k, t);
}
const childrenOf = new Map();
for (const [k, p] of prev) {
if (!childrenOf.has(p)) childrenOf.set(p, []);
childrenOf.get(p).push(k);
if (typeof t === 'string' && t !== k && nodes.has(t)) prev.set(k, t);
}
const rootOf = (k) => { let x = k, g = 0; while (prev.has(x) && g++ < 100000) x = prev.get(x); return x; };
const groups = new Map();
for (const k of nodes.keys()) { const r = rootOf(k); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(k); }
const tallyByKey = new Map();
const results = new Map();
for (const [root, keys] of groups.entries()) {
const rootNode = nodes.get(root);
if (!rootNode) continue;
const options = Array.isArray(rootNode.content.options) ? rootNode.content.options : [];
let votesMap = Object.assign({}, rootNode.content.votes || {});
let voters = Array.isArray(rootNode.content.voters) ? rootNode.content.voters.slice() : [];
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
let cur = root;
let g = 0;
let creatorChoice = null;
let creatorCounted = false;
while (g++ < 100000) {
const kids = (childrenOf.get(cur) || []).map(k => nodes.get(k)).filter(Boolean).sort((a, b) => a.ts - b.ts);
if (!kids.length) break;
let advanced = false;
for (const kid of kids) {
const kVotes = Object.assign({}, kid.content.votes || {});
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters.slice() : [];
for (const o of options) if (!(o in kVotes)) kVotes[o] = 0;
if (kid.author === rootNode.author) {
if (!creatorCounted && kVoters.includes(rootNode.author) && !voters.includes(rootNode.author)) {
creatorCounted = true;
let inc = null;
let incCount = 0;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
if (d >= 1) { inc = o; incCount++; }
}
if (incCount === 1) creatorChoice = inc;
}
votesMap = kVotes;
voters = kVoters;
cur = kid.key;
advanced = true;
break;
}
if (voters.includes(kid.author)) continue;
if (kVoters.length !== voters.length + 1) continue;
const added = kVoters.filter(x => !voters.includes(x));
if (added.length !== 1 || added[0] !== kid.author) continue;
let deltas = 0;
let deltaOk = true;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
if (d === 0) continue;
if (d === 1) { deltas++; continue; }
deltaOk = false;
break;
}
if (!deltaOk || deltas !== 1) continue;
votesMap = kVotes;
voters = kVoters;
cur = kid.key;
advanced = true;
break;
const creator = rootNode.author;
const tags = Array.isArray(rootNode.content.tags) ? rootNode.content.tags : [];
const frozen = tags.some(t => INSTITUTIONAL_VOTE_TAG.test(String(t)));
let current = rootNode;
if (!frozen) {
for (const k of keys) {
const n = nodes.get(k);
if (n.author === creator && isLater(n, current)) current = n;
}
if (!advanced) break;
}
const options = Array.isArray(current.content.options) ? current.content.options.filter(o => typeof o === 'string') : [];
const deadline = typeof current.content.deadline === 'string' ? current.content.deadline : null;
const opensAt = rootNode.ts;
const closesAt = deadline ? msOf(deadline) : null;
const cast = [];
for (const k of keys) {
for (const b of ballotsByTarget.get(k) || []) cast.push(b);
const n = nodes.get(k);
if (n.author === creator || !prev.has(k)) continue;
const choice = legacyChoice(n, nodes.get(prev.get(k)), options);
if (choice !== null) cast.push({ author: n.author, choice, ts: n.ts, seq: n.seq });
}
if (voters.includes(rootNode.author)) {
voters = voters.filter(x => x !== rootNode.author);
if (creatorChoice && (Number(votesMap[creatorChoice]) || 0) > 0) votesMap[creatorChoice] = votesMap[creatorChoice] - 1;
}
const voterSet = new Set(voters);
let totalVotes = voters.length;
const chainKeys = new Set(keys);
for (const b of ballots) {
if (!chainKeys.has(b.target)) continue;
if (b.author === rootNode.author) continue;
if (!b.author || voterSet.has(b.author)) continue;
const latest = new Map();
for (const b of cast) {
if (!b.author || b.author === creator) continue;
if (!options.includes(b.choice)) continue;
voterSet.add(b.author);
voters.push(b.author);
votesMap[b.choice] = (votesMap[b.choice] || 0) + 1;
totalVotes += 1;
if (b.ts < opensAt) continue;
if (closesAt !== null && b.ts > closesAt) continue;
const known = latest.get(b.author);
if (!known || isLater(b, known)) latest.set(b.author, b);
}
const tally = { votes: votesMap, voters, totalVotes };
for (const k of keys) tallyByKey.set(k, tally);
const votes = {};
for (const o of options) votes[o] = 0;
const voters = [];
for (const b of [...latest.values()].sort((a, b) => a.ts - b.ts || a.seq - b.seq)) {
votes[b.choice] = (votes[b.choice] || 0) + 1;
voters.push(b.author);
}
const result = { votes, voters, totalVotes: voters.length, creator, rootId: root, deadline, options };
for (const k of keys) results.set(k, result);
}
return results;
};
const buildVoteTally = (messages) => {
const shared = new Map();
const tallyByKey = new Map();
for (const [k, r] of buildVoteResults(messages)) {
if (!shared.has(r)) shared.set(r, { votes: r.votes, voters: r.voters, totalVotes: r.totalVotes });
tallyByKey.set(k, shared.get(r));
}
return tallyByKey;
};
module.exports = { buildVoteTally };
module.exports = { buildVoteTally, buildVoteResults };

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

View file

@ -261,7 +261,7 @@ h3 { font-size: 1em !important; }
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
justify-content: center !important;
justify-content: flex-start !important;
align-items: center !important;
width: 100% !important;
max-width: 100% !important;
@ -288,8 +288,8 @@ h3 { font-size: 1em !important; }
}
.inhabitant-action .filter-btn {
min-height: 0 !important;
padding: 7px 12px !important;
font-size: 13px !important;
padding: 2px 9px !important;
font-size: .75em !important;
white-space: nowrap !important;
}
.profile-sensors-box {
@ -315,8 +315,8 @@ h3 { font-size: 1em !important; }
.activity-filter-col .filter-btn,
.activity-sub-filter .filter-btn {
min-height: 0 !important;
padding: 7px 12px !important;
font-size: 13px !important;
padding: 2px 9px !important;
font-size: .75em !important;
white-space: nowrap !important;
}
@ -450,10 +450,6 @@ h3 { font-size: 1em !important; }
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-media-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-overview-grid {
grid-template-columns: 1fr !important;
}
@ -466,24 +462,11 @@ h3 { font-size: 1em !important; }
height: 180px !important;
}
.tribe-votation-option {
flex-direction: column !important;
align-items: flex-start !important;
}
.tribe-content-header {
flex-direction: column !important;
gap: 8px !important;
}
.tribe-content-filters {
flex-wrap: wrap !important;
}
.tribe-media-filters {
flex-wrap: wrap !important;
}
.forum-card {
flex-direction: column !important;
}
@ -626,6 +609,7 @@ a.filter-btn,
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
flex: 0 0 auto !important;
height: 42px !important;
font-size: 16px !important;
box-sizing: border-box !important;
@ -672,7 +656,7 @@ a.filter-btn,
padding: 9px 16px !important;
}
.filters .ui-toolbar--filters {
justify-content: center !important;
justify-content: flex-start !important;
align-items: center !important;
gap: 6px !important;
}
@ -728,6 +712,15 @@ a.filter-btn,
max-width: 100% !important;
overflow-wrap: anywhere !important;
}
.card-field .card-value.user-link,
.card-field .card-value .user-link {
display: inline-block !important;
vertical-align: bottom !important;
max-width: 100% !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.market-grid,
.shop-products-grid,
@ -817,14 +810,14 @@ a.filter-btn,
margin-left: 0 !important;
}
.oasis-id-copy {
input.oasis-id-copy[readonly] {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
font-family: monospace !important;
font-size: 12px !important;
font-size: clamp(8px, 2.5vw, 12px) !important;
text-align: center !important;
padding: 8px !important;
padding: 8px 6px !important;
border: 1px solid #FFA500 !important;
border-radius: 8px !important;
background: rgba(255, 165, 0, 0.08) !important;
@ -849,6 +842,7 @@ a.create-button {
.peers-list .block-info-table,
.peers-technical-block .block-info-table,
.parliament-rows-table,
.gov-overview {
display: block !important;
width: 100% !important;
@ -859,17 +853,20 @@ a.create-button {
}
.peers-list .block-info-table tbody,
.peers-technical-block .block-info-table tbody,
.parliament-rows-table tbody,
.gov-overview tbody {
display: block !important;
width: 100% !important;
}
.gov-overview thead { display: none !important; }
.gov-overview thead,
.parliament-rows-table thead { display: none !important; }
.peers-list .block-info-table tr:first-child,
.peers-technical-block .block-info-table tr:first-child {
display: none !important;
}
.peers-list .block-info-table tr,
.peers-technical-block .block-info-table tr,
.parliament-rows-table tbody tr,
.gov-overview tbody tr {
display: block !important;
width: 100% !important;
@ -882,6 +879,7 @@ a.create-button {
}
.peers-list .block-info-table td,
.peers-technical-block .block-info-table td,
.parliament-rows-table tbody td,
.gov-overview tbody td {
display: flex !important;
justify-content: space-between !important;
@ -897,6 +895,7 @@ a.create-button {
}
.peers-list .block-info-table td::before,
.peers-technical-block .block-info-table td::before,
.parliament-rows-table tbody td::before,
.gov-overview tbody td::before {
content: attr(data-label);
flex: 0 0 auto !important;
@ -913,6 +912,121 @@ a.create-button {
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
.peers-list .block-info-table td .peer-key,
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
.phone-history td.phone-dir,
.phone-contact .phone-dir,
.phone-dir {
display: none !important;
}
.phone-contact {
gap: 6px !important;
padding: 8px !important;
}
.phone-contact .user-link {
flex: 1 1 100% !important;
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.phone-contact .date-link {
flex: 0 1 auto !important;
font-size: 12px !important;
}
.phone-history,
.phone-history tbody {
display: block !important;
width: 100% !important;
}
.phone-history tr:first-child {
display: none !important;
}
.phone-history tr {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
border: 1px solid #444 !important;
border-radius: 10px !important;
margin: 0 0 8px 0 !important;
padding: 4px 10px !important;
}
.phone-history td {
display: flex !important;
justify-content: space-between !important;
align-items: baseline !important;
gap: 10px !important;
width: 100% !important;
border: 0 !important;
padding: 3px 0 !important;
font-size: 12px !important;
text-align: right !important;
}
.phone-history td::before {
content: attr(data-label);
flex: 0 0 auto !important;
font-weight: 700 !important;
text-transform: uppercase;
font-size: 10px !important;
letter-spacing: .03em;
opacity: .7 !important;
text-align: left !important;
white-space: nowrap !important;
}
.phone-history td.phone-row-actions {
justify-content: flex-end !important;
padding: 0 !important;
margin-top: -6px !important;
}
.phone-history .phone-row-delete .btn-singleview,
.phone-latest-row .phone-row-delete .btn-singleview {
width: 32px !important;
height: 32px !important;
min-width: 0 !important;
min-height: 0 !important;
line-height: 1 !important;
padding: 0 !important;
font-size: 14px !important;
}
.phone-history td.phone-row-actions::before {
content: none !important;
}
.phone-latest-row .phone-row-delete {
margin-left: auto !important;
}
.phone-history td .user-link {
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.peers-list .block-info-table td .ubi-tick-ok,
.peers-list .block-info-table td .peer-tor-no,
.peers-technical-block .block-info-table td .ubi-tick-ok,
.peers-technical-block .block-info-table td .peer-tor-no {
padding: 0 !important;
line-height: 1.2 !important;
}
.conn-actions,
.peers-conn-actions {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
gap: 8px !important;
width: 100% !important;
}
.conn-actions form,
.peers-conn-actions form {
flex: 1 1 45% !important;
min-width: 130px !important;
max-width: 100% !important;
margin: 0 !important;
display: block !important;
}
.conn-actions form button,
.peers-conn-actions form button {
width: 100% !important;
min-width: 0 !important;
}
.relationship-status .relationship-actions { flex-direction: column !important; align-items: stretch !important; gap: 8px !important; }
.relationship-status .relationship-actions form,
@ -921,10 +1035,21 @@ a.create-button {
.profile-sensors-box { align-items: center !important; text-align: center !important; }
.profile-sensors-box > * { margin-left: auto !important; margin-right: auto !important; justify-content: center !important; }
.tribe-feed .refeed-column,
.tribe-feed-full .refeed-column { align-self: center !important; align-items: center !important; justify-content: center !important; }
.tribe-feed .card-footer,
.tribe-feed-full .card-footer { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
.profile-layout,
.profile-layout .tribe-side,
.profile-layout .tribe-main {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box !important;
}
.profile-side-description,
.profile-side-description p,
.trending-card .card-section,
.feed-text {
word-break: normal !important;
overflow-wrap: break-word !important;
}
.invite-qr-card { display: flex !important; justify-content: center !important; border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; margin: 0 auto !important; }
.invite-qr-img { display: block; margin: 0 auto; }
@ -942,3 +1067,445 @@ a.create-button {
.chat-messages-list { padding: 0 !important; }
.chat-message { max-width: 92% !important; }
.chat-message.chat-poll { max-width: 97% !important; }
.feed-workspace {
display: block !important;
}
.feed-side {
display: none !important;
}
.tribe-info-table,
.tribe-info-table tbody,
.tribe-info-table tr,
.tribe-info-table td {
display: block !important;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
text-align: left !important;
overflow: visible !important;
}
.tribe-info-table td .user-link {
display: inline-block !important;
max-width: 100% !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.school-lesson-summary {
flex-wrap: wrap !important;
}
.school-lesson-summary-title {
flex: 1 1 100% !important;
min-width: 0 !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
}
.school-lesson-summary-meta {
flex: 1 1 100% !important;
white-space: normal !important;
}
.feed-votes-summary {
flex-wrap: wrap !important;
max-width: 100% !important;
min-width: 0 !important;
}
.feed-votes-summary .vote-category {
white-space: nowrap !important;
flex: 0 0 auto !important;
}
.tribe-open-invite-code {
font-size: .85em !important;
}
.school-progress-row {
flex-direction: column !important;
align-items: flex-start !important;
gap: 6px !important;
}
.school-progress-issue {
width: 100% !important;
}
.school-progress-issue input[type="text"] {
max-width: 100% !important;
flex: 1 1 100% !important;
}
.subscription-box-block {
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 8px !important;
}
.subscription-box-block .subscription-form { margin: 0 !important; }
.profile-module-section,
.profile-module-section .trending-card,
.profile-module-section .card-body,
.profile-module-section .card-section {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box !important;
}
.card-section-action {
display: flex !important;
justify-content: center !important;
width: 100% !important;
}
.card-section-action .keep-reading-link {
text-align: center !important;
}
.card-field.image-container,.card-field.video-container,.card-field.audio-container { justify-content: center !important; }
.card-field.media-left { justify-content: flex-start !important; }
.card-footer .date-link,
.card-footer .user-link { padding: 0 10px !important; font-size: 12px !important; }
.card-meta-row .opinions-summary,
.card-meta-row .comments-summary-link { padding: 0 10px !important; font-size: 12px !important; }
.parliament-rows-table tbody td[data-label=""] { justify-content: center !important; }
.parliament-rows-table tbody td[data-label=""]::before { content: none !important; }
.parliament-rows-table tbody td .parliament-avatar-cell {
display: flex !important;
align-items: center !important;
gap: 8px !important;
justify-content: flex-end !important;
}
.parliament-rows-table tbody td .parliament-leader-avatar { width: 32px !important; height: 32px !important; }
.parliament-rows-table tbody td .parliament-method-img { max-height: 28px !important; width: auto !important; }
.parliament-rows-table tbody td form { margin: 0 !important; }
.hemi-legend {
flex-direction: column !important;
align-items: flex-start !important;
gap: 6px !important;
justify-content: flex-start !important;
}
.hemi-legend-item {
width: 100% !important;
flex-wrap: nowrap !important;
justify-content: flex-start !important;
}
.hemi-legend-count { margin-left: auto !important; }
.hemi-modes { flex-wrap: wrap !important; }
.room-item .mailing-archive-head{flex-wrap:wrap}
.oasis-nav-side-toggle {
position: absolute !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
pointer-events: none !important;
}
.oasis-nav-side-toggle ~ .header {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 8px 6px !important;
padding: 8px 8px 4px !important;
min-height: 60px !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 900 !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .header + * ,
.oasis-nav-side-toggle ~ .header ~ .update-banner:first-of-type {
margin-top: 0 !important;
}
body:has(.oasis-nav-side-toggle) {
padding-top: 64px !important;
}
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header {
position: absolute !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left,
.oasis-nav-side-toggle ~ .header .top-bar-right,
.oasis-nav-side-toggle ~ .header .top-bar-mid {
display: contents !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left a.logo-icon {
order: 1 !important;
flex: 0 0 auto !important;
margin: 0 !important;
padding: 0 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left a.logo-icon img.logo-icon {
width: 44px !important;
height: 44px !important;
margin: 0 !important;
}
.oasis-nav-side-toggle ~ .header .oasis-mobile-menus {
order: 2 !important;
display: flex !important;
flex: 1 1 auto !important;
flex-wrap: nowrap !important;
justify-content: flex-end !important;
align-items: center !important;
gap: 5px !important;
margin: 0 !important;
padding: 0 !important;
}
.oasis-nav-side-toggle ~ .header .oasis-menu-slot {
display: inline-flex !important;
}
.oasis-nav-side-toggle ~ .header .oasis-menu-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 4px !important;
min-height: 44px !important;
padding: 6px 9px !important;
white-space: nowrap !important;
cursor: pointer !important;
}
.oasis-nav-side-toggle ~ .header .oasis-menu-slot .oasis-menu-close {
display: none !important;
}
#nav-left-toggle:checked ~ .header .oasis-menu-slot-left .oasis-menu-open,
#nav-right-toggle:checked ~ .header .oasis-menu-slot-right .oasis-menu-open {
display: none !important;
}
#nav-left-toggle:checked ~ .header .oasis-menu-slot-left .oasis-menu-close,
#nav-right-toggle:checked ~ .header .oasis-menu-slot-right .oasis-menu-close {
display: inline-flex !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
order: 3 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
order: 4 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav,
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
flex: 1 1 calc(50% - 4px) !important;
width: auto !important;
min-width: 0 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul,
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 4px !important;
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul li,
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul li {
margin: 0 !important;
min-width: 0 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul li a,
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul li a {
position: relative !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
min-height: 50px !important;
padding: 0 !important;
font-size: 0 !important;
text-decoration: none !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .header nav ul li a .nav-text {
display: none !important;
}
.oasis-nav-side-toggle ~ .header nav ul li a .emoji {
font-size: 1.6rem !important;
line-height: 1 !important;
margin: 0 !important;
}
.oasis-nav-side-toggle ~ .header nav ul li a .inbox-badge {
position: absolute !important;
top: 3px !important;
right: 3px !important;
margin: 0 !important;
font-size: 0.66rem !important;
}
.oasis-nav-side-toggle ~ .main-content {
display: flex !important;
flex-direction: column !important;
}
.oasis-nav-side-toggle ~ .main-content .main-column {
order: 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .sidebar-left,
.oasis-nav-side-toggle ~ .main-content .sidebar-right {
display: none !important;
}
#nav-left-toggle:checked ~ .main-content .sidebar-left,
#nav-right-toggle:checked ~ .main-content .sidebar-right {
display: block !important;
order: -1 !important;
width: 100% !important;
max-width: 100% !important;
padding: 0 4px !important;
margin: 0 0 8px 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-header {
margin: 0 0 6px 0 !important;
padding: 12px !important;
}
.oasis-nav-side-toggle ~ .main-content .sidebar-left .oasis-nav-list,
.oasis-nav-side-toggle ~ .main-content .sidebar-right .oasis-nav-list {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 8px !important;
margin: 0 !important;
padding: 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li {
margin: 0 !important;
padding: 0 !important;
min-width: 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 74px !important;
height: 100% !important;
padding: 8px 4px !important;
margin: 0 !important;
text-align: center !important;
font-size: 0.72rem !important;
opacity: 1 !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a .emoji {
width: auto !important;
font-size: 1.5rem !important;
line-height: 1 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a .nav-text {
line-height: 1.15 !important;
overflow-wrap: anywhere !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav,
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
position: fixed !important;
bottom: 0 !important;
height: 66px !important;
width: 50% !important;
flex: none !important;
z-index: 900 !important;
margin: 0 !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
left: 0 !important;
right: auto !important;
padding: 6px 2px 6px 6px !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
right: 0 !important;
left: auto !important;
padding: 6px 6px 6px 2px !important;
}
.oasis-nav-side-toggle ~ .oasis-footer {
padding-bottom: 84px !important;
}
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header .top-bar-left nav,
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header .top-bar-right nav {
display: none !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
width: calc(300% / 7) !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
width: calc(400% / 7) !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul {
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
}
.oasis-nav-side-toggle ~ .main-content .sidebar-left nav > ul,
.oasis-nav-side-toggle ~ .main-content .sidebar-right nav > ul {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
grid-auto-flow: row dense !important;
gap: 8px !important;
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group {
display: contents !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 74px !important;
margin: 0 !important;
padding: 8px 4px !important;
text-align: center !important;
font-size: 0.68rem !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .emoji {
width: auto !important;
font-size: 1.5rem !important;
line-height: 1 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .nav-text {
flex: 0 0 auto !important;
line-height: 1.15 !important;
overflow-wrap: anywhere !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .oasis-nav-arrow {
display: none !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-list {
grid-column: 1 / -1 !important;
max-height: none !important;
overflow: visible !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-toggle:not(:checked) + .oasis-nav-header + .oasis-nav-list {
display: none !important;
}
.oasis-nav-side-toggle ~ .header .oasis-phone-state {
order: 1 !important;
flex: 1 1 0 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
align-self: center !important;
margin: 0 !important;
padding: 0 !important;
min-width: 0 !important;
}
.oasis-nav-side-toggle ~ .header:has(.oasis-phone-state) .oasis-mobile-menus {
flex: 0 1 auto !important;
}
.oasis-nav-side-toggle ~ .header .oasis-phone-state-btn {
appearance: none !important;
-webkit-appearance: none !important;
font: inherit !important;
display: inline-flex !important;
align-items: center !important;
gap: 3px !important;
min-height: 0 !important;
justify-content: center !important;
width: 38px !important;
height: 38px !important;
padding: 0 !important;
font-size: 8.5px !important;
line-height: 1.2 !important;
white-space: nowrap !important;
border-radius: 999px !important;
cursor: pointer !important;
}
.oasis-nav-side-toggle ~ .header .oasis-phone-state-btn .pm-exposition-icon {
font-size: 22px !important;
line-height: 1 !important;
margin: 0 !important;
}

File diff suppressed because it is too large Load diff

View file

@ -224,32 +224,23 @@ footer a:hover {
background: none;
}
.card-tags a.tag-link {
color: #181818;
background: #D94F4F;
}
.card-tags a.tag-link:hover {
background: #D94F4F;
color: #111;
cursor: pointer;
}
a.user-link {
a.user-link, button.user-link {
background-color: #FFD600;
color: #FFFFFF;
border-color: #FFD600;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
a.user-link:hover, button.user-link:hover {
background-color: #FFD600;
border-color: #FFD600;
color: #FFFFFF;
cursor: pointer;
}
a.user-link:focus {
a.user-link:focus, button.user-link:focus {
background-color: #9A2F2F;
border-color: #9A2F2F;
color: #FFFFFF;
@ -302,7 +293,7 @@ a.user-link:focus {
.stats-kpi-value { color: #007BFF !important; }
.carbon-bar-note, .carbon-bar-formula { color: #007BFF !important; }
.graphos-node-label { fill: #2C2C2C !important; }
.graphos-node-label-me { fill: #cc7700 !important; }
.graphos-node-label-me { fill: #1f6391 !important; }
.data-best-label { color: #cc7700 !important; }
.data-best-card { background: #FFF3E0 !important; border: 1px solid rgba(204, 119, 0, .4) !important; }
.graphos-legend { color: #2C2C2C !important; }
@ -327,6 +318,8 @@ a.user-link:focus {
.btn-singleview:hover { background: #E0E0E0 !important; color: #FF8F00 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #FF6F00 !important; color: #FFFFFF !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.content-actions .btn-singleview.btn-content { border: 1px solid #FF6F00 !important; }
.content-actions .btn-singleview.btn-content:hover { background: rgba(255, 111, 0, .18) !important; border-color: #FF6F00 !important; }
.btn-back { background: #FF6F00 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; }
@ -383,3 +376,48 @@ a.user-link:focus {
.karvan-msg-self { background-color: #FFEFC2 !important; border-color: #D9A400 !important; }
.karvan-msg .km-from { color: #8A6D00 !important; }
.karvan-msg .km-text { color: #2C2C2C !important; }
.map-viewer, .map-stage div, .map-hero, .map-toolbar, .map-toolbar div, .map-place-results, .map-markers-list, .map-markers-list div, .map-detail-grid, .map-card-thumb .map-viewer { background: transparent !important; border: 0 !important; }
.map-pick-cell, .map-pick-cell:hover { border: 0 !important; padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
.map-pick-cell { background: transparent !important; }
.map-pick-cell:hover { background: rgba(255, 111, 0, 0.18) !important; }
.map-ctrl, .map-place-result { padding: 0 !important; border-radius: 6px !important; box-shadow: none !important; }
.map-place-result { padding: 4px 10px !important; border-radius: 14px !important; }
.map-stage { background: #cfe0ec !important; border: 1px solid #E0E0E0 !important; }
.map-marker-row { border-bottom: 1px solid #E0E0E0 !important; }
.map-label-country { fill: #4a4a4a; stroke: #ffffff; }
.map-place-dot { fill: #333333; stroke: #ffffff; }
.map-place-capital { fill: #E65100; }
.map-place-label { fill: #222222; stroke: #ffffff; }
.map-pin-label-bg { fill: rgba(255, 255, 255, 0.92); stroke: rgba(0, 0, 0, 0.3); }
.map-pin-label { fill: #222222; }
.map-pin-focus .map-pin-body, .map-cluster-dot { fill: #FF6F00; }
.map-pin-focus .map-pin-label-bg { stroke: #FF6F00; }
.map-cluster-ring { fill: rgba(255, 111, 0, 0.25); }
.map-pick-ring, .map-pick-cross { stroke: #FF6F00; }
.map-pins-layer a:hover .map-pin-body, .map-pins a:hover .map-pin-body { fill: #FF6F00; }
.map-scale-line { stroke: #333333; }
.map-scale-text { fill: #333333; stroke: #ffffff; }
.map-attrib { fill: #777777; }
.map-ctrl { background: rgba(255, 255, 255, 0.92) !important; color: #FF6F00 !important; border-color: #cccccc !important; }
.map-ctrl:hover { background: #FF6F00 !important; color: #ffffff !important; border-color: #FF6F00 !important; }
.map-ctrl-off:hover { background: rgba(255, 255, 255, 0.92) !important; color: #FF6F00 !important; border-color: #cccccc !important; }
.map-pick-cell:hover { background: rgba(255, 111, 0, 0.18); outline-color: rgba(255, 111, 0, 0.7); }
.map-place-result, .map-center-link { color: #FF6F00 !important; border-color: #cccccc !important; }
.map-place-result:hover, .map-center-link:hover { background: #FF6F00 !important; color: #ffffff !important; border-color: #FF6F00 !important; }
.map-picked { background: #F0F0F0 !important; color: #FF6F00 !important; }
.map-picked-near { color: #555555 !important; }
.map-markers-list .map-marker-row--focus { background: rgba(255, 111, 0, 0.08) !important; box-shadow: inset 3px 0 0 #FF6F00 !important; }
.map-marker-row--focus .map-marker-idx { background: #FF6F00 !important; color: #ffffff !important; }
.map-marker-label { color: #E65100 !important; }
.map-marker-date { color: #777777 !important; }
.map-marker-coords { color: #222222 !important; }
.map-popup-pointer { fill: #ffffff; stroke: #cccccc; }
.map-stage .map-popup-card { background: #ffffff !important; border: 1px solid #cccccc !important; color: #222222; }
.map-stage .map-popup-text { color: #333333; }
.map-popup-coords { color: #222222; }
.map-stage .map-popup-meta { color: #777777; }
.map-popup-close { color: #777777; }
.map-popup-close:hover { color: #FF6F00; }
.oasis-pager-info { color: #888888; }
.oasis-pager-btn { background: #FFFFFF; color: #555555 !important; border: 1px solid #E0E0E0; text-decoration: none !important; }
.oasis-pager-btn:hover, .oasis-pager-btn.active { border-color: #2C2C2C; color: #2C2C2C !important; text-decoration: none !important; }

View file

@ -210,24 +210,9 @@ footer a:hover {
gap: 9px;
}
.card-tags a.tag-link {
text-decoration: none;
color: #181818;
background: #FFD600;
padding: 5px 13px 4px 13px;
border-radius: 7px;
font-size: .98em;
border: none;
font-weight: bold;
}
.card-tags a.tag-link:hover {
background: #ffe86a;
color: #111;
cursor: pointer;
}
a.user-link {
a.user-link, button.user-link {
background-color: #FFA500;
color: #000;
padding: 8px 16px;
@ -241,14 +226,14 @@ a.user-link {
font-size: 0.8em;
}
a.user-link:hover {
a.user-link:hover, button.user-link:hover {
background-color: #FFD700;
border-color: #FFD700;
color: #000;
cursor: pointer;
}
a.user-link:focus {
a.user-link:focus, button.user-link:focus {
background-color: #007B9F;
border-color: #007B9F;
color: #fff;
@ -316,7 +301,7 @@ a.user-link:focus {
.stats-kpi-value { color: #FFD700 !important; }
.carbon-bar-note, .carbon-bar-formula { color: #FFD700 !important; }
.graphos-node-label { fill: #ddd !important; }
.graphos-node-label-me { fill: #ffa500 !important; }
.graphos-node-label-me { fill: #5dade2 !important; }
.graphos-legend { color: #ddd !important; }
.blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
.block { background: #23242a !important; }
@ -333,6 +318,8 @@ a.user-link:focus {
.btn-singleview:hover { background: #2d2e34 !important; color: #FFD700 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #ffa300 !important; color: #000000 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !important; }
.content-actions .btn-singleview.btn-content { border: 1px solid #ffa300 !important; }
.content-actions .btn-singleview.btn-content:hover { background: rgba(255, 163, 0, .18) !important; border-color: #ffa300 !important; }
.btn-back { background: #21232b !important; color: #ffa300 !important; }
.btn-back:hover { background: #292b36 !important; color: #FFD700 !important; }
.block-info-table td, .pm-info-table td { border-color: #444 !important; }

View file

@ -241,32 +241,23 @@ footer a {
color: #00FF00;
}
.card-tags a.tag-link {
color: #00FF00;
background: #000000;
}
.card-tags a.tag-link:hover {
background: #00FF00;
color: #000000;
cursor: pointer;
}
a.user-link {
a.user-link, button.user-link {
background-color: #00FF00;
color: #000000;
border-color: #00FF00;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
a.user-link:hover, button.user-link:hover {
background-color: #000000;
border-color: #00FF00;
color: #00FF00;
cursor: pointer;
}
a.user-link:focus {
a.user-link:focus, button.user-link:focus {
background-color: #00FF00;
border-color: #00FF00;
color: #000000;
@ -310,7 +301,7 @@ a.user-link:focus {
.stats-kpi-value { color: #00FF00 !important; }
.carbon-bar-note, .carbon-bar-formula { color: #00FF00 !important; }
.graphos-node-label { fill: #00FF00 !important; }
.graphos-node-label-me { fill: #ffa500 !important; font-weight: bold !important; }
.graphos-node-label-me { fill: #5dade2 !important; font-weight: bold !important; }
.graphos-legend { color: #00FF00 !important; }
.graphos-edge { stroke: #003300 !important; }
/* Blockexplorer */
@ -329,6 +320,8 @@ a.user-link:focus {
.btn-singleview:hover { background: #00FF00 !important; color: #000000 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #00FF00 !important; color: #000000 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #66FF66 !important; color: #000000 !important; }
.content-actions .btn-singleview.btn-content { border: 1px solid #00FF00 !important; }
.content-actions .btn-singleview.btn-content:hover { background: rgba(0, 255, 0, .18) !important; border-color: #00FF00 !important; }
.btn-back { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
.btn-back:hover { background: #00FF00 !important; color: #000000 !important; }
.block-info-table td, .pm-info-table td { border-color: #00FF00 !important; }
@ -379,4 +372,47 @@ nav, .sidebar-left nav, .sidebar-right nav, .sidebar-left ul, .sidebar-right ul
.ai-nav-result-card a { color: #00FF00 !important; }
.ai-nav-result-card a:hover { background: #00FF00 !important; color: #000000 !important; }
.ai-nav-query { color: #00FF00 !important; }
.trending-card.own-content { box-shadow: inset 3px 0 0 #00FF00; }
.trending-card.own-content, .addr-card.ubi-pub-card.own-content { box-shadow: inset 3px 0 0 #00FF00; }
.map-stage { background: #000000 !important; border-color: #00FF00 !important; }
.map-label-country { fill: #00FF00; stroke: #000000; }
.map-place-dot { fill: #00FF00; stroke: #000000; }
.map-place-capital { fill: #66FF66; }
.map-place-label { fill: #00FF00; stroke: #000000; }
.map-pin-body { fill: #00aa00; stroke: #00FF00; }
.map-pin-main .map-pin-body { fill: #00FF00; stroke: #000000; }
.map-pin-focus .map-pin-body { fill: #ffffff; stroke: #00FF00; }
.map-pin-eye { fill: #000000; }
.map-pin-label-bg { fill: rgba(0, 0, 0, 0.9); stroke: #00FF00; }
.map-pin-focus .map-pin-label-bg { stroke: #ffffff; }
.map-pin-label { fill: #00FF00; }
.map-pins-layer a:hover .map-pin-body, .map-pins a:hover .map-pin-body { fill: #ffffff; }
.map-cluster-ring { fill: rgba(0, 255, 0, 0.25); }
.map-cluster-dot { fill: #00FF00; stroke: #000000; }
.map-pick-ring, .map-pick-cross { stroke: #00FF00; }
.map-scale-line { stroke: #00FF00; }
.map-scale-text { fill: #00FF00; stroke: #000000; }
.map-attrib { fill: #008800; }
.map-ctrl { background: rgba(0, 0, 0, 0.9) !important; color: #00FF00 !important; border-color: #00FF00 !important; }
.map-ctrl:hover { background: #00FF00 !important; color: #000000 !important; }
.map-ctrl-off:hover { background: rgba(0, 0, 0, 0.9) !important; color: #00FF00 !important; }
.map-pick-cell:hover { background: rgba(0, 255, 0, 0.18); outline-color: rgba(0, 255, 0, 0.7); }
.map-place-result, .map-center-link { color: #00FF00 !important; border-color: #00FF00 !important; }
.map-place-result:hover, .map-center-link:hover { background: #00FF00 !important; color: #000000 !important; }
.map-picked { background: #050505 !important; color: #00FF00 !important; }
.map-picked-near { color: #00CC00 !important; }
.map-markers-list .map-marker-row { border-color: #003300 !important; }
.map-markers-list .map-marker-row--focus { background: rgba(0, 255, 0, 0.08) !important; box-shadow: inset 3px 0 0 #00FF00 !important; }
.map-marker-idx { background: #004400 !important; color: #00FF00 !important; }
.map-marker-idx--main, .map-marker-row--focus .map-marker-idx { background: #00FF00 !important; color: #000000 !important; }
.map-marker-label { color: #00FF00 !important; }
.map-marker-date { color: #00AA00 !important; }
.map-popup-pointer { fill: #000000; stroke: #00FF00; }
.map-stage .map-popup-card { background: #000000; border-color: #00FF00; color: #00FF00; }
.map-stage .map-popup-text { color: #00FF00; }
.map-popup-coords { color: #66FF66; }
.map-stage .map-popup-meta { color: #00AA00; }
.map-popup-close { color: #00AA00; }
.map-popup-close:hover { color: #ffffff; }
.oasis-pager-info { color: #008800; }
.oasis-pager-btn { background: #0a0a0a; color: #00CC00; border: 1px solid #00FF00; }
.oasis-pager-btn:hover, .oasis-pager-btn.active { border-color: #00FF00; color: #00FF00; }

View file

@ -277,32 +277,23 @@ header {
color: #B86ADE;
}
.card-tags a.tag-link {
color: #FFFFFF;
background: #D94F4F;
}
.card-tags a.tag-link:hover {
background: #B86ADE;
color: #E5E5E5;
cursor: pointer;
}
a.user-link {
a.user-link, button.user-link {
background-color: #FFD600;
color: #3C1360;
border-color: #FFD600;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
a.user-link:hover, button.user-link:hover {
background-color: #FFB600;
border-color: #FFB600;
color: #3C1360;
cursor: pointer;
}
a.user-link:focus {
a.user-link:focus, button.user-link:focus {
background-color: #9A2F2F;
border-color: #9A2F2F;
color: #E5E5E5;
@ -346,7 +337,7 @@ a.user-link:focus {
.stats-kpi-value { color: #FFEEDB !important; }
.carbon-bar-note, .carbon-bar-formula { color: #FFEEDB !important; }
.graphos-node-label { fill: #FFEEDB !important; }
.graphos-node-label-me { fill: #ffa500 !important; }
.graphos-node-label-me { fill: #5dade2 !important; }
.graphos-legend { color: #FFEEDB !important; }
/* Blockexplorer */
.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
@ -364,6 +355,8 @@ a.user-link:focus {
.btn-singleview:hover { background: #5A1A85 !important; color: #FFD600 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #B86ADE !important; color: #2A0E42 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #CE8CE9 !important; color: #2A0E42 !important; }
.content-actions .btn-singleview.btn-content { border: 1px solid #B86ADE !important; }
.content-actions .btn-singleview.btn-content:hover { background: rgba(184, 106, 222, .18) !important; border-color: #B86ADE !important; }
.btn-back { background: #A34AD8 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #751E9F !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #B86ADE !important; }
@ -395,4 +388,41 @@ a.user-link:focus {
.tribe-subtribe-link:hover { background: #5A1A85 !important; }
.tribe-parent-image { border-color: #B86ADE !important; }
.tribe-parent-box { background: #3C1360 !important; }
.trending-card.own-content { box-shadow: inset 3px 0 0 #B86ADE; }
.trending-card.own-content, .addr-card.ubi-pub-card.own-content { box-shadow: inset 3px 0 0 #B86ADE; }
.map-stage { background: #2a0640 !important; border-color: #9B1C96 !important; }
.map-label-country { fill: #E5E5E5; stroke: #2a0640; }
.map-place-dot { fill: #ffffff; stroke: #2a0640; }
.map-place-capital { fill: #B86ADE; }
.map-place-label { fill: #E5E5E5; stroke: #2a0640; }
.map-pin-focus .map-pin-body, .map-cluster-dot { fill: #B86ADE; }
.map-pin-focus .map-pin-label-bg { stroke: #B86ADE; }
.map-pins-layer a:hover .map-pin-body, .map-pins a:hover .map-pin-body { fill: #B86ADE; }
.map-cluster-ring { fill: rgba(184, 106, 222, 0.28); }
.map-pick-ring, .map-pick-cross { stroke: #B86ADE; }
.map-scale-text { stroke: #2a0640; }
.map-attrib { fill: #9B7CAA; }
.map-ctrl { background: rgba(43, 10, 70, 0.9) !important; color: #E5E5E5 !important; border-color: #9B1C96 !important; }
.map-ctrl:hover { background: #B86ADE !important; color: #000000 !important; border-color: #B86ADE !important; }
.map-ctrl-off:hover { background: rgba(43, 10, 70, 0.9) !important; color: #E5E5E5 !important; border-color: #9B1C96 !important; }
.map-pick-cell, .map-pick-cell:hover { border: 0 !important; padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
.map-pick-cell { background: transparent !important; }
.map-pick-cell:hover { background: rgba(184, 106, 222, 0.22) !important; outline-color: rgba(184, 106, 222, 0.7); }
.map-place-result, .map-center-link { color: #E5E5E5 !important; border-color: #9B1C96 !important; }
.map-place-result:hover, .map-center-link:hover { background: #B86ADE !important; color: #000000 !important; border-color: #B86ADE !important; }
.map-picked { background: #3C1360 !important; color: #E5E5E5 !important; }
.map-picked-near { color: #C8C8C8 !important; }
.map-markers-list .map-marker-row { border-color: #5A1A85 !important; }
.map-markers-list .map-marker-row--focus { background: rgba(184, 106, 222, 0.1) !important; box-shadow: inset 3px 0 0 #B86ADE !important; }
.map-marker-row--focus .map-marker-idx { background: #B86ADE !important; color: #000000 !important; }
.map-marker-label { color: #B86ADE !important; }
.map-marker-date { color: #C8C8C8 !important; }
.map-popup-pointer { fill: #3C1360; stroke: #9B1C96; }
.map-stage .map-popup-card { background: #3C1360; border-color: #9B1C96; color: #E5E5E5; }
.map-stage .map-popup-text { color: #E5E5E5; }
.map-popup-coords { color: #ffffff; }
.map-stage .map-popup-meta { color: #C8C8C8; }
.map-popup-close { color: #C8C8C8; }
.map-popup-close:hover { color: #B86ADE; }
.oasis-pager-info { color: #9B7CAA; }
.oasis-pager-btn { background: #39006D; color: #C8C8C8; border: 1px solid #9B1C96; }
.oasis-pager-btn:hover, .oasis-pager-btn.active { border-color: #E5E5E5; color: #E5E5E5; }

View file

@ -1,15 +1,22 @@
const path = require('path');
let i18n = {};
const languages = ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt', 'zh', 'ar', 'hi', 'ru'];
const i18n = {};
const languages = ['en', 'es', 'ca', 'gl', 'fr', 'eu', 'de', 'it', 'pt', 'zh', 'ar', 'hi', 'ru'];
languages.forEach(language => {
try {
const languagePath = path.join(__dirname, `oasis_${language}.js`);
const languageData = require(languagePath);
i18n[language] = languageData[language];
} catch (error) {
console.error(`Failed to load language file for ${language}:`, error);
}
Object.defineProperty(i18n, language, {
enumerable: true,
configurable: true,
get() {
let languageData;
try {
languageData = require(path.join(__dirname, `oasis_${language}.js`))[language];
} catch (error) {
console.error(`Failed to load language file for ${language}:`, error);
}
Object.defineProperty(i18n, language, { value: languageData, enumerable: true, configurable: true, writable: true });
return languageData;
}
});
});
try {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -10,10 +10,13 @@ const { printMetadata } = require('../server/ssb_metadata');
const updateFlagPath = path.join(__dirname, "../server/.update_required");
let internalSSB = null;
try {
const { server } = require('../server/SSB_server');
internalSSB = server;
} catch {}
let internalOpen = null;
if (!process.env.OASIS_TEST) {
try {
const SSB_server = require('../server/SSB_server');
internalOpen = () => SSB_server.open().then((srv) => { internalSSB = srv; return srv; });
} catch {}
}
if (process.env.OASIS_TEST) {
ssbConfig.path = fs.mkdtempSync(path.join(os.tmpdir(), "oasis-"));
@ -24,10 +27,33 @@ const socketPath = path.join(ssbConfig.path, "socket");
const publicInteger = ssbConfig.keys.public.replace(".ed25519", "");
const remote = `unix:${socketPath}~noauth:${publicInteger}`;
const windowLogStream = (ssb) => {
if (!ssb || ssb._logStreamWindowed || typeof ssb.createLogStream !== "function") return ssb;
const pull = require("../server/node_modules/pull-stream");
const defer = require("../server/node_modules/pull-defer");
const orig = ssb.createLogStream;
ssb.createLogStream = function (opts) {
const o = opts || {};
const plain = o.limit && !o.reverse && !o.live && !o.raw && o.old === undefined && !o.gt && !o.lt;
if (!plain) return orig.call(ssb, opts);
const src = defer.source();
pull(
orig.call(ssb, { ...o, reverse: true }),
pull.collect((err, msgs) => {
if (err) return src.abort(err);
src.resolve(pull.values(msgs.reverse()));
})
);
return src;
};
ssb._logStreamWindowed = true;
return ssb;
};
const connect = (options) =>
new Promise((resolve, reject) => {
ssbClient(process.env.OASIS_TEST ? ssbConfig.keys : null, options)
.then(resolve)
.then((ssb) => resolve(windowLogStream(ssb)))
.catch(reject);
});
@ -89,8 +115,15 @@ module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }
return new Promise((resolve, reject) => {
if (internalSSB) {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
return resolve(internalSSB);
printMetadata('OASIS UX', colors.yellow, port, host, offline, isPublic);
return resolve(windowLogStream(internalSSB));
}
if (internalOpen) {
return internalOpen().then((srv) => {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS UX', colors.yellow, port, host, offline, isPublic);
resolve(windowLogStream(srv));
}).catch(reject);
}
if (clientHandle && clientHandle.closed === false) {
@ -105,7 +138,7 @@ module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }
reject(new Error("Closing Oasis"));
} else {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
printMetadata('OASIS UX', colors.yellow, port, host, offline, isPublic);
resolve(ssb);
}
}).catch(reject);

View file

@ -8,20 +8,21 @@ const mount = require(path.join(__dirname, "../server/node_modules/koa-mount"));
function obfuscateClearnetHtml(html) {
if (typeof html !== 'string' || html.length === 0) return html;
const preserve = [];
const tag = require('crypto').randomBytes(12).toString('hex');
const stash = (re) => {
html = html.replace(re, (m) => {
preserve.push(m);
return `${preserve.length - 1}`;
return `\u0001${tag}:${preserve.length - 1}\u0001`;
});
};
stash(/<pre[\s\S]*?<\/pre>/gi);
stash(/<textarea[\s\S]*?<\/textarea>/gi);
stash(/<style[\s\S]*?<\/style>/gi);
html = html.replace(/<!--[\s\S]*?-->/g, '');
html = html.replace(/>[\s\n\r\t]+</g, '><');
html = html.replace(/>[ \t]*[\r\n][\s]*</g, '><');
html = html.replace(/[ \t]{2,}/g, ' ');
html = html.replace(/[\r\n]+/g, '');
html = html.replace(/(\d+)/g, (_, i) => preserve[Number(i)] || '');
html = html.replace(new RegExp(`\\u0001${tag}:(\\d+)\\u0001`, 'g'), (_, i) => preserve[Number(i)] || '');
return html;
}
@ -42,42 +43,17 @@ const collectLocalIPs = () => {
module.exports = ({ host, port, middleware, allowHost }) => {
const assets = new Koa()
assets.use(koaStatic(join(__dirname, "..", "client", "assets"), { maxage: 60 * 60 * 1000 }));
assets.use(async (ctx, next) => {
ctx.set("Cache-Control", ctx.query && ctx.query.v ? "public, max-age=31536000, immutable" : "public, max-age=3600");
await next();
});
assets.use(koaStatic(join(__dirname, "..", "client", "assets")));
const app = new Koa();
const validHosts = [];
const isClearnetPath = (request) => {
const url = String(request.url || '');
return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?');
};
const isValidRequest = (request) => {
if (isClearnetPath(request)) return request.method === 'GET';
if (validHosts.includes(request.hostname) !== true) {
return false;
}
if (request.method !== "GET") {
if (request.header.referer == null) {
return false;
}
try {
const refererUrl = new URL(request.header.referer);
if (validHosts.includes(refererUrl.hostname) !== true) {
return false;
}
if (refererUrl.pathname.startsWith("/blob/")) {
return false;
}
} catch (e) {
return false;
}
}
return true;
};
const { isClearnetPath, isTrustedRequest, buildCsp } = require(path.join(__dirname, "..", "backend", "request_guards"));
const isValidRequest = (request) => isTrustedRequest(request, validHosts);
const httpDebug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
@ -98,17 +74,21 @@ module.exports = ({ host, port, middleware, allowHost }) => {
});
app.use(mount("/assets", assets));
app.use(mount("/c/assets", assets));
const maptiles = new Koa();
maptiles.use(koaStatic(join(__dirname, "..", "maps", "tiles")));
app.use(mount("/maptiles", maptiles));
const mapcache = new Koa();
mapcache.use(koaStatic(join(__dirname, "..", "maps", "cache")));
app.use(mount("/mapcache", mapcache));
const mapTiles = new Koa();
mapTiles.use(koaStatic(join(__dirname, "..", "maps", "tiles"), { maxage: 30 * 24 * 60 * 60 * 1000, immutable: true }));
app.use(mount("/maptiles", mapTiles));
app.use(mount("/c/maptiles", mapTiles));
const gamesStatic = new Koa();
gamesStatic.use(async (ctx, next) => {
ctx.set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob: mediastream:; connect-src 'none'; form-action 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'self'");
ctx.set("X-Content-Type-Options", "nosniff");
ctx.set("X-Frame-Options", "SAMEORIGIN");
ctx.set("Referrer-Policy", "same-origin");
await next();
});
gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
app.use(mount("/game-assets", gamesStatic));
@ -120,34 +100,7 @@ module.exports = ({ host, port, middleware, allowHost }) => {
if (httpDebug) console.log(`[http] ${ctx.method} ${ctx.path}`);
const isClearnet = isClearnetPath(ctx.request);
const csp = isClearnet
? [
"default-src 'self'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"media-src 'self' blob:",
"connect-src 'self'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join("; ")
: [
"default-src 'self'",
"script-src 'self' http://localhost:3000/js",
"style-src 'self'",
"img-src 'self'",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"frame-src 'self'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join("; ");
ctx.set("Content-Security-Policy", csp);
ctx.set("Content-Security-Policy", buildCsp(isClearnet, { frames: ctx.method === "GET" && /^\/games\/[a-z0-9]+\/?$/.test(ctx.path) }));
ctx.set("X-Frame-Options", "SAMEORIGIN");
ctx.set("X-Content-Type-Options", "nosniff");
@ -155,12 +108,10 @@ module.exports = ({ host, port, middleware, allowHost }) => {
ctx.set("Referrer-Policy", "same-origin");
ctx.set("Permissions-Policy", "speaker=(self)");
const validHostsString = validHosts.join(" or ");
ctx.assert(
isValidRequest(ctx.request),
400,
`Request must be addressed to ${validHostsString} and non-GET requests must contain non-blob referer.`
"Request must be addressed to this node and non-GET requests must come from its own pages."
);
await next();
@ -173,13 +124,10 @@ module.exports = ({ host, port, middleware, allowHost }) => {
}
});
// pdf viewer
const pdfjsPath = path.join(__dirname, '../server/node_modules/pdfjs-dist/build/pdf.min.js');
app.use(koaStatic(pdfjsPath));
middleware.forEach((m) => app.use(m));
const server = app.listen({ host, port });
const server = require("http").createServer({ maxHeaderSize: 256 * 1024 }, app.callback()).listen({ host, port });
server.on("listening", () => {
const address = server.address();

View file

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

View file

@ -1,32 +1,68 @@
document.addEventListener('DOMContentLoaded', () => {
if (typeof pdfjsLib === 'undefined') return;
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.mjs';
const showPdfFallback = () => {
document.querySelectorAll('.pdf-viewer-container').forEach(container => {
if (container.dataset.pdfReady) return;
container.dataset.pdfReady = '1';
container.textContent = '';
const url = container.getAttribute('data-pdf-url');
if (url) {
const link = document.createElement('a');
link.href = url;
link.textContent = '📄 PDF';
container.appendChild(link);
}
});
};
const initPdfViewers = async () => {
let pdfjsLib;
try {
pdfjsLib = await import('/js/pdf.min.mjs?v=102');
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.mjs?v=102';
} catch (err) {
showPdfFallback();
return;
}
document.querySelectorAll('.pdf-viewer-container').forEach(async container => {
const pdfUrl = container.getAttribute('data-pdf-url');
if (!pdfUrl) return;
if (!pdfUrl || container.dataset.pdfReady) return;
container.dataset.pdfReady = '1';
let pdf;
try {
pdf = await pdfjsLib.getDocument(pdfUrl).promise;
} catch (err) {
container.textContent = '';
const fallback = document.createElement('a');
fallback.href = pdfUrl;
fallback.textContent = '📄 PDF';
container.appendChild(fallback);
return;
}
const pdf = await pdfjsLib.getDocument(pdfUrl).promise;
let currentPage = 1;
let scale = 1.5;
let rotation = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const canvasWrap = document.createElement('div');
canvasWrap.className = 'pdf-canvas-wrap';
canvasWrap.appendChild(canvas);
container.innerHTML = '';
container.appendChild(canvas);
container.appendChild(canvasWrap);
const controls = document.createElement('div');
controls.className = 'pdf-controls';
controls.innerHTML = `
<button id="prev">⬅️</button>
<button id="next">➡️</button>
<button id="zoomIn">🔍+</button>
<button id="zoomOut">🔍−</button>
<button id="rotate">↻</button>
<button id="download">⬇️</button>
<button id="fullscreen">🔲</button>
<button id="metadata">ℹ️</button>
<button class="pdf-prev">⬅️</button>
<button class="pdf-next">➡️</button>
<button class="pdf-zoom-in">🔍+</button>
<button class="pdf-zoom-out">🔍−</button>
<button class="pdf-rotate">↻</button>
<button class="pdf-download">⬇️</button>
<button class="pdf-fullscreen">🔲</button>
<button class="pdf-metadata">ℹ️</button>
`;
container.appendChild(controls);
@ -48,29 +84,48 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
try {
const firstPage = await pdf.getPage(1);
const baseViewport = firstPage.getViewport({ scale: 1, rotation: 0 });
const avail = canvasWrap.clientWidth || container.clientWidth;
if (avail > 0 && baseViewport.width > 0) {
scale = Math.min(1.5, Math.max(0.5, (avail - 40) / baseViewport.width));
}
} catch (e) {}
renderPage(currentPage);
controls.querySelector('#prev').onclick = () => goToPage(-1);
controls.querySelector('#next').onclick = () => goToPage(1);
controls.querySelector('#zoomIn').onclick = () => { scale += 0.2; renderPage(currentPage); };
controls.querySelector('#zoomOut').onclick = () => { scale = Math.max(0.5, scale - 0.2); renderPage(currentPage); };
controls.querySelector('#rotate').onclick = () => { rotation = (rotation + 90) % 360; renderPage(currentPage); };
controls.querySelector('#download').onclick = () => {
controls.querySelector('.pdf-prev').onclick = () => goToPage(-1);
controls.querySelector('.pdf-next').onclick = () => goToPage(1);
controls.querySelector('.pdf-zoom-in').onclick = () => { scale += 0.2; renderPage(currentPage); };
controls.querySelector('.pdf-zoom-out').onclick = () => { scale = Math.max(0.5, scale - 0.2); renderPage(currentPage); };
controls.querySelector('.pdf-rotate').onclick = () => { rotation = (rotation + 90) % 360; renderPage(currentPage); };
controls.querySelector('.pdf-download').onclick = () => {
const a = document.createElement('a');
a.href = pdfUrl;
a.download = 'document.pdf';
a.click();
};
controls.querySelector('#fullscreen').onclick = () => {
if (canvas.requestFullscreen) canvas.requestFullscreen();
else if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen();
else if (canvas.mozRequestFullScreen) canvas.mozRequestFullScreen();
else if (canvas.msRequestFullscreen) canvas.msRequestFullscreen();
controls.querySelector('.pdf-fullscreen').onclick = async () => {
const maximized = container.classList.toggle('pdf-maximized');
document.body.classList.toggle('pdf-maximized-body', maximized);
try {
const p1 = await pdf.getPage(currentPage);
const vw1 = p1.getViewport({ scale: 1, rotation });
const avail = Math.max(260, (maximized ? window.innerWidth : (canvasWrap.clientWidth || container.clientWidth || 360)) - 24);
scale = maximized ? avail / vw1.width : Math.min(1.5, avail / vw1.width);
await renderPage(currentPage);
} catch (_) {}
};
controls.querySelector('#metadata').onclick = async () => {
controls.querySelector('.pdf-metadata').onclick = async () => {
const info = await pdf.getMetadata();
alert(`Title: ${info.info.Title || 'N/A'}\nAuthor: ${info.info.Author || 'N/A'}\nPDF Producer: ${info.info.Producer || 'N/A'}`);
};
});
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPdfViewers);
} else {
initPdfViewers();
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
[]

View file

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

View file

@ -1 +0,0 @@
[]

View file

@ -1 +0,0 @@
[]

View file

@ -1,7 +1,58 @@
const fs = require('fs');
const path = require('path');
const configFilePath = path.join(__dirname, 'oasis-config.json');
const os = require('os');
const legacyConfigPath = path.join(__dirname, 'oasis-config.json');
const stateDir = process.env.OASIS_STATE_DIR || process.env.ssb_path || path.join(os.homedir(), '.ssb');
const oasisDir = path.join(stateDir, 'oasis');
const configFilePath = path.join(oasisDir, 'oasis-config.json');
const defaultServerConfigPath = path.join(__dirname, 'server-config.json');
const serverConfigFilePath = path.join(oasisDir, 'oasis-server-config.json');
for (const [from, to] of [[path.join(stateDir, 'oasis-config.json'), configFilePath], [path.join(stateDir, 'oasis-server-config.json'), serverConfigFilePath]]) {
try {
if (!fs.existsSync(to) && fs.existsSync(from)) {
fs.mkdirSync(oasisDir, { recursive: true, mode: 0o700 });
fs.renameSync(from, to);
}
} catch (_) {}
}
const writeStateFile = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, data, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tmp, file);
try { fs.chmodSync(file, 0o600); } catch (_) {}
};
let configCache = { at: 0, cfg: null };
const CONFIG_TTL_MS = 2000;
const writeConfigFile = (data) => { configCache = { at: 0, cfg: null }; return writeStateFile(configFilePath, data); };
const readJsonFile = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; } };
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const mergeDeep = (base, over) => {
if (!isPlainObject(base) || !isPlainObject(over)) return over === undefined ? base : over;
const out = { ...base };
for (const [k, v] of Object.entries(over)) out[k] = isPlainObject(v) && isPlainObject(base[k]) ? mergeDeep(base[k], v) : v;
return out;
};
if (!fs.existsSync(serverConfigFilePath)) {
const shipped = readJsonFile(defaultServerConfigPath);
const hops = shipped && shipped.friends ? shipped.friends.hops : undefined;
try {
if (shipped && shipped.pub === true) writeStateFile(serverConfigFilePath, JSON.stringify(shipped, null, 2));
else if (Number.isFinite(hops) && hops !== 2) writeStateFile(serverConfigFilePath, JSON.stringify({ friends: { hops } }, null, 2));
} catch (_) {}
}
const readServerConfig = () => mergeDeep(readJsonFile(defaultServerConfigPath) || {}, readJsonFile(serverConfigFilePath) || {});
const saveServerConfig = (patch) => writeStateFile(serverConfigFilePath, JSON.stringify(mergeDeep(readJsonFile(serverConfigFilePath) || {}, patch), null, 2));
const inviteCodesPath = path.join(__dirname, 'invite-codes.json');
const readInviteCodes = () => {
const raw = readJsonFile(inviteCodesPath);
return (Array.isArray(raw) ? raw : raw ? [raw] : []).filter((e) => e && typeof e.code === 'string' && e.code.trim());
};
const defaultInvite = () => readInviteCodes()[0] || null;
if (!fs.existsSync(configFilePath) && fs.existsSync(legacyConfigPath)) {
try { writeConfigFile(fs.readFileSync(legacyConfigPath, 'utf8')); } catch (_) {}
}
if (!fs.existsSync(configFilePath)) {
const defaultConfig = {
@ -22,10 +73,10 @@ if (!fs.existsSync(configFilePath)) {
"threadsMod": "on",
"multiverseMod": "on",
"pollsMod": "on",
"fediverseMod": "off",
"fediverseMod": "on",
"invitesMod": "on",
"walletMod": "on",
"legacyMod": "on",
"backupMod": "on",
"devMod": "on",
"cipherMod": "on",
"bookmarksMod": "on",
@ -43,6 +94,14 @@ if (!fs.existsSync(configFilePath)) {
"reportsMod": "on",
"opinionsMod": "on",
"padsMod": "on",
"wikiMod": "on",
"emergenciesMod": "on",
"mailingMod": "on",
"roomsMod": "on",
"phoneMod": "on",
"logisticsMod": "on",
"podcastsMod": "on",
"campaignsMod": "on",
"calendarsMod": "on",
"transfersMod": "on",
"feedMod": "on",
@ -58,6 +117,7 @@ if (!fs.existsSync(configFilePath)) {
"projectsMod": "on",
"industryMod": "on",
"bankingMod": "on",
"schoolMod": "on",
"parliamentMod": "on",
"courtsMod": "on",
"favoritesMod": "on",
@ -65,6 +125,7 @@ if (!fs.existsSync(configFilePath)) {
"mapsMod": "on",
"chatsMod": "on",
"torrentsMod": "on",
"filesMod": "on",
"graphosMod": "on",
"larpMod": "on"
},
@ -74,9 +135,6 @@ if (!fs.existsSync(configFilePath)) {
"pass": "",
"fee": "5"
},
"walletPub": {
"pubId": ""
},
"ai": {
"prompt": "Provide an informative and precise response."
},
@ -87,20 +145,29 @@ if (!fs.existsSync(configFilePath)) {
"language": "en",
"wish": "whole",
"pmVisibility": "whole",
"phone": { "visibility": "mutuals", "dnd": false, "relay": true },
"lanBroadcasting": true
};
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
writeConfigFile(JSON.stringify(defaultConfig, null, 2));
}
const getConfig = () => {
const now = Date.now();
if (!configCache.cfg || now - configCache.at > CONFIG_TTL_MS) configCache = { at: now, cfg: readConfig() };
return structuredClone(configCache.cfg);
};
const readConfig = () => {
const configData = fs.readFileSync(configFilePath);
const cfg = JSON.parse(configData);
if (!['whole', 'mutuals', 'only-lan'].includes(cfg.wish)) cfg.wish = 'whole';
if (!['whole', 'mutuals', 'only-lan', 'local'].includes(cfg.wish)) cfg.wish = 'whole';
if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole';
if (!cfg.phone || typeof cfg.phone !== 'object') cfg.phone = {};
cfg.phone = { visibility: cfg.phone.visibility === 'whole' ? 'whole' : 'mutuals', dnd: cfg.phone.dnd === true, relay: cfg.phone.relay !== false, video: cfg.phone.video !== false, ...(typeof cfg.phone.videoDevice === 'string' && cfg.phone.videoDevice.trim() ? { videoDevice: cfg.phone.videoDevice.trim() } : {}) };
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks';
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats') cfg.ux.current = 'blocks';
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats' && cfg.ux.current !== 'feed' && cfg.ux.current !== 'phone') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'chats' && cfg.modules && cfg.modules.chatsMod !== 'on') cfg.ux.current = 'blocks';
@ -113,14 +180,30 @@ const getConfig = () => {
pins = pins.filter((k, i) => pins.indexOf(k) === i).slice(0, 4);
cfg.bottomBarPins = pins;
delete cfg.bottomBarPin;
if (cfg.ux.current === 'phone' && cfg.modules && cfg.modules.phoneMod === 'off') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'feed' && cfg.modules && cfg.modules.feedMod === 'off') cfg.ux.current = 'blocks';
if (!cfg.blobCache || typeof cfg.blobCache !== 'object' || !Number.isFinite(Number(cfg.blobCache.maxMB))) cfg.blobCache = { maxMB: 0 };
if (!Number.isFinite(Number(cfg.blobCache.pubMaxMB))) cfg.blobCache.pubMaxMB = 0;
if (cfg.modules && typeof cfg.modules === 'object') {
if (cfg.modules.backupMod === undefined) cfg.modules.backupMod = cfg.modules.legacyMod === 'off' ? 'off' : 'on';
for (const mod of ['wikiMod', 'emergenciesMod', 'mailingMod', 'logisticsMod', 'podcastsMod', 'campaignsMod', 'filesMod', 'phoneMod', 'roomsMod']) {
if (cfg.modules[mod] === undefined) cfg.modules[mod] = 'on';
}
}
return cfg;
};
const saveConfig = (newConfig) => {
fs.writeFileSync(configFilePath, JSON.stringify(newConfig, null, 2));
writeConfigFile(JSON.stringify(newConfig, null, 2));
};
module.exports = {
configFilePath,
serverConfigFilePath,
getConfig,
saveConfig,
readServerConfig,
saveServerConfig,
readInviteCodes,
defaultInvite,
};

View file

@ -1,27 +0,0 @@
{
"audios": [],
"blogs": [],
"bookmarks": [],
"calendars": [],
"chats": [],
"documents": [],
"events": [],
"forum": [],
"housing": [],
"images": [],
"jobs": [],
"logs": [],
"maps": [],
"market": [],
"pads": [],
"polls": [],
"projects": [],
"reports": [],
"shopProducts": [],
"shops": [],
"tasks": [],
"torrents": [],
"transfers": [],
"videos": [],
"votes": []
}

View file

@ -1 +0,0 @@
{"pending":[],"accepted":[],"lastAcceptMs":0}

View file

@ -0,0 +1,23 @@
[
{
"name": "SNH \"La Plaza\"",
"description": "A shared place to begin an experimental utopia ...",
"url": "https://solarnethub.com",
"code": "pub.solarnethub.com:8008:@0qSCyK3xyL71X4qKkmf84Cb2riP6OeUqxCvbP2Z6HWs=.ed25519~7mC1c1gBJe8pmnWI9eIUYpFtEPUOiuHAxbzfxP/f0Ak=",
"createdAt": "2026-05-24T00:00:00.000Z"
},
{
"name": "t0k10 h4x0rs",
"description": "n0 l0gin. n0 m41l. n0 cl0ud...",
"url": "https://pub.elhack.net",
"code": "pub.elhack.net:8008:@R8WWz72Pz66Jw0SObjxe9Boio5WzskGpTK7wsPzeUEc=.ed25519~HaBhhmtYnYYfBA2j+JoYnckWXhPSzFGn0TCY1I9rP98=",
"createdAt": "2026-08-11T00:00:00.000Z"
},
{
"name": "Acampada 26s",
"description": "Red de la acampada en Madrid del #26s",
"url": "https://acampada26s.net",
"code": "madrid.acampada26s.net:8008:@Wt9tL9XtJCb7sZjX0+FtDlzziFLumWo1DAbou4Q5m3o=.ed25519~tkAoj1gE6dX8AcAOLQXs22VWFWKfOeJdT+7pBPfgbDA=",
"createdAt": "2026-09-26T00:00:00.000Z"
}
]

View file

@ -11,9 +11,7 @@
"fediverseMod": "on",
"invitesMod": "on",
"walletMod": "off",
"legacyMod": "on",
"devMod": "off",
"industryMod": "on",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
@ -34,17 +32,20 @@
"calendarsMod": "on",
"transfersMod": "off",
"feedMod": "on",
"pixeliaMod": "on",
"pixeliaMod": "off",
"melodyMod": "on",
"agendaMod": "on",
"aiMod": "off",
"aiNavMod": "off",
"forumMod": "on",
"gamesMod": "off",
"housingMod": "on",
"jobsMod": "on",
"shopsMod": "on",
"projectsMod": "on",
"industryMod": "on",
"bankingMod": "off",
"schoolMod": "on",
"parliamentMod": "on",
"courtsMod": "off",
"favoritesMod": "on",
@ -52,15 +53,24 @@
"mapsMod": "off",
"chatsMod": "on",
"torrentsMod": "off",
"filesMod": "on",
"phoneMod": "on",
"roomsMod": "on",
"graphosMod": "on",
"larpMod": "on",
"housingMod": "off"
"backupMod": "on",
"campaignsMod": "on",
"emergenciesMod": "on",
"logisticsMod": "on",
"mailingMod": "on",
"podcastsMod": "on",
"wikiMod": "on"
},
"wallet": {
"url": "http://localhost:7474",
"url": "http://127.0.0.1:7474",
"user": "",
"pass": "",
"fee": "5"
"fee": "0.01"
},
"walletPub": {
"pubId": ""
@ -76,6 +86,15 @@
"wish": "whole",
"pmVisibility": "whole",
"lanBroadcasting": true,
"phone": {
"visibility": "whole",
"dnd": false,
"relay": true
},
"blobCache": {
"maxMB": 0,
"pubMaxMB": 0
},
"bottomBarPins": [
"search",
"inbox",
@ -91,4 +110,4 @@
},
"relayOnly": false
}
}
}

View file

@ -11,13 +11,6 @@
"dunbar": 300,
"hops": 2
},
"gossip": {
"connections": 20,
"local": true,
"friends": true,
"seed": true,
"global": true
},
"replicationScheduler": {
"autostart": true,
"partialReplication": null
@ -54,7 +47,11 @@
}
],
"tunnel": [],
"onion": [],
"onion": [
{
"transform": "shs"
}
],
"ws": []
}
}

View file

@ -1,28 +1,65 @@
let _inboxCount = 0;
let _phoneCount = 0;
let _phoneCall = null;
let _phoneRoom = null;
let _pubIds = new Set();
let _inboxPmCount = 0;
let _inboxNotifCount = 0;
let _carbonHcT = 0;
let _walletReady = false;
let _donatableAuthors = new Set();
let _carbonHcH = 0;
let _lastRefresh = 0;
let _onlinePeers = null;
let _syncedPeers = null;
let _inboxUnread = null;
let _lastSyncTs = null;
let _ecoValue = null;
let _lastActivity = null;
let _maxBlockBytes = 0;
let _inhabitantCount = 0;
let _tribesCount = 0;
let _mentionsCount = 0;
let _mentionsTotal = 0;
let _bestMatch = null;
let _dismissedSuggestion = null;
let _matchPool = [];
let _matchIdx = -1;
let _sectionMatches = new Map();
const _dismissedSuggestions = new Set();
let _suggestionsQuiet = false;
let _featuredEmergency = null;
let _dismissedEmergency = null;
module.exports = {
getInboxCount: () => _inboxCount,
setInboxCount: (n) => { _inboxCount = n; },
getPhoneCount: () => _phoneCount,
setPhoneCount: (n) => { _phoneCount = Number(n) || 0; },
getPhoneCall: () => _phoneCall,
setPhoneCall: (st) => { _phoneCall = st || null; },
getPhoneRoom: () => _phoneRoom,
setPhoneRoom: (st) => { _phoneRoom = st || null; },
getPubIds: () => _pubIds,
setPubIds: (set) => { _pubIds = set instanceof Set ? set : new Set(); },
isPubId: (id) => _pubIds.has(String(id || '')),
getInboxPmCount: () => _inboxPmCount,
setInboxPmCount: (n) => { _inboxPmCount = n; },
getInboxNotifCount: () => _inboxNotifCount,
setInboxNotifCount: (n) => { _inboxNotifCount = n; },
getCarbonHcT: () => _carbonHcT,
setCarbonHcT: (n) => { _carbonHcT = n; },
getDonatableAuthors: () => _donatableAuthors,
setDonatableAuthors: (set) => { _donatableAuthors = set instanceof Set ? set : new Set(); },
getWalletReady: () => _walletReady,
setWalletReady: (v) => { _walletReady = !!v; },
getCarbonHcH: () => _carbonHcH,
setCarbonHcH: (n) => { _carbonHcH = n; },
getLastRefresh: () => _lastRefresh,
setLastRefresh: (t) => { _lastRefresh = t; },
getOnlinePeerCount: () => _onlinePeers,
setOnlinePeerCount: (n) => { _onlinePeers = n; },
getSyncedPeerCount: () => _syncedPeers,
setSyncedPeerCount: (n) => { _syncedPeers = Math.max(0, Number(n) || 0); },
getInboxUnreadCount: () => _inboxUnread,
setInboxUnreadCount: (n) => { _inboxUnread = n; },
getLastSyncTs: () => _lastSyncTs,
@ -33,12 +70,36 @@ module.exports = {
setLastActivity: (a) => { _lastActivity = a; },
getMaxBlockBytes: () => _maxBlockBytes,
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
getMentionsTotal: () => _mentionsTotal,
setMentionsTotal: (n) => { _mentionsTotal = Math.max(0, Number(n) || 0); },
getMentionsCount: () => _mentionsCount,
setMentionsCount: (n) => { _mentionsCount = Math.max(0, Number(n) || 0); },
getInhabitantCount: () => _inhabitantCount,
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); },
getTribesCount: () => _tribesCount,
setTribesCount: (n) => { _tribesCount = Math.max(0, Number(n) || 0); },
getFeaturedEmergency: () => _featuredEmergency,
setFeaturedEmergency: (a) => { _featuredEmergency = a || null; },
getDismissedEmergency: () => _dismissedEmergency,
setDismissedEmergency: (id) => { _dismissedEmergency = id || null; },
getBestMatch: () => _bestMatch,
setBestMatch: (m) => { _bestMatch = m || null; },
getDismissedSuggestion: () => _dismissedSuggestion,
setDismissedSuggestion: (href) => { _dismissedSuggestion = href || null; }
setMatchPool: (pool) => {
const before = new Set(_matchPool.map(m => m.href));
_matchPool = Array.isArray(pool) ? pool.filter(m => m && m.href) : [];
if (_matchPool.some(m => !before.has(m.href) && !_dismissedSuggestions.has(m.href))) _suggestionsQuiet = false;
},
nextBestMatch: () => {
const avail = _matchPool.filter(m => !_dismissedSuggestions.has(m.href));
if (!avail.length) { _bestMatch = null; return null; }
_matchIdx = (_matchIdx + 1) % avail.length;
_bestMatch = avail[_matchIdx];
return _bestMatch;
},
setSectionMatches: (map) => { _sectionMatches = map instanceof Map ? map : new Map(); },
getSectionMatch: (section) => (_sectionMatches.get(String(section || '')) || []).find(m => !_dismissedSuggestions.has(m.href)) || null,
isKnownSuggestion: (href) => !!href && (_matchPool.some(m => m.href === href) || [..._sectionMatches.values()].some(list => list.some(m => m.href === href))),
dismissSuggestion: (href) => { if (href) { _dismissedSuggestions.add(href); _suggestionsQuiet = true; } },
areSuggestionsQuiet: () => _suggestionsQuiet,
isSuggestionDismissed: (href) => _dismissedSuggestions.has(href)
};

View file

@ -1,7 +0,0 @@
{
"name": "SNH \"La Plaza\"",
"description": "A shared place to begin a utopia ...",
"url": "https://pub.solarnethub.com",
"code": "pub.solarnethub.com:8008:@0qSCyK3xyL71X4qKkmf84Cb2riP6OeUqxCvbP2Z6HWs=.ed25519~7mC1c1gBJe8pmnWI9eIUYpFtEPUOiuHAxbzfxP/f0Ak=",
"createdAt": "2026-05-24T00:00:00.000Z"
}

View file

@ -0,0 +1,112 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const LEGACY_CONFIG_DIR = __dirname;
const ROOT = 'oasis';
const FOLDERS = {
'wallet-addresses.json': 'banking',
'banking-address-book.json': 'banking',
'banking-allocations.json': 'banking',
'banking-eco-history.json': 'banking',
'banking-epochs.json': 'banking',
'banking-ubi-notice.json': 'banking',
'banking-confirm-notice.json': 'banking',
'banking-ubi-paid.json': 'banking',
'banking-funds-history.json': 'banking',
'content_favorites.json': 'content',
'blob-access.json': 'content',
'follow_state.json': 'content',
'invites-used.json': 'content',
'agenda-config.json': 'content',
'AI-history.json': 'ai',
'AI-vectors.json': 'ai',
'AI-search-vectors.json': 'ai',
'fediverse-accounts.json': 'multiverse',
'nextcloud-account.json': 'multiverse',
'oasis-backup.json': 'backup',
'oasis-first-contact': 'flags',
'oasis-mentions-seen': 'flags',
'oasis-inbox-read': 'flags',
'oasis-inbox-archived': 'flags',
'oasis-political-seen': 'flags',
'gossip_unfollowed.json': 'peers',
'lan-peers.json': 'peers',
'peer-health.json': 'peers',
'phone-history.json': 'phone',
'phone-seen.json': 'phone',
'snapshot.oasissn': 'content',
'snapshot-recent.oasissn': 'content'
};
const ALIASES = {
'oasis-backup.json': ['backup.json']
};
const ssbDir = () => {
try {
const resolved = require('../server/ssb_config').statePath('.');
if (resolved) return path.resolve(resolved);
} catch (_) {}
if (process.env.OASIS_STATE_DIR) return process.env.OASIS_STATE_DIR;
return path.join(os.homedir(), '.ssb');
};
const stateDir = (name) => path.join(ssbDir(), ROOT, FOLDERS[name] || '');
const statePath = (name) => {
const dir = stateDir(name);
const target = path.join(dir, name);
try {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(target)) {
const candidates = [name, ...(ALIASES[name] || [])]
.flatMap(n => [path.join(ssbDir(), n), path.join(LEGACY_CONFIG_DIR, n)]);
const legacy = candidates.find(p => fs.existsSync(p));
if (legacy) {
try { fs.renameSync(legacy, target); }
catch (_) { fs.copyFileSync(legacy, target); try { fs.unlinkSync(legacy); } catch (_) {} }
}
}
} catch (_) {}
return target;
};
const keysDir = (base = null) => {
const root = ssbDir();
if (base && path.resolve(base) !== root) return path.join(base, 'keys');
const target = path.join(root, ROOT, 'keys');
try {
if (!fs.existsSync(target)) {
fs.mkdirSync(path.dirname(target), { recursive: true });
const legacy = path.join(root, 'keys');
if (fs.existsSync(legacy)) fs.renameSync(legacy, target);
else fs.mkdirSync(target, { recursive: true, mode: 0o700 });
}
} catch (_) {}
return target;
};
const migrateAll = () => {
for (const name of Object.keys(FOLDERS)) {
try {
const legacy = [name, ...(ALIASES[name] || [])]
.flatMap(n => [path.join(ssbDir(), n), path.join(LEGACY_CONFIG_DIR, n)])
.find(p => fs.existsSync(p));
if (legacy) statePath(name);
} catch (_) {}
}
for (const name of Object.keys(FOLDERS)) {
if (FOLDERS[name] !== 'flags' || name.endsWith('.json')) continue;
const legacy = path.join(ssbDir(), name);
const target = path.join(stateDir(name), name);
try { if (fs.existsSync(legacy) && fs.existsSync(target)) fs.unlinkSync(legacy); } catch (_) {}
}
for (const leftover of ['keys', 'node_modules']) {
const dir = path.join(ssbDir(), leftover);
try { if (fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); } catch (_) {}
}
};
module.exports = { statePath, stateDir, ssbDir, keysDir, migrateAll };

View file

@ -1 +0,0 @@
{}

View file

@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Minesweeper</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#hud { display: flex; gap: 18px; padding: 6px 10px; font-size: 13px; color: #ccc; flex-wrap: wrap; justify-content: center; }
#hud b { color: #FFA500; }
#board { display: grid; gap: 2px; margin: 4px auto; touch-action: manipulation; }
.cell { display: flex; align-items: center; justify-content: center; background: #222; border: 1px solid #555; color: #eee; font-family: monospace; font-weight: bold; cursor: pointer; padding: 0; user-select: none; }
.cell.open { background: #0d0d0d; border-color: #333; cursor: default; }
.cell.boom { background: #3a1010; border-color: #f44; }
.cell.flag { color: #FFA500; }
.n1 { color: #55aaff; } .n2 { color: #6c6; } .n3 { color: #e66; } .n4 { color: #b7f; }
.n5 { color: #fa0; } .n6 { color: #4dd; } .n7 { color: #f8f; } .n8 { color: #fff; }
#msg { font-size: 15px; color: #FFA500; margin: 4px; text-align: center; min-height: 20px; }
#actions { display: flex; gap: 8px; justify-content: center; margin: 4px; }
#actions button { background: #222; border: 1px solid #555; color: #FFA500; padding: 6px 14px; cursor: pointer; font-family: monospace; font-size: 14px; }
#flagBtn.on { background: #3a2a00; border-color: #FFA500; }
#controls { color: #888; font-size: 12px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">MINESWEEPER</span>
</div>
<div id="hud">
<span>LEVEL <b id="levelEl">1</b></span>
<span>MINES <b id="minesEl">0</b></span>
<span>TIME <b id="timeEl">0</b></span>
<span>SCORE <b id="scoreEl">0</b></span>
<span>BEST <b id="bestEl">-</b></span>
</div>
<div id="board"></div>
<div id="msg">Click any cell to start</div>
<div id="actions">
<button id="flagBtn">&#9873; FLAG MODE: OFF</button>
<button id="newBtn">NEW GAME</button>
</div>
<div id="controls">Click — reveal &nbsp;|&nbsp; Right click — flag &nbsp;|&nbsp; Clear the board to level up</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="minesweeper">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const boardEl = document.getElementById('board');
let level, size, mines, grid, opened, flags, totalScore, gameState, mined, startTs, timerId;
let flagMode = false;
let best = parseInt(localStorage.getItem('minesweeper_best') || '-1');
if (best >= 0) document.getElementById('bestEl').textContent = best;
function dims() {
size = Math.min(7 + level, 16);
const cells = size * size;
mines = Math.min(Math.round(cells * (0.105 + level * 0.02)), Math.floor(cells * 0.28));
}
function cellPx() {
const w = (window.innerWidth - 24) / size;
const h = (window.innerHeight - 210) / size;
return Math.max(18, Math.min(34, Math.floor(Math.min(w, h))));
}
function buildBoard() {
dims();
grid = Array.from({ length: size }, () => Array.from({ length: size }, () => ({ mine: false, open: false, flag: false, n: 0 })));
opened = 0; flags = 0; mined = false;
const px = cellPx();
boardEl.style.gridTemplateColumns = `repeat(${size}, ${px}px)`;
boardEl.innerHTML = '';
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
const b = document.createElement('button');
b.className = 'cell';
b.style.width = px + 'px';
b.style.height = px + 'px';
b.style.fontSize = Math.floor(px * 0.55) + 'px';
b.dataset.r = r; b.dataset.c = c;
b.addEventListener('click', onTap);
b.addEventListener('contextmenu', (e) => { e.preventDefault(); toggleFlag(r, c); });
boardEl.appendChild(b);
}
}
document.getElementById('levelEl').textContent = level;
document.getElementById('minesEl').textContent = mines;
document.getElementById('scoreEl').textContent = totalScore;
}
function neighbors(r, c) {
const out = [];
for (let dr = -1; dr <= 1; dr++)
for (let dc = -1; dc <= 1; dc++) {
if (!dr && !dc) continue;
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < size && nc >= 0 && nc < size) out.push([nr, nc]);
}
return out;
}
function placeMines(sr, sc) {
const safe = new Set([sr + ':' + sc]);
neighbors(sr, sc).forEach(([r, c]) => safe.add(r + ':' + c));
let left = mines;
while (left > 0) {
const r = Math.floor(Math.random() * size), c = Math.floor(Math.random() * size);
if (safe.has(r + ':' + c) || grid[r][c].mine) continue;
grid[r][c].mine = true; left--;
}
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
grid[r][c].n = neighbors(r, c).filter(([nr, nc]) => grid[nr][nc].mine).length;
mined = true;
startTs = Date.now();
timerId = setInterval(() => {
document.getElementById('timeEl').textContent = Math.floor((Date.now() - startTs) / 1000);
}, 500);
}
function cellBtn(r, c) {
return boardEl.children[r * size + c];
}
function toggleFlag(r, c) {
if (gameState !== 'play') return;
const cell = grid[r][c];
if (cell.open) return;
cell.flag = !cell.flag;
flags += cell.flag ? 1 : -1;
const b = cellBtn(r, c);
b.textContent = cell.flag ? '⚑' : '';
b.classList.toggle('flag', cell.flag);
document.getElementById('minesEl').textContent = Math.max(0, mines - flags);
}
function onTap(e) {
if (gameState !== 'play') return;
const r = +e.currentTarget.dataset.r, c = +e.currentTarget.dataset.c;
if (flagMode) { toggleFlag(r, c); return; }
reveal(r, c);
}
function reveal(r, c) {
const cell = grid[r][c];
if (cell.open || cell.flag) return;
if (!mined) placeMines(r, c);
if (cell.mine) { boom(r, c); return; }
const stack = [[r, c]];
while (stack.length) {
const [cr, cc] = stack.pop();
const cur = grid[cr][cc];
if (cur.open || cur.flag || cur.mine) continue;
cur.open = true; opened++;
totalScore += 10 * level;
const b = cellBtn(cr, cc);
b.classList.add('open');
if (cur.n > 0) { b.textContent = cur.n; b.classList.add('n' + cur.n); }
else { neighbors(cr, cc).forEach((p) => stack.push(p)); }
}
document.getElementById('scoreEl').textContent = totalScore;
if (opened === size * size - mines) levelCleared();
}
function levelCleared() {
clearInterval(timerId);
const secs = Math.floor((Date.now() - startTs) / 1000);
const bonus = mines * 25 * level + Math.max(0, size * size - secs) * level;
totalScore += bonus;
document.getElementById('scoreEl').textContent = totalScore;
gameState = 'cleared';
document.getElementById('msg').textContent = `LEVEL ${level} CLEARED! +${bonus} bonus. Click to continue`;
boardEl.addEventListener('click', nextLevel, { once: true });
}
function nextLevel() {
level++;
gameState = 'play';
document.getElementById('msg').textContent = '';
document.getElementById('timeEl').textContent = '0';
buildBoard();
}
function boom(r, c) {
clearInterval(timerId);
gameState = 'over';
for (let rr = 0; rr < size; rr++)
for (let cc = 0; cc < size; cc++) {
if (grid[rr][cc].mine) {
const b = cellBtn(rr, cc);
b.classList.add('open');
b.textContent = '✹';
b.style.color = '#f44';
}
}
cellBtn(r, c).classList.add('boom');
if (best < 0 || totalScore > best) {
best = totalScore;
localStorage.setItem('minesweeper_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('msg').textContent = `GAME OVER! Score: ${totalScore}`;
document.getElementById('scoreInput').value = totalScore;
document.getElementById('scoreSubmit').style.display = 'block';
}
function startGame() {
clearInterval(timerId);
level = 1; totalScore = 0; gameState = 'play';
document.getElementById('msg').textContent = 'Click any cell to start';
document.getElementById('timeEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
buildBoard();
}
document.getElementById('flagBtn').addEventListener('click', () => {
flagMode = !flagMode;
const b = document.getElementById('flagBtn');
b.textContent = '⚑ FLAG MODE: ' + (flagMode ? 'ON' : 'OFF');
b.classList.toggle('on', flagMode);
});
document.getElementById('newBtn').addEventListener('click', startGame);
window.addEventListener('resize', () => {
if (gameState !== 'play' || mined) return;
buildBoard();
});
startGame();
</script>
</body>
</html>

View file

@ -0,0 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<g stroke="#333" stroke-width="2">
<rect x="60" y="30" width="40" height="40" fill="#222"/>
<rect x="100" y="30" width="40" height="40" fill="#0d0d0d"/>
<rect x="140" y="30" width="40" height="40" fill="#0d0d0d"/>
<rect x="180" y="30" width="40" height="40" fill="#222"/>
<rect x="220" y="30" width="40" height="40" fill="#222"/>
<rect x="260" y="30" width="40" height="40" fill="#0d0d0d"/>
<rect x="300" y="30" width="40" height="40" fill="#222"/>
<rect x="60" y="70" width="40" height="40" fill="#0d0d0d"/>
<rect x="100" y="70" width="40" height="40" fill="#0d0d0d"/>
<rect x="140" y="70" width="40" height="40" fill="#222"/>
<rect x="180" y="70" width="40" height="40" fill="#3a1010" stroke="#f44"/>
<rect x="220" y="70" width="40" height="40" fill="#222"/>
<rect x="260" y="70" width="40" height="40" fill="#0d0d0d"/>
<rect x="300" y="70" width="40" height="40" fill="#0d0d0d"/>
<rect x="60" y="110" width="40" height="40" fill="#222"/>
<rect x="100" y="110" width="40" height="40" fill="#0d0d0d"/>
<rect x="140" y="110" width="40" height="40" fill="#0d0d0d"/>
<rect x="180" y="110" width="40" height="40" fill="#222"/>
<rect x="220" y="110" width="40" height="40" fill="#222"/>
<rect x="260" y="110" width="40" height="40" fill="#222"/>
<rect x="300" y="110" width="40" height="40" fill="#0d0d0d"/>
</g>
<g font-family="monospace" font-weight="bold" font-size="26" text-anchor="middle">
<text x="120" y="59" fill="#55aaff">1</text>
<text x="160" y="59" fill="#6c6">2</text>
<text x="280" y="59" fill="#55aaff">1</text>
<text x="80" y="99" fill="#6c6">2</text>
<text x="120" y="99" fill="#e66">3</text>
<text x="280" y="99" fill="#6c6">2</text>
<text x="320" y="99" fill="#55aaff">1</text>
<text x="120" y="139" fill="#55aaff">1</text>
<text x="160" y="139" fill="#55aaff">1</text>
<text x="320" y="139" fill="#55aaff">1</text>
</g>
<circle cx="200" cy="90" r="11" fill="#f44"/>
<g stroke="#f44" stroke-width="3">
<line x1="200" y1="73" x2="200" y2="107"/>
<line x1="183" y1="90" x2="217" y2="90"/>
<line x1="188" y1="78" x2="212" y2="102"/>
<line x1="212" y1="78" x2="188" y2="102"/>
</g>
<g>
<line x1="235" y1="122" x2="235" y2="146" stroke="#FFA500" stroke-width="3"/>
<path d="M235 122 L255 129 L235 136 Z" fill="#FFA500"/>
</g>
<text x="200" y="190" font-family="monospace" font-weight="bold" font-size="28" fill="#FFA500" text-anchor="middle">MINESWEEPER</text>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,945 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visual DJ</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #1a1a1a;
--border: #333;
--accent: #ff4ad6;
--deckb: #4affd6;
--mix: #ff9500;
--fx: #4a9eff;
--text: #ffffff;
--muted: #888;
}
* { box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body { margin: 0; padding: 10px; font-family: Consolas, monospace; background: var(--bg); color: var(--text); display: flex; flex-direction: column; }
.container { display: flex; gap: 10px; align-items: stretch; flex: 1; min-height: 0; }
.stage-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 10px; flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0;
}
.controls-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 10px; width: 460px; flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; overflow-x: hidden;
}
.decks { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
h3 { margin: 0 0 6px 0; color: var(--accent); font-size: 12px; letter-spacing: 2px; text-transform: uppercase; }
.deck-section { border: 2px solid var(--accent); border-radius: 8px; padding: 8px; margin: 0; background: #1a0f17; }
.deck-section.deck-b { border-color: var(--deckb); background: #0f1a17; }
.deck-section.deck-b h3 { color: var(--deckb); }
.deck-section.deck-b input[type="range"] { accent-color: var(--deckb); }
.deck-section.deck-b .value-display { color: var(--deckb); }
.deck-section.deck-b .program-btn { border-color: var(--deckb); color: var(--deckb); }
.deck-section.deck-b .program-btn.active, .deck-section.deck-b .program-btn:hover { background: var(--deckb); color: #000; }
.mix-section { border: 2px solid var(--mix); border-radius: 8px; padding: 8px; margin: 0; background: #1a0f00; }
.mix-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
.fx-section { border: 2px solid var(--fx); border-radius: 8px; padding: 8px; margin: 0; background: #0a0f1a; }
.fx-section h3 { color: var(--fx); }
.fx-section input[type="range"] { accent-color: var(--fx); }
.fx-section .value-display { color: var(--fx); }
.fx-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin-bottom: 6px; }
.fx-btn { border-color: var(--fx); color: var(--fx); padding: 3px 1px; font-size: 9px; }
.fx-btn:hover, .fx-btn.on { background: var(--fx); color: #000; }
.fx-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
.fx-text { display: flex; gap: 6px; align-items: center; }
.fx-text input[type="text"] {
flex: 1; min-width: 0; background: #000; color: var(--fx); border: 1px solid var(--fx); border-radius: 4px;
padding: 2px 6px; font-family: inherit; font-size: 11px; height: 20px;
}
.scenes { display: flex; gap: 4px; align-items: center; flex: none; }
.scenes .label-inline { font-size: 10px; color: #ccc; text-transform: uppercase; letter-spacing: 1px; margin-right: 2px; }
.scene-btn { border-color: var(--fx); color: var(--fx); padding: 2px 7px; font-size: 10px; height: 20px; }
.scene-btn:hover, .scene-btn.on { background: var(--fx); color: #000; }
.scene-btn.armed { border-style: dashed; }
.scene-btn.empty { opacity: 0.45; }
.mix-section h3 { color: var(--mix); }
.mix-section input[type="range"] { accent-color: var(--mix); }
.mix-section .value-display { color: var(--mix); }
.status { background:#000; color:var(--accent); padding:6px 10px; border-radius:6px; margin-bottom:6px; font-size:11px; flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mix-status { background:#1a0f00; color:var(--mix); border: 1px solid var(--mix); }
.toolbar { display:flex; gap:8px; margin-bottom:8px; flex-wrap: wrap; flex: none; }
button {
padding: 6px 10px; border: 1px solid var(--accent); background: var(--panel); color: var(--accent);
border-radius: 6px; cursor: pointer; transition: all .2s; font-family: inherit; font-size: 12px;
}
button:hover { background: var(--accent); color: #000; }
button.on { background: var(--accent); color: #000; }
button.mix-btn { border-color: var(--mix); color: var(--mix); }
button.mix-btn:hover, button.mix-btn.on { background: var(--mix); color: #000; }
.stage { position: relative; background: #000; border-radius: 6px; overflow: hidden; flex: 1; min-height: 120px; }
canvas { width: 100%; height: 100%; display: block; background: #000; cursor: crosshair; }
.stage:fullscreen { border-radius: 0; }
.stage:fullscreen canvas { width: 100vw; height: 100vh; object-fit: contain; }
.help { background:#0a0a2a; color:#99aaff; padding:6px 10px; border-radius:6px; margin-top:8px; font-size:10px; line-height: 1.6; flex: none; }
.help kbd { background:#000; border:1px solid #334; border-radius:3px; padding:0 4px; color:#fff; }
.control-group { margin: 0; padding: 0; }
label { display:block; margin: 0; font-size:10px; color:#ccc; text-transform:uppercase; letter-spacing:1px; line-height: 14px; }
input[type="range"] { width:100%; margin: 0 0 2px 0; height: 14px; accent-color: var(--accent); }
select {
width: 100%; background: #000; color: var(--mix); border: 1px solid var(--mix); border-radius: 4px;
padding: 1px 4px; font-family: inherit; font-size: 11px; margin: 0 0 4px 0; height: 20px;
}
.value-display { background:#000; color:var(--accent); padding:0 5px; border-radius:3px; font-size:10px; float:right; line-height: 14px; }
.programs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-bottom: 6px; }
.program-btn { padding: 3px 1px; font-size: 9px; letter-spacing: 0; }
.program-btn.active { background: var(--accent); color: #000; }
#topbar { width:100%; padding:6px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; margin-bottom:8px; flex: none; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
#topbar span { color:#FFA500; font-weight:bold; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span>VISUAL DJ</span>
</div>
<div class="container">
<div class="stage-panel">
<div class="status" id="status">Status: Paused | FPS: -- | BPM: 120 | Time: 0.0 s</div>
<div class="status mix-status" id="mixStatus">Deck A: Plasma | Deck B: Tunnel | Mix: 50% A/B | Blend: Screen | Mic: Off</div>
<div class="toolbar">
<button id="playBtn">&#9654; Play</button>
<button id="flashBtn">&#10024; Flash</button>
<button id="tapBtn" class="mix-btn">&#9201; Tap Tempo</button>
<button id="micBtn" class="mix-btn">&#127908; Mic</button>
<button id="snapBtn">&#128247; Snapshot</button>
<button id="fullBtn">&#9974; Fullscreen</button>
</div>
<div class="stage" id="stage">
<canvas id="stageCanvas" width="680" height="540"></canvas>
</div>
<div class="help">
<strong>Live controls:</strong><br />
Drag on the stage to move the crossfader, click to flash.<br />
<kbd>1</kbd>-<kbd>8</kbd> pattern on deck A &middot; <kbd>Q</kbd>-<kbd>I</kbd> pattern on deck B &middot; <kbd>Z</kbd>-<kbd>/</kbd> effects &middot; <kbd>&larr;</kbd> <kbd>&rarr;</kbd> crossfade &middot; <kbd>Space</kbd> flash &middot; <kbd>T</kbd> tap tempo &middot; <kbd>P</kbd> play/pause &middot; <kbd>F</kbd> fullscreen &middot; <kbd>Shift</kbd>+<kbd>1</kbd>-<kbd>4</kbd> scenes
</div>
</div>
<div class="controls-panel">
<div class="decks">
<div class="deck-section" id="deckAPanel">
<h3>Deck A</h3>
<div class="programs" id="programsA"></div>
<div class="control-group">
<label>Speed <span class="value-display" id="speedADisplay">1.00</span></label>
<input type="range" id="speedA" min="0" max="3" step="0.05" value="1" />
<label>Intensity <span class="value-display" id="intensityADisplay">0.70</span></label>
<input type="range" id="intensityA" min="0" max="1" step="0.05" value="0.7" />
<label>Hue <span class="value-display" id="hueADisplay">320</span></label>
<input type="range" id="hueA" min="0" max="360" step="1" value="320" />
<label>Zoom <span class="value-display" id="zoomADisplay">1.00</span></label>
<input type="range" id="zoomA" min="0.3" max="3" step="0.05" value="1" />
<label>Rotation <span class="value-display" id="rotADisplay">0.00</span></label>
<input type="range" id="rotA" min="-2" max="2" step="0.05" value="0" />
</div>
</div>
<div class="deck-section deck-b" id="deckBPanel">
<h3>Deck B</h3>
<div class="programs" id="programsB"></div>
<div class="control-group">
<label>Speed <span class="value-display" id="speedBDisplay">1.00</span></label>
<input type="range" id="speedB" min="0" max="3" step="0.05" value="1" />
<label>Intensity <span class="value-display" id="intensityBDisplay">0.70</span></label>
<input type="range" id="intensityB" min="0" max="1" step="0.05" value="0.7" />
<label>Hue <span class="value-display" id="hueBDisplay">170</span></label>
<input type="range" id="hueB" min="0" max="360" step="1" value="170" />
<label>Zoom <span class="value-display" id="zoomBDisplay">1.00</span></label>
<input type="range" id="zoomB" min="0.3" max="3" step="0.05" value="1" />
<label>Rotation <span class="value-display" id="rotBDisplay">0.00</span></label>
<input type="range" id="rotB" min="-2" max="2" step="0.05" value="0" />
</div>
</div>
</div>
<div class="mix-section">
<h3>Mixer</h3>
<div class="mix-grid">
<label>Crossfader A &harr; B <span class="value-display" id="crossfadeDisplay">50%</span></label>
<label>Blend mode</label>
<input type="range" id="crossfade" min="0" max="1" step="0.01" value="0.5" />
<select id="blend">
<option value="screen" selected>Screen</option>
<option value="lighter">Add</option>
<option value="source-over">Normal</option>
<option value="difference">Difference</option>
<option value="multiply">Multiply</option>
<option value="overlay">Overlay</option>
</select>
<label>Auto mix (s) <span class="value-display" id="automixDisplay">Off</span></label>
<label>Trails <span class="value-display" id="trailsDisplay">0.30</span></label>
<input type="range" id="automix" min="0" max="60" step="1" value="0" />
<input type="range" id="trails" min="0" max="0.95" step="0.05" value="0.3" />
<label>Brightness <span class="value-display" id="brightnessDisplay">1.00</span></label>
<label>Strobe (per beat) <span class="value-display" id="strobeDisplay">0.00</span></label>
<input type="range" id="brightness" min="0.2" max="2" step="0.05" value="1" />
<input type="range" id="strobe" min="0" max="1" step="0.05" value="0" />
<label>Beat pulse <span class="value-display" id="pulseDisplay">0.50</span></label>
<label>Mic sensitivity <span class="value-display" id="micGainDisplay">2.0</span></label>
<input type="range" id="pulse" min="0" max="1" step="0.05" value="0.5" />
<input type="range" id="micGain" min="0.5" max="6" step="0.1" value="2" />
</div>
</div>
<div class="fx-section">
<h3>Effects</h3>
<div class="fx-grid" id="fxGrid"></div>
<div class="fx-row">
<label>FX amount <span class="value-display" id="fxAmountDisplay">0.50</span></label>
<label>Text size <span class="value-display" id="textSizeDisplay">0.50</span></label>
<input type="range" id="fxAmount" min="0" max="1" step="0.05" value="0.5" />
<input type="range" id="textSize" min="0.1" max="1" step="0.05" value="0.5" />
</div>
<div class="fx-text">
<input type="text" id="overlayText" maxlength="40" placeholder="Text on stage..." autocomplete="off" />
<div class="scenes" id="scenes">
<span class="label-inline">Scenes</span>
<button class="scene-btn" id="sceneSave" title="Arm, then pick a slot to store the current setup">Save</button>
</div>
</div>
</div>
</div>
</div>
<script>
const PROGRAMS = [
{ id: 'plasma', name: 'Plasma' },
{ id: 'tunnel', name: 'Tunnel' },
{ id: 'particles', name: 'Stars' },
{ id: 'waves', name: 'Waves' },
{ id: 'grid', name: 'Grid' },
{ id: 'kaleido', name: 'Kaleido' },
{ id: 'lissajous', name: 'Curves' },
{ id: 'blocks', name: 'Blocks' }
];
const KEYS_A = ['1', '2', '3', '4', '5', '6', '7', '8'];
const KEYS_B = ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i'];
const EFFECTS = [
{ id: 'mirror', name: 'Mirror' },
{ id: 'quad', name: 'Quad' },
{ id: 'pixelate', name: 'Pixels' },
{ id: 'rgb', name: 'RGB split' },
{ id: 'invert', name: 'Invert' },
{ id: 'scanlines', name: 'Scanlines' },
{ id: 'vignette', name: 'Vignette' },
{ id: 'grain', name: 'Grain' },
{ id: 'shake', name: 'Shake' },
{ id: 'cycle', name: 'Color cycle' }
];
const KEYS_FX = ['z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/'];
const SCENE_SLOTS = 4;
const hsl = (h, s, l, a) => 'hsla(' + ((h % 360) + 360) % 360 + ',' + s + '%,' + l + '%,' + (a === undefined ? 1 : a) + ')';
class Deck {
constructor(name, program, hue) {
this.name = name;
this.program = program;
this.speed = 1; this.intensity = 0.7; this.hue = hue; this.zoom = 1; this.rot = 0;
this.time = 0; this.angle = 0;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.low = document.createElement('canvas');
this.low.width = 96; this.low.height = 72;
this.lowCtx = this.low.getContext('2d');
this.image = this.lowCtx.createImageData(this.low.width, this.low.height);
this.particles = [];
this.blocks = [];
this.blockBeat = -1;
}
resize(w, h) {
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; }
}
step(dt, beat) {
this.time += dt * this.speed * (1 + beat * 0.5);
this.angle += dt * this.rot;
}
render(w, h, beat, beatIndex) {
const ctx = this.ctx;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.rotate(this.angle);
ctx.scale(this.zoom, this.zoom);
ctx.translate(-w / 2, -h / 2);
const p = { w, h, t: this.time, beat, hue: this.hue, k: this.intensity, beatIndex };
this['draw_' + this.program](ctx, p);
ctx.restore();
}
draw_plasma(ctx, p) {
const lw = this.low.width, lh = this.low.height, d = this.image.data;
const t = p.t * 0.8;
for (let y = 0; y < lh; y++) {
for (let x = 0; x < lw; x++) {
const nx = x / lw - 0.5, ny = y / lh - 0.5;
const v = Math.sin(nx * 10 + t) + Math.sin(ny * 10 - t * 0.7) + Math.sin((nx + ny) * 10 + t * 0.5) + Math.sin(Math.hypot(nx, ny) * 20 - t * 1.3);
const n = (v + 4) / 8;
const hue = (p.hue + n * 120 * p.k + p.beat * 40) % 360;
const rgb = this.hslToRgb(hue / 360, 0.9, 0.25 + 0.45 * n * (0.5 + p.k * 0.5));
const i = (y * lw + x) * 4;
d[i] = rgb[0]; d[i + 1] = rgb[1]; d[i + 2] = rgb[2]; d[i + 3] = 255;
}
}
this.lowCtx.putImageData(this.image, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this.low, -p.w * 0.5, -p.h * 0.5, p.w * 2, p.h * 2);
}
draw_tunnel(ctx, p) {
const cx = p.w / 2, cy = p.h / 2;
const maxR = Math.hypot(cx, cy) * 1.2;
const rings = 14 + Math.round(p.k * 16);
for (let i = rings; i >= 0; i--) {
const f = ((i / rings) + (p.t * 0.25 % 1)) % 1;
const r = Math.pow(f, 2.2) * maxR;
ctx.beginPath();
const sides = 5 + Math.round(p.k * 7);
for (let s = 0; s <= sides; s++) {
const a = (s / sides) * Math.PI * 2 + p.t * 0.3 + f * 2;
const wob = 1 + Math.sin(a * 3 + p.t * 2) * 0.08 * p.k;
const x = cx + Math.cos(a) * r * wob, y = cy + Math.sin(a) * r * wob;
if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.strokeStyle = hsl(p.hue + f * 90, 100, 40 + f * 30, 0.35 + f * 0.65);
ctx.lineWidth = 1 + f * (6 + p.beat * 10);
ctx.stroke();
}
}
draw_particles(ctx, p) {
const want = 120 + Math.round(p.k * 380);
while (this.particles.length < want) this.particles.push({ x: Math.random() * 2 - 1, y: Math.random() * 2 - 1, z: Math.random() });
if (this.particles.length > want) this.particles.length = want;
const cx = p.w / 2, cy = p.h / 2;
const dz = 0.004 + p.beat * 0.02;
for (const s of this.particles) {
s.z -= dz * this.speed;
if (s.z <= 0.02) { s.x = Math.random() * 2 - 1; s.y = Math.random() * 2 - 1; s.z = 1; }
const px = cx + (s.x / s.z) * cx, py = cy + (s.y / s.z) * cy;
const prev = s.z + dz * 3;
const qx = cx + (s.x / prev) * cx, qy = cy + (s.y / prev) * cy;
const b = 1 - s.z;
ctx.strokeStyle = hsl(p.hue + b * 60, 100, 50 + b * 40, Math.min(1, b * 1.4));
ctx.lineWidth = 1 + b * 3;
ctx.beginPath(); ctx.moveTo(qx, qy); ctx.lineTo(px, py); ctx.stroke();
}
}
draw_waves(ctx, p) {
const lines = 8 + Math.round(p.k * 24);
for (let i = 0; i < lines; i++) {
const f = i / lines;
const yBase = p.h * (0.1 + f * 0.8);
ctx.beginPath();
for (let x = -20; x <= p.w + 20; x += 6) {
const nx = x / p.w;
const y = yBase + Math.sin(nx * (4 + f * 6) + p.t * (1 + f) + i) * (10 + p.k * 40 + p.beat * 40) * Math.sin(p.t * 0.3 + f * 3);
if (x === -20) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.strokeStyle = hsl(p.hue + f * 120, 100, 45 + f * 25, 0.5 + f * 0.5);
ctx.lineWidth = 1 + f * 3;
ctx.stroke();
}
}
draw_grid(ctx, p) {
const horizon = p.h * 0.45;
const cx = p.w / 2;
ctx.fillStyle = hsl(p.hue + 180, 80, 10 + p.beat * 15, 1);
ctx.fillRect(0, 0, p.w, horizon);
const sun = ctx.createRadialGradient(cx, horizon, 10, cx, horizon, p.w * 0.3);
sun.addColorStop(0, hsl(p.hue, 100, 65, 0.9));
sun.addColorStop(1, hsl(p.hue, 100, 50, 0));
ctx.fillStyle = sun; ctx.fillRect(0, 0, p.w, horizon);
ctx.fillStyle = '#000'; ctx.fillRect(0, horizon, p.w, p.h - horizon);
ctx.strokeStyle = hsl(p.hue, 100, 60, 0.9);
ctx.lineWidth = 1.5;
const cols = 8 + Math.round(p.k * 12);
for (let i = -cols; i <= cols; i++) {
const x = cx + (i / cols) * p.w * 0.7;
ctx.beginPath(); ctx.moveTo(cx + (x - cx) * 0.05, horizon); ctx.lineTo(cx + (x - cx) * 3, p.h); ctx.stroke();
}
const rows = 12;
for (let r = 0; r < rows; r++) {
const f = ((r / rows) + (p.t * 0.4 % 1)) % 1;
const y = horizon + Math.pow(f, 2) * (p.h - horizon);
ctx.globalAlpha = 0.3 + f * 0.7;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(p.w, y); ctx.stroke();
}
ctx.globalAlpha = 1;
}
draw_kaleido(ctx, p) {
const cx = p.w / 2, cy = p.h / 2;
const seg = 6 + Math.round(p.k * 6);
const r = Math.min(cx, cy) * (0.9 + p.beat * 0.2);
for (let s = 0; s < seg; s++) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((s / seg) * Math.PI * 2);
if (s % 2) ctx.scale(1, -1);
for (let i = 0; i < 7; i++) {
const a = p.t * (0.5 + i * 0.13) + i;
const d = r * (0.2 + 0.7 * (0.5 + 0.5 * Math.sin(p.t * 0.7 + i)));
const x = Math.cos(a) * d, y = Math.sin(a) * d * 0.5;
ctx.fillStyle = hsl(p.hue + i * 30 + p.t * 20, 100, 55, 0.6);
ctx.beginPath(); ctx.arc(x, y, 8 + i * 4 + p.beat * 12, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = hsl(p.hue + i * 30 + 180, 100, 70, 0.8);
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(x, y); ctx.stroke();
}
ctx.restore();
}
}
draw_lissajous(ctx, p) {
const cx = p.w / 2, cy = p.h / 2;
const ax = cx * 0.85, ay = cy * 0.85;
const curves = 2 + Math.round(p.k * 4);
for (let c = 0; c < curves; c++) {
const fx = 2 + c, fy = 3 + c * 0.5 + Math.sin(p.t * 0.1) * 0.5;
const ph = p.t * 0.5 + c;
ctx.beginPath();
for (let i = 0; i <= 400; i++) {
const u = (i / 400) * Math.PI * 2;
const x = cx + Math.sin(u * fx + ph) * ax * (1 - c * 0.08);
const y = cy + Math.sin(u * fy) * ay * (1 - c * 0.08);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.strokeStyle = hsl(p.hue + c * 50 + p.t * 10, 100, 60, 0.85);
ctx.lineWidth = 1.5 + c * 0.5 + p.beat * 4;
ctx.stroke();
}
}
draw_blocks(ctx, p) {
const cols = 8, rows = 6;
if (p.beatIndex !== this.blockBeat || !this.blocks.length) {
this.blockBeat = p.beatIndex;
this.blocks = [];
const n = 4 + Math.round(p.k * 20);
for (let i = 0; i < n; i++) this.blocks.push({ c: Math.floor(Math.random() * cols), r: Math.floor(Math.random() * rows), h: Math.random() * 90 });
}
const bw = p.w / cols, bh = p.h / rows;
for (const b of this.blocks) {
ctx.fillStyle = hsl(p.hue + b.h, 100, 40 + p.beat * 35, 0.5 + p.beat * 0.5);
const inset = (1 - p.beat) * 6;
ctx.fillRect(b.c * bw + inset, b.r * bh + inset, bw - inset * 2, bh - inset * 2);
}
ctx.strokeStyle = hsl(p.hue, 60, 30, 0.6);
ctx.lineWidth = 1;
for (let c = 1; c < cols; c++) { ctx.beginPath(); ctx.moveTo(c * bw, 0); ctx.lineTo(c * bw, p.h); ctx.stroke(); }
for (let r = 1; r < rows; r++) { ctx.beginPath(); ctx.moveTo(0, r * bh); ctx.lineTo(p.w, r * bh); ctx.stroke(); }
}
hslToRgb(h, s, l) {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const f = (t) => {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [Math.round(f(h + 1 / 3) * 255), Math.round(f(h) * 255), Math.round(f(h - 1 / 3) * 255)];
}
}
class VisualDJ {
constructor() {
this.canvas = document.getElementById('stageCanvas');
this.ctx = this.canvas.getContext('2d');
this.stage = document.getElementById('stage');
this.deckA = new Deck('A', 'plasma', 320);
this.deckB = new Deck('B', 'tunnel', 170);
this.crossfade = 0.5; this.blend = 'screen'; this.automix = 0; this.automixDir = 1;
this.trails = 0.3; this.brightness = 1; this.strobe = 0; this.pulse = 0.5; this.micGain = 2;
this.running = false; this.time = 0; this.last = 0; this.fps = 0; this.frames = 0; this.fpsAt = 0;
this.bpm = 120; this.beatAt = 0; this.beatIndex = 0; this.taps = [];
this.flash = 0;
this.mic = null; this.micLevel = 0;
this.dragging = false; this.dragMoved = false;
this.fx = new Set(); this.fxAmount = 0.5; this.textSize = 0.5; this.overlayText = '';
this.fxCanvas = document.createElement('canvas'); this.fxCtx = this.fxCanvas.getContext('2d');
this.chanCanvas = document.createElement('canvas'); this.chanCtx = this.chanCanvas.getContext('2d');
this.pixCanvas = document.createElement('canvas'); this.pixCtx = this.pixCanvas.getContext('2d');
this.grainCanvas = document.createElement('canvas'); this.grainCanvas.width = 160; this.grainCanvas.height = 120;
this.grainCtx = this.grainCanvas.getContext('2d'); this.grainImage = this.grainCtx.createImageData(160, 120);
this.scanPattern = null;
this.scenes = new Array(SCENE_SLOTS).fill(null); this.sceneArmed = false;
this.buildPrograms();
this.buildEffects();
this.buildScenes();
this.bindControls();
this.resize();
this.updateControls();
this.render(0);
this.updateStatus();
requestAnimationFrame(() => this.resize());
}
buildPrograms() {
const make = (deck, holder, keys) => {
holder.innerHTML = '';
PROGRAMS.forEach((prog, i) => {
const b = document.createElement('button');
b.className = 'program-btn' + (deck.program === prog.id ? ' active' : '');
b.textContent = prog.name;
b.title = 'Key ' + keys[i].toUpperCase();
b.addEventListener('click', () => this.setProgram(deck, prog.id));
holder.appendChild(b);
});
};
make(this.deckA, document.getElementById('programsA'), KEYS_A);
make(this.deckB, document.getElementById('programsB'), KEYS_B);
}
setProgram(deck, id) {
deck.program = id;
const holder = document.getElementById(deck === this.deckA ? 'programsA' : 'programsB');
[...holder.children].forEach((b, i) => b.classList.toggle('active', PROGRAMS[i].id === id));
this.updateStatus();
if (!this.running) this.render(0);
}
buildEffects() {
const holder = document.getElementById('fxGrid');
holder.innerHTML = '';
EFFECTS.forEach((fx, i) => {
const b = document.createElement('button');
b.className = 'fx-btn' + (this.fx.has(fx.id) ? ' on' : '');
b.textContent = fx.name;
b.title = 'Key ' + KEYS_FX[i].toUpperCase();
b.addEventListener('click', () => this.toggleFx(fx.id));
holder.appendChild(b);
});
}
toggleFx(id) {
if (this.fx.has(id)) this.fx.delete(id); else this.fx.add(id);
[...document.getElementById('fxGrid').children].forEach((b, i) => b.classList.toggle('on', this.fx.has(EFFECTS[i].id)));
if (!this.running) this.render(0);
}
buildScenes() {
const holder = document.getElementById('scenes');
[...holder.querySelectorAll('.scene-slot')].forEach(b => b.remove());
for (let i = 0; i < SCENE_SLOTS; i++) {
const b = document.createElement('button');
b.className = 'scene-btn scene-slot' + (this.scenes[i] ? '' : ' empty');
b.textContent = String(i + 1);
b.title = 'Shift+' + (i + 1);
b.addEventListener('click', () => this.scene(i));
holder.appendChild(b);
}
document.getElementById('sceneSave').addEventListener('click', () => this.armScene(!this.sceneArmed));
}
armScene(on) {
this.sceneArmed = on;
document.getElementById('sceneSave').classList.toggle('on', on);
[...document.querySelectorAll('.scene-slot')].forEach(b => b.classList.toggle('armed', on));
}
snapshotState() {
const deck = (d) => ({ program: d.program, speed: d.speed, intensity: d.intensity, hue: d.hue, zoom: d.zoom, rot: d.rot });
return {
a: deck(this.deckA), b: deck(this.deckB),
crossfade: this.crossfade, blend: this.blend, automix: this.automix, trails: this.trails, brightness: this.brightness,
strobe: this.strobe, pulse: this.pulse, micGain: this.micGain,
fx: [...this.fx], fxAmount: this.fxAmount, textSize: this.textSize, overlayText: this.overlayText
};
}
applyState(st) {
for (const [deck, d] of [[this.deckA, st.a], [this.deckB, st.b]]) {
Object.assign(deck, { speed: d.speed, intensity: d.intensity, hue: d.hue, zoom: d.zoom, rot: d.rot });
this.setProgram(deck, d.program);
}
for (const k of ['crossfade', 'blend', 'automix', 'trails', 'brightness', 'strobe', 'pulse', 'micGain', 'fxAmount', 'textSize', 'overlayText']) this[k] = st[k];
this.fx = new Set(st.fx);
[...document.getElementById('fxGrid').children].forEach((b, i) => b.classList.toggle('on', this.fx.has(EFFECTS[i].id)));
this.syncInputs();
this.updateControls();
if (!this.running) this.render(0);
}
syncInputs() {
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
for (const [sfx, deck] of [['A', this.deckA], ['B', this.deckB]]) {
set('speed' + sfx, deck.speed); set('intensity' + sfx, deck.intensity); set('hue' + sfx, deck.hue); set('zoom' + sfx, deck.zoom); set('rot' + sfx, deck.rot);
}
for (const k of ['crossfade', 'automix', 'trails', 'brightness', 'strobe', 'pulse', 'micGain', 'fxAmount', 'textSize', 'overlayText']) set(k, this[k]);
set('blend', this.blend);
}
scene(i) {
const slots = [...document.querySelectorAll('.scene-slot')];
if (this.sceneArmed) {
this.scenes[i] = this.snapshotState();
slots[i].classList.remove('empty');
this.armScene(false);
return;
}
if (!this.scenes[i]) return;
this.applyState(this.scenes[i]);
slots.forEach((b, j) => b.classList.toggle('on', j === i));
}
bindControls() {
const fxSliders = ['fxAmount', 'textSize'];
for (const id of fxSliders) {
document.getElementById(id).addEventListener('input', (e) => {
this[id] = parseFloat(e.target.value);
this.updateControls();
if (!this.running) this.render(0);
});
}
document.getElementById('overlayText').addEventListener('input', (e) => { this.overlayText = e.target.value; if (!this.running) this.render(0); });
const deckSliders = [['speed', 'speed'], ['intensity', 'intensity'], ['hue', 'hue'], ['zoom', 'zoom'], ['rot', 'rot']];
for (const [id, prop] of deckSliders) {
for (const [suffix, deck] of [['A', this.deckA], ['B', this.deckB]]) {
document.getElementById(id + suffix).addEventListener('input', (e) => {
deck[prop] = parseFloat(e.target.value);
this.updateControls();
if (!this.running) this.render(0);
});
}
}
const mix = ['crossfade', 'automix', 'trails', 'brightness', 'strobe', 'pulse', 'micGain'];
for (const id of mix) {
document.getElementById(id).addEventListener('input', (e) => {
this[id] = parseFloat(e.target.value);
this.updateControls();
if (!this.running) this.render(0);
});
}
document.getElementById('blend').addEventListener('change', (e) => { this.blend = e.target.value; this.updateStatus(); if (!this.running) this.render(0); });
document.getElementById('playBtn').addEventListener('click', () => this.toggle());
document.getElementById('flashBtn').addEventListener('click', () => this.doFlash());
document.getElementById('tapBtn').addEventListener('click', () => this.tap());
document.getElementById('micBtn').addEventListener('click', () => this.toggleMic());
document.getElementById('snapBtn').addEventListener('click', () => this.snapshot());
document.getElementById('fullBtn').addEventListener('click', () => this.fullscreen());
this.canvas.addEventListener('mousedown', (e) => { this.dragging = true; this.dragMoved = false; this.dragTo(e); });
this.canvas.addEventListener('mousemove', (e) => { if (this.dragging) { this.dragMoved = true; this.dragTo(e); } });
const up = () => { if (this.dragging && !this.dragMoved) this.doFlash(); this.dragging = false; };
this.canvas.addEventListener('mouseup', up);
this.canvas.addEventListener('mouseleave', () => { this.dragging = false; });
this.canvas.addEventListener('touchstart', (e) => { this.dragging = true; this.dragMoved = false; this.dragTo(e.touches[0]); e.preventDefault(); }, { passive: false });
this.canvas.addEventListener('touchmove', (e) => { if (this.dragging) { this.dragMoved = true; this.dragTo(e.touches[0]); } e.preventDefault(); }, { passive: false });
this.canvas.addEventListener('touchend', up);
window.addEventListener('keydown', (e) => this.onKey(e));
window.addEventListener('resize', () => this.resize());
document.addEventListener('fullscreenchange', () => this.resize());
}
dragTo(e) {
if (!this.dragMoved) return;
const r = this.canvas.getBoundingClientRect();
this.crossfade = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
this.updateControls();
if (!this.running) this.render(0);
}
onKey(e) {
if (e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName) && e.key !== 'Escape') return;
const k = e.key.toLowerCase();
if (e.shiftKey && /^Digit[1-4]$/.test(e.code)) { e.preventDefault(); return this.scene(Number(e.code.slice(5)) - 1); }
const ia = KEYS_A.indexOf(k), ib = KEYS_B.indexOf(k), ifx = KEYS_FX.indexOf(k);
if (ia >= 0) return this.setProgram(this.deckA, PROGRAMS[ia].id);
if (ib >= 0) return this.setProgram(this.deckB, PROGRAMS[ib].id);
if (ifx >= 0) return this.toggleFx(EFFECTS[ifx].id);
if (k === ' ') { e.preventDefault(); return this.doFlash(); }
if (k === 'arrowleft') { e.preventDefault(); this.crossfade = Math.max(0, this.crossfade - 0.05); this.updateControls(); return; }
if (k === 'arrowright') { e.preventDefault(); this.crossfade = Math.min(1, this.crossfade + 0.05); this.updateControls(); return; }
if (k === 'p') return this.toggle();
if (k === 'f') return this.fullscreen();
if (k === 't') return this.tap();
}
resize() {
const w = this.stage.clientWidth || 680;
const h = this.stage.clientHeight || Math.round(w * (540 / 680));
if (w < 10 || h < 10) return;
this.canvas.width = w; this.canvas.height = h;
this.deckA.resize(w, h); this.deckB.resize(w, h);
if (!this.running) this.render(0);
}
toggle() {
this.running = !this.running;
const btn = document.getElementById('playBtn');
btn.innerHTML = this.running ? '&#9208; Pause' : '&#9654; Play';
btn.classList.toggle('on', this.running);
if (this.running) { this.last = performance.now(); this.beatAt = this.last; requestAnimationFrame((t) => this.loop(t)); }
this.updateStatus();
}
doFlash() { this.flash = 1; if (!this.running) this.render(0); }
tap() {
const now = performance.now();
if (this.taps.length && now - this.taps[this.taps.length - 1] > 2500) this.taps = [];
this.taps.push(now);
if (this.taps.length > 8) this.taps.shift();
if (this.taps.length >= 2) {
const span = this.taps[this.taps.length - 1] - this.taps[0];
this.bpm = Math.round(Math.max(40, Math.min(240, 60000 / (span / (this.taps.length - 1)))));
}
this.beatAt = now;
this.beatIndex++;
this.updateStatus();
}
async toggleMic() {
const btn = document.getElementById('micBtn');
if (this.mic) {
try { this.mic.stream.getTracks().forEach(t => t.stop()); this.mic.context.close(); } catch (e) {}
this.mic = null; this.micLevel = 0;
btn.classList.remove('on');
this.updateStatus();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
const context = new (window.AudioContext || window.webkitAudioContext)();
const source = context.createMediaStreamSource(stream);
const analyser = context.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);
this.mic = { stream, context, analyser, data: new Uint8Array(analyser.frequencyBinCount), avg: 0 };
btn.classList.add('on');
} catch (e) {
this.mic = null;
btn.classList.remove('on');
btn.textContent = '\u{1F3A4} Mic unavailable';
setTimeout(() => { btn.innerHTML = '&#127908; Mic'; }, 2000);
}
this.updateStatus();
}
readMic(now) {
if (!this.mic) { this.micLevel = 0; return; }
this.mic.analyser.getByteTimeDomainData(this.mic.data);
let e = 0;
for (let i = 0; i < this.mic.data.length; i++) { const v = (this.mic.data[i] - 128) / 128; e += v * v; }
const rms = Math.sqrt(e / this.mic.data.length);
this.mic.avg = this.mic.avg * 0.95 + rms * 0.05;
this.micLevel = Math.min(1, rms * this.micGain);
if (rms > this.mic.avg * 1.6 && rms > 0.03 && now - this.beatAt > 60000 / this.bpm * 0.5) {
this.beatAt = now; this.beatIndex++;
}
}
snapshot() {
try {
const a = document.createElement('a');
a.download = 'visualdj-' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
a.href = this.canvas.toDataURL('image/png');
a.click();
} catch (e) {}
}
fullscreen() {
if (document.fullscreenElement) { document.exitFullscreen(); return; }
if (this.stage.requestFullscreen) this.stage.requestFullscreen().catch(() => {});
}
beatValue(now) {
const period = 60000 / this.bpm;
while (now - this.beatAt >= period) { this.beatAt += period; this.beatIndex++; }
const phase = (now - this.beatAt) / period;
const envelope = Math.pow(1 - phase, 3);
return Math.min(1, envelope * this.pulse + this.micLevel * 0.8);
}
loop(now) {
if (!this.running) return;
const dt = Math.min(0.1, (now - this.last) / 1000);
this.last = now;
this.time += dt;
this.frames++;
if (now - this.fpsAt > 500) { this.fps = Math.round(this.frames * 1000 / (now - this.fpsAt)); this.frames = 0; this.fpsAt = now; this.updateStatus(); }
this.readMic(now);
if (this.automix > 0) {
this.crossfade += (dt / this.automix) * this.automixDir;
if (this.crossfade >= 1) { this.crossfade = 1; this.automixDir = -1; }
if (this.crossfade <= 0) { this.crossfade = 0; this.automixDir = 1; }
this.updateControls();
}
this.render(dt);
requestAnimationFrame((t) => this.loop(t));
}
render(dt) {
const now = performance.now();
const beat = this.running ? this.beatValue(now) : 0;
const w = this.canvas.width, h = this.canvas.height;
const ctx = this.ctx;
this.deckA.step(dt, beat); this.deckB.step(dt, beat);
this.deckA.resize(w, h); this.deckB.resize(w, h);
this.deckA.render(w, h, beat, this.beatIndex);
this.deckB.render(w, h, beat, this.beatIndex);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1 - this.trails;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, w, h);
const amt = this.fxAmount;
let sx = 0, sy = 0;
if (this.fx.has('shake') && this.running) { const m = beat * amt * 30; sx = (Math.random() - 0.5) * m; sy = (Math.random() - 0.5) * m; }
if (this.fx.has('cycle')) ctx.filter = 'hue-rotate(' + Math.round((this.time * 40 * (0.5 + amt)) % 360) + 'deg)';
ctx.globalAlpha = (1 - this.crossfade) * this.brightness;
if (ctx.globalAlpha > 0.005) ctx.drawImage(this.deckA.canvas, sx, sy);
ctx.globalAlpha = this.crossfade * this.brightness;
ctx.globalCompositeOperation = this.blend;
if (ctx.globalAlpha > 0.005) ctx.drawImage(this.deckB.canvas, -sx, -sy);
ctx.filter = 'none';
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
this.applyEffects(w, h, beat);
ctx.globalCompositeOperation = 'source-over';
const strobeOn = this.strobe > 0 && beat > 1 - this.strobe * 0.6;
const white = Math.max(this.flash, strobeOn ? 0.6 : 0);
if (white > 0.01) {
ctx.globalAlpha = Math.min(1, white);
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, w, h);
}
this.flash = Math.max(0, this.flash - dt * 4);
ctx.globalAlpha = 1;
}
snap(w, h) {
if (this.fxCanvas.width !== w || this.fxCanvas.height !== h) { this.fxCanvas.width = w; this.fxCanvas.height = h; }
this.fxCtx.globalCompositeOperation = 'source-over';
this.fxCtx.clearRect(0, 0, w, h);
this.fxCtx.drawImage(this.canvas, 0, 0);
}
applyEffects(w, h, beat) {
const ctx = this.ctx, amt = this.fxAmount;
if (this.fx.has('pixelate')) {
const size = 3 + amt * 25;
const pw = Math.max(1, Math.round(w / size)), ph = Math.max(1, Math.round(h / size));
if (this.pixCanvas.width !== pw || this.pixCanvas.height !== ph) { this.pixCanvas.width = pw; this.pixCanvas.height = ph; }
this.pixCtx.imageSmoothingEnabled = true;
this.pixCtx.drawImage(this.canvas, 0, 0, pw, ph);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(this.pixCanvas, 0, 0, pw, ph, 0, 0, w, h);
ctx.imageSmoothingEnabled = true;
}
if (this.fx.has('mirror')) {
this.snap(w, h);
ctx.save(); ctx.translate(w, 0); ctx.scale(-1, 1);
ctx.drawImage(this.fxCanvas, 0, 0, w / 2, h, 0, 0, w / 2, h);
ctx.restore();
}
if (this.fx.has('quad')) {
this.snap(w, h);
ctx.save(); ctx.translate(0, h); ctx.scale(1, -1);
ctx.drawImage(this.fxCanvas, 0, 0, w, h / 2, 0, 0, w, h / 2);
ctx.restore();
}
if (this.fx.has('rgb')) {
this.snap(w, h);
const off = (2 + amt * 18) * (0.6 + beat * 0.6);
if (this.chanCanvas.width !== w || this.chanCanvas.height !== h) { this.chanCanvas.width = w; this.chanCanvas.height = h; }
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, w, h);
for (const [color, dx] of [['#f00', -off], ['#0f0', 0], ['#00f', off]]) {
const c = this.chanCtx;
c.globalCompositeOperation = 'source-over'; c.clearRect(0, 0, w, h); c.drawImage(this.fxCanvas, 0, 0);
c.globalCompositeOperation = 'multiply'; c.fillStyle = color; c.fillRect(0, 0, w, h);
c.globalCompositeOperation = 'destination-in'; c.drawImage(this.fxCanvas, 0, 0);
ctx.globalCompositeOperation = 'lighter'; ctx.drawImage(this.chanCanvas, dx, 0);
}
ctx.globalCompositeOperation = 'source-over';
}
if (this.fx.has('invert')) {
ctx.globalCompositeOperation = 'difference'; ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
}
if (this.fx.has('scanlines')) {
if (!this.scanPattern) {
const t = document.createElement('canvas'); t.width = 1; t.height = 3;
const tc = t.getContext('2d'); tc.fillStyle = 'rgba(0,0,0,0.55)'; tc.fillRect(0, 2, 1, 1);
this.scanPattern = ctx.createPattern(t, 'repeat');
}
ctx.globalAlpha = 0.4 + amt * 0.6; ctx.fillStyle = this.scanPattern; ctx.fillRect(0, 0, w, h); ctx.globalAlpha = 1;
}
if (this.fx.has('vignette')) {
const g = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * (0.5 - amt * 0.3), w / 2, h / 2, Math.hypot(w, h) / 2);
g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,' + (0.5 + amt * 0.5) + ')');
ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
}
if (this.fx.has('grain')) {
const d = this.grainImage.data;
for (let i = 0; i < d.length; i += 4) { const v = Math.random() * 255; d[i] = d[i + 1] = d[i + 2] = v; d[i + 3] = 255; }
this.grainCtx.putImageData(this.grainImage, 0, 0);
ctx.globalCompositeOperation = 'overlay'; ctx.globalAlpha = 0.2 + amt * 0.6;
ctx.imageSmoothingEnabled = false; ctx.drawImage(this.grainCanvas, 0, 0, w, h); ctx.imageSmoothingEnabled = true;
ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
}
if (this.overlayText) {
const size = Math.max(14, Math.round(Math.min(w, h) * (0.06 + this.textSize * 0.2) * (1 + beat * 0.12)));
ctx.save();
ctx.font = 'bold ' + size + 'px Consolas, monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor = hsl(this.deckA.hue + this.time * 30, 100, 60, 1); ctx.shadowBlur = 20 + beat * 30;
ctx.lineWidth = Math.max(2, size * 0.08); ctx.strokeStyle = 'rgba(0,0,0,0.85)';
ctx.strokeText(this.overlayText, w / 2, h / 2);
ctx.fillStyle = hsl(this.deckA.hue + this.time * 30, 100, 85, 1);
ctx.fillText(this.overlayText, w / 2, h / 2);
ctx.restore();
}
}
updateControls() {
const set = (id, v, d = 2) => { const el = document.getElementById(id); if (el) el.textContent = typeof v === 'number' ? v.toFixed(d) : v; };
for (const [s, deck] of [['A', this.deckA], ['B', this.deckB]]) {
set('speed' + s + 'Display', deck.speed); set('intensity' + s + 'Display', deck.intensity);
set('hue' + s + 'Display', deck.hue, 0); set('zoom' + s + 'Display', deck.zoom); set('rot' + s + 'Display', deck.rot);
}
set('crossfadeDisplay', Math.round(this.crossfade * 100) + '%');
document.getElementById('crossfade').value = this.crossfade;
set('automixDisplay', this.automix > 0 ? this.automix + ' s' : 'Off');
set('trailsDisplay', this.trails); set('brightnessDisplay', this.brightness); set('strobeDisplay', this.strobe);
set('pulseDisplay', this.pulse); set('micGainDisplay', this.micGain, 1);
set('fxAmountDisplay', this.fxAmount); set('textSizeDisplay', this.textSize);
this.updateStatus();
}
updateStatus() {
const name = (id) => (PROGRAMS.find(p => p.id === id) || {}).name || id;
const blendName = document.getElementById('blend').selectedOptions[0].textContent;
document.getElementById('status').textContent = 'Status: ' + (this.running ? 'Playing' : 'Paused') + ' | FPS: ' + (this.running ? this.fps : '--') + ' | BPM: ' + this.bpm + ' | Time: ' + this.time.toFixed(1) + ' s';
document.getElementById('mixStatus').textContent = 'Deck A: ' + name(this.deckA.program) + ' | Deck B: ' + name(this.deckB.program) + ' | Mix: ' + Math.round(this.crossfade * 100) + '% A/B | Blend: ' + blendName + ' | Mic: ' + (this.mic ? 'On' : 'Off');
}
}
const dj = new VisualDJ();
</script>
</body>
</html>

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="6" fill="#0a0a0a" stroke="#1a1a1a" stroke-width="1"/>
<circle cx="110" cy="100" r="62" fill="#1a0f17" stroke="#ff4ad6" stroke-width="2"/>
<circle cx="110" cy="100" r="46" fill="none" stroke="#ff4ad6" stroke-width="1" opacity="0.5"/>
<circle cx="110" cy="100" r="30" fill="none" stroke="#ff4ad6" stroke-width="1" opacity="0.35"/>
<circle cx="110" cy="100" r="8" fill="#ff4ad6"/>
<path d="M 60 100 Q 72 70 85 100 Q 98 130 110 100 Q 122 70 135 100 Q 148 130 160 100" stroke="#ff9500" stroke-width="2" fill="none" opacity="0.9"/>
<circle cx="290" cy="100" r="62" fill="#0f1a17" stroke="#4affd6" stroke-width="2"/>
<polygon points="290,46 337,73 337,127 290,154 243,127 243,73" fill="none" stroke="#4affd6" stroke-width="1" opacity="0.5"/>
<polygon points="290,66 319,83 319,117 290,134 261,117 261,83" fill="none" stroke="#4affd6" stroke-width="1" opacity="0.35"/>
<circle cx="290" cy="100" r="8" fill="#4affd6"/>
<path d="M 240 100 L 255 80 L 270 115 L 285 70 L 300 125 L 315 85 L 330 110 L 340 100" stroke="#ff9500" stroke-width="2" fill="none" opacity="0.9"/>
<rect x="150" y="176" width="100" height="6" rx="3" fill="#333"/>
<rect x="150" y="176" width="55" height="6" rx="3" fill="#ff9500"/>
<rect x="199" y="170" width="12" height="18" rx="2" fill="#fff"/>
<text x="200" y="213" font-family="'Courier New', monospace" font-size="13" fill="#ff9500" text-anchor="middle" font-weight="bold" letter-spacing="3">VISUALDJ</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

80
src/maps/map_data.js Normal file
View file

@ -0,0 +1,80 @@
const fs = require("fs");
const path = require("path");
const DATA_DIR = path.join(__dirname, "data");
const loaded = new Map();
const load = (name) => {
if (loaded.has(name)) return loaded.get(name);
let data = { f: [] };
try { data = JSON.parse(fs.readFileSync(path.join(DATA_DIR, `${name}.json`), "utf8")); } catch (_) {}
loaded.set(name, data);
return data;
};
const countries = (detail) => load(detail === "high" ? "countries_50m" : "countries_110m").f;
const coast = () => load("coast_50m").f;
const lakes = () => load("lakes_50m").f;
const rivers = () => load("rivers_50m").f;
const states = () => load("states_50m").f;
const places = (detail) => load(detail === "high" ? "places_50m" : "places_110m").f;
const fold = (s) => String(s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
let placeIndex = null;
const buildPlaceIndex = () => {
if (placeIndex) return placeIndex;
const list = [];
for (const p of places("high")) {
list.push({ kind: "place", name: p.n, country: p.a, iso: p.i, pop: p.p || 0, capital: !!p.k, lat: p.la, lng: p.lo, key: fold(p.n) });
}
for (const c of countries("low")) {
list.push({ kind: "country", name: c.n, country: "", iso: c.c, pop: Number.MAX_SAFE_INTEGER, capital: false, lat: c.ly, lng: c.lx, bbox: c.b, key: fold(c.n) });
}
for (const c of countries("high")) {
if (list.some((e) => e.kind === "country" && e.key === fold(c.n))) continue;
list.push({ kind: "country", name: c.n, country: "", iso: c.c, pop: Number.MAX_SAFE_INTEGER, capital: false, lat: c.ly, lng: c.lx, bbox: c.b, key: fold(c.n) });
}
placeIndex = list;
return list;
};
const searchPlaces = (query, limit = 5) => {
const q = fold(query);
if (!q) return [];
const index = buildPlaceIndex();
const scored = [];
for (const e of index) {
let score = 0;
if (e.key === q) score = 3;
else if (e.key.startsWith(q)) score = 2;
else if (e.key.includes(q)) score = 1;
else if (e.iso && fold(e.iso) === q) score = 2;
if (!score) continue;
scored.push({ e, score });
}
scored.sort((a, b) => b.score - a.score || b.e.pop - a.e.pop || a.e.key.localeCompare(b.e.key));
return scored.slice(0, limit).map(({ e }) => ({ ...e, key: undefined }));
};
const toRad = (d) => d * Math.PI / 180;
const distanceKm = (lat1, lng1, lat2, lng2) => {
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const nearestPlace = (lat, lng, maxKm = 300) => {
if (typeof lat !== "number" || typeof lng !== "number" || !isFinite(lat) || !isFinite(lng)) return null;
let best = null;
for (const p of places("high")) {
if (Math.abs(p.la - lat) > 4) continue;
const d = distanceKm(lat, lng, p.la, p.lo);
if (d > maxKm) continue;
if (!best || d < best.km) best = { name: p.n, country: p.a, iso: p.i, km: Math.round(d), lat: p.la, lng: p.lo };
}
return best;
};
module.exports = { load, countries, coast, lakes, rivers, states, places, searchPlaces, nearestPlace, distanceKm, fold };

View file

@ -1,238 +1,608 @@
const { execFileSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const data = require("./map_data");
const BASE_MAP = path.join(__dirname, "..", "client", "assets", "images", "worldmap-z2.png");
const CACHE_DIR = path.join(__dirname, "cache");
const TILES_DIR = path.join(__dirname, "tiles");
const MAP_W = 1024;
const MAP_H = 1024;
const MAP_H = 640;
const TILE = 256;
const MIN_ZOOM = 2;
const MAX_ZOOM = 9;
const TILE_MAX_ZOOM = 6;
const TILE_PATH = "/maptiles";
const TILE_ENV = process.env.OASIS_MAP_TILES || "";
const CRISP_ZOOM = 9;
const MAX_LAT = 85.0511;
const GRID_COLS = 24;
const GRID_ROWS = 15;
const CLUSTER_PX = 28;
const EDGE = 6;
const POPUP_W = 320;
const POPUP_H = 400;
const POPUP_GAP = 10;
const latLngToPx = (lat, lng) => {
const latRad = lat * Math.PI / 180;
const x = Math.round((lng + 180) / 360 * MAP_W);
const y = Math.round((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * MAP_H);
return { x: Math.max(12, Math.min(MAP_W - 12, x)), y: Math.max(12, Math.min(MAP_H - 12, y)) };
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const num = (v) => { const n = typeof v === "number" ? v : parseFloat(v); return isFinite(n) ? n : null; };
const r1 = (v) => Math.round(v * 10) / 10;
const r4 = (v) => Math.round(v * 10000) / 10000;
const esc = (s) => String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const pxToLatLng = (px, py) => {
const lng = px / MAP_W * 360 - 180;
const n = Math.PI - 2 * Math.PI * py / MAP_H;
const lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
return { lat: Math.round(lat * 100) / 100, lng: Math.round(lng * 100) / 100 };
};
const getMaxTileZoom = () => {
try {
const dirs = fs.readdirSync(TILES_DIR).filter(d => /^\d+$/.test(d) && fs.existsSync(path.join(TILES_DIR, d, '0_0.png')));
return dirs.length ? Math.max(...dirs.map(Number)) : 0;
} catch (_) { return 0; }
};
const getViewportBounds = (centerLat, centerLng, zoom) => {
const effectiveZ = Math.min(zoom, getMaxTileZoom());
const n = Math.pow(2, effectiveZ);
const tileSize = 256;
const worldPx = n * tileSize;
const scale = Math.pow(2, zoom - effectiveZ);
const vw = MAP_W / scale;
const vh = MAP_H / scale;
const latRad = centerLat * Math.PI / 180;
const cx = (centerLng + 180) / 360 * worldPx;
const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
const x0 = cx - vw / 2;
const y0 = cy - vh / 2;
const x1 = cx + vw / 2;
const y1 = cy + vh / 2;
const pxToLng = (px) => px / worldPx * 360 - 180;
const pxToLat = (py) => { const nn = Math.PI - 2 * Math.PI * py / worldPx; return 180 / Math.PI * Math.atan(0.5 * (Math.exp(nn) - Math.exp(-nn))); };
const worldPx = (zoom) => TILE * Math.pow(2, zoom);
const project = (lat, lng, zoom) => {
const w = worldPx(zoom);
const la = clamp(lat, -MAX_LAT, MAX_LAT) * Math.PI / 180;
return {
latMin: Math.max(-85, pxToLat(Math.min(y1, worldPx - 1))),
latMax: Math.min(85, pxToLat(Math.max(y0, 0))),
lngMin: Math.max(-180, pxToLng(Math.max(x0, 0))),
lngMax: Math.min(180, pxToLng(Math.min(x1, worldPx - 1)))
x: (lng + 180) / 360 * w,
y: (1 - Math.log(Math.tan(Math.PI / 4 + la / 2)) / Math.PI) / 2 * w
};
};
const renderMapWithPins = (markers, mainIdx) => {
const pins = (Array.isArray(markers) ? markers : [])
.filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
.map((m, i) => ({ ...latLngToPx(m.lat, m.lng), main: i === (mainIdx || 0) }));
const unproject = (x, y, zoom) => {
const w = worldPx(zoom);
const n = Math.PI - 2 * Math.PI * y / w;
return { lat: 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))), lng: x / w * 360 - 180 };
};
const hash = crypto.createHash("md5")
.update(pins.map((p) => `${p.x},${p.y},${p.main}`).join(";"))
.digest("hex")
.slice(0, 12);
const latLngToPx = (lat, lng) => {
const p = project(lat, lng, MIN_ZOOM);
return { x: Math.round(p.x), y: Math.round(p.y) };
};
const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
const pxToLatLng = (px, py) => {
const p = unproject(px, py, MIN_ZOOM);
return { lat: Math.round(p.lat * 100) / 100, lng: Math.round(p.lng * 100) / 100 };
};
if (fs.existsSync(outFile)) return `map_${hash}.png`;
const normalizeZoom = (z) => clamp(Math.round(num(z) == null ? MIN_ZOOM : num(z)), MIN_ZOOM, MAX_ZOOM);
const script = `
from PIL import Image, ImageDraw
import sys, json
const makeView = (centerLat, centerLng, zoom) => {
const z = normalizeZoom(zoom);
const w = worldPx(z);
const c = project(num(centerLat) == null ? 0 : num(centerLat), num(centerLng) == null ? 0 : num(centerLng), z);
const cx = w <= MAP_W ? w / 2 : clamp(c.x, MAP_W / 2, w - MAP_W / 2);
const cy = w <= MAP_H ? w / 2 : clamp(c.y, MAP_H / 2, w - MAP_H / 2);
const center = unproject(cx, cy, z);
return { zoom: z, lat: r4(center.lat), lng: r4(center.lng), x0: cx - MAP_W / 2, y0: cy - MAP_H / 2, world: w };
};
pins = json.loads(sys.argv[1])
im = Image.open(sys.argv[2]).copy()
draw = ImageDraw.Draw(im)
const toScreen = (view, lat, lng) => {
const p = project(lat, lng, view.zoom);
return { x: p.x - view.x0, y: p.y - view.y0 };
};
def draw_pin(draw, x, y, main):
sw = 7 if main else 5
sh = 34 if main else 26
head = 11 if main else 8
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
cy = y - sh
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
const fromScreen = (view, x, y) => {
const p = unproject(view.x0 + x, view.y0 + y, view.zoom);
return { lat: r4(clamp(p.lat, -MAX_LAT, MAX_LAT)), lng: r4(clamp(p.lng, -180, 180)) };
};
for p in pins:
draw_pin(draw, p['x'], p['y'], p.get('main', False))
const getViewportBounds = (centerLat, centerLng, zoom) => {
const view = makeView(centerLat, centerLng, zoom);
const nw = fromScreen(view, 0, 0);
const se = fromScreen(view, MAP_W, MAP_H);
return { latMin: se.lat, latMax: nw.lat, lngMin: nw.lng, lngMax: se.lng };
};
im.save(sys.argv[3], optimize=True)
`;
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
execFileSync("python3", [
"-c", script,
JSON.stringify(pins),
BASE_MAP,
outFile
], { timeout: 10000 });
} catch (e) {
return null;
const fitView = (points, opts = {}) => {
const pts = (Array.isArray(points) ? points : []).filter((p) => p && num(p.lat) != null && num(p.lng) != null);
const single = normalizeZoom(opts.singleZoom || 8);
if (!pts.length) return { zoom: MIN_ZOOM, lat: 10, lng: 0 };
if (pts.length === 1) return { zoom: single, lat: num(pts[0].lat), lng: num(pts[0].lng) };
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of pts) {
const q = project(num(p.lat), num(p.lng), 0);
x0 = Math.min(x0, q.x); x1 = Math.max(x1, q.x); y0 = Math.min(y0, q.y); y1 = Math.max(y1, q.y);
}
const pad = opts.pad == null ? 70 : opts.pad;
const dx = Math.max(x1 - x0, 1e-6);
const dy = Math.max(y1 - y0, 1e-6);
const z = Math.floor(Math.log2(Math.min((MAP_W - 2 * pad) / dx, (MAP_H - 2 * pad) / dy)));
const c = unproject((x0 + x1) / 2, (y0 + y1) / 2, 0);
return { zoom: clamp(z, MIN_ZOOM, Math.min(MAX_ZOOM, opts.maxZoom || MAX_ZOOM)), lat: r4(c.lat), lng: r4(c.lng) };
};
return `map_${hash}.png`;
const parseView = (raw) => {
const s = Array.isArray(raw) ? raw[raw.length - 1] : raw;
const m = /^\s*(\d{1,2})\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)\s*$/.exec(String(s || ""));
if (!m) return null;
return { zoom: normalizeZoom(m[1]), lat: clamp(parseFloat(m[2]), -MAX_LAT, MAX_LAT), lng: clamp(parseFloat(m[3]), -180, 180) };
};
const viewParam = (v) => `${normalizeZoom(v.zoom)}/${r4(v.lat)}/${r4(v.lng)}`;
const parsePick = (raw) => {
const s = Array.isArray(raw) ? raw[raw.length - 1] : raw;
const m = /^\s*(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\s*$/.exec(String(s || ""));
if (!m) return null;
const lat = parseFloat(m[1]), lng = parseFloat(m[2]);
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
return { lat: r4(lat), lng: r4(lng) };
};
const resolveView = (params = {}, pins = [], opts = {}) => {
const explicit = parseView(params.view);
if (explicit) return makeView(explicit.lat, explicit.lng, explicit.zoom);
const clat = num(Array.isArray(params.clat) ? params.clat[0] : params.clat);
const clng = num(Array.isArray(params.clng) ? params.clng[0] : params.clng);
const zoomParam = num(Array.isArray(params.zoom) ? params.zoom[0] : params.zoom);
if (clat != null && clng != null) return makeView(clat, clng, zoomParam == null ? (opts.singleZoom || 8) : zoomParam);
const fit = fitView(pins, opts);
if (zoomParam != null && zoomParam >= MIN_ZOOM) return makeView(opts.focusLat != null ? opts.focusLat : fit.lat, opts.focusLng != null ? opts.focusLng : fit.lng, zoomParam);
return makeView(fit.lat, fit.lng, fit.zoom);
};
const metersPerPx = (view) => 156543.03392 * Math.cos(view.lat * Math.PI / 180) / Math.pow(2, view.zoom);
const bboxHit = (b, vb) => b[0] <= vb[2] && b[2] >= vb[0] && b[1] <= vb[3] && b[3] >= vb[1];
const clipPolygon = (pts, x0, y0, x1, y1) => {
let out = pts;
const passes = [
(p) => p.x >= x0, (p, q) => ({ x: x0, y: p.y + (q.y - p.y) * (x0 - p.x) / (q.x - p.x) }),
(p) => p.x <= x1, (p, q) => ({ x: x1, y: p.y + (q.y - p.y) * (x1 - p.x) / (q.x - p.x) }),
(p) => p.y >= y0, (p, q) => ({ x: p.x + (q.x - p.x) * (y0 - p.y) / (q.y - p.y), y: y0 }),
(p) => p.y <= y1, (p, q) => ({ x: p.x + (q.x - p.x) * (y1 - p.y) / (q.y - p.y), y: y1 })
];
for (let i = 0; i < passes.length && out.length; i += 2) {
const inside = passes[i], cross = passes[i + 1];
const res = [];
let prev = out[out.length - 1];
for (const cur of out) {
const ci = inside(cur), pi = inside(prev);
if (ci) {
if (!pi) res.push(cross(prev, cur));
res.push(cur);
} else if (pi) {
res.push(cross(prev, cur));
}
prev = cur;
}
out = res;
}
return out;
};
const clipSegment = (a, b, x0, y0, x1, y1) => {
let t0 = 0, t1 = 1;
const dx = b.x - a.x, dy = b.y - a.y;
const checks = [[-dx, a.x - x0], [dx, x1 - a.x], [-dy, a.y - y0], [dy, y1 - a.y]];
for (const [p, q] of checks) {
if (p === 0) { if (q < 0) return null; continue; }
const r = q / p;
if (p < 0) { if (r > t1) return null; if (r > t0) t0 = r; }
else { if (r < t0) return null; if (r < t1) t1 = r; }
}
return [{ x: a.x + t0 * dx, y: a.y + t0 * dy }, { x: a.x + t1 * dx, y: a.y + t1 * dy }];
};
const simplify = (pts, tol) => {
if (pts.length <= 2) return pts;
const keep = new Uint8Array(pts.length);
keep[0] = 1; keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
const tol2 = tol * tol;
while (stack.length) {
const [a, b] = stack.pop();
if (b - a < 2) continue;
const ax = pts[a].x, ay = pts[a].y, bx = pts[b].x, by = pts[b].y;
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
let best = -1, bestD = 0;
for (let i = a + 1; i < b; i++) {
let d;
if (len2 === 0) { d = (pts[i].x - ax) ** 2 + (pts[i].y - ay) ** 2; }
else {
const t = clamp(((pts[i].x - ax) * dx + (pts[i].y - ay) * dy) / len2, 0, 1);
d = (pts[i].x - ax - t * dx) ** 2 + (pts[i].y - ay - t * dy) ** 2;
}
if (d > bestD) { bestD = d; best = i; }
}
if (best >= 0 && bestD > tol2) { keep[best] = 1; stack.push([a, best], [best, b]); }
}
const out = [];
for (let i = 0; i < pts.length; i++) if (keep[i]) out.push(pts[i]);
return out;
};
const projectFlat = (flat, view, tol) => {
const out = [];
let lx = NaN, ly = NaN;
for (let i = 0; i < flat.length; i += 2) {
const p = toScreen(view, flat[i + 1], flat[i]);
if (Math.abs(p.x - lx) + Math.abs(p.y - ly) < tol) continue;
out.push(p);
lx = p.x; ly = p.y;
}
return simplify(out, tol);
};
const pathOf = (pts, close) => {
if (pts.length < 2) return "";
let d = `M${r1(pts[0].x)} ${r1(pts[0].y)}`;
for (let i = 1; i < pts.length; i++) d += `L${r1(pts[i].x)} ${r1(pts[i].y)}`;
return close ? d + "Z" : d;
};
const polygonPath = (rings, view, tol) => {
let d = "";
for (const ring of rings) {
const pts = projectFlat(ring, view, tol);
if (pts.length < 3) continue;
const clipped = clipPolygon(pts, -EDGE, -EDGE, MAP_W + EDGE, MAP_H + EDGE);
if (clipped.length >= 3) d += pathOf(clipped, true);
}
return d;
};
const linePath = (flat, view, tol) => {
const pts = projectFlat(flat, view, tol);
let d = "";
let run = [];
const flush = () => { if (run.length >= 2) d += pathOf(run, false); run = []; };
for (let i = 1; i < pts.length; i++) {
const seg = clipSegment(pts[i - 1], pts[i], -EDGE, -EDGE, MAP_W + EDGE, MAP_H + EDGE);
if (!seg) { flush(); continue; }
if (!run.length || Math.abs(run[run.length - 1].x - seg[0].x) + Math.abs(run[run.length - 1].y - seg[0].y) > 0.05) { flush(); run.push(seg[0]); }
run.push(seg[1]);
}
flush();
return d;
};
const viewBoxLatLng = (view) => {
const nw = fromScreen(view, -EDGE, -EDGE);
const se = fromScreen(view, MAP_W + EDGE, MAP_H + EDGE);
return [nw.lng, se.lat, se.lng, nw.lat];
};
const baseCache = new Map();
const cacheGet = (key) => baseCache.get(key);
const cacheSet = (key, val) => {
if (baseCache.size >= 64) baseCache.delete(baseCache.keys().next().value);
baseCache.set(key, val);
};
const tileZoom = (zoom, thumb) => clamp(Math.floor(zoom) - (thumb ? 2 : 0), 0, TILE_MAX_ZOOM);
const tileCoverage = (view, opts = {}) => {
const tz = tileZoom(view.zoom, !!opts.thumb);
const size = TILE * Math.pow(2, view.zoom - tz);
const n = Math.pow(2, tz);
const tx0 = Math.max(0, Math.floor(view.x0 / size)), tx1 = Math.min(n - 1, Math.ceil((view.x0 + MAP_W) / size) - 1);
const ty0 = Math.max(0, Math.floor(view.y0 / size)), ty1 = Math.min(n - 1, Math.ceil((view.y0 + MAP_H) / size) - 1);
const out = [];
for (let ty = ty0; ty <= ty1; ty++) {
for (let tx = tx0; tx <= tx1; tx++) out.push({ z: tz, x: tx, y: ty, sx: tx * size - view.x0, sy: ty * size - view.y0, size });
}
return out;
};
const tileHref = (base, t) => `${base}/${t.z}/${t.x}_${t.y}.jpg`;
const tileBaseOf = (opts) => opts.tileBase || TILE_ENV || `${opts.publicBase || ""}${TILE_PATH}`;
const renderRaster = (view, opts) => {
const tiles = tileCoverage(view, { thumb: !!opts.thumb });
if (!tiles.length) return "";
const base = tileBaseOf(opts);
const clipId = `${opts.id || "map"}-clip`;
let out = `<clipPath id="${esc(clipId)}"><rect x="0" y="0" width="${MAP_W}" height="${MAP_H}"/></clipPath><g class="map-raster" clip-path="url(#${esc(clipId)})">`;
for (const t of tiles) {
out += `<image class="map-tile" href="${esc(tileHref(base, t))}" x="${r1(t.sx)}" y="${r1(t.sy)}" width="${t.size + 1}" height="${t.size + 1}" preserveAspectRatio="none"/>`;
}
return out + "</g>";
};
const graticuleStep = (zoom) => zoom <= 2 ? 30 : zoom === 3 ? 15 : zoom === 4 ? 10 : 0;
const renderGraticule = (view, vb) => {
const step = graticuleStep(view.zoom);
if (!step) return "";
let d = "";
for (let lng = Math.ceil(vb[0] / step) * step; lng <= vb[2]; lng += step) {
const x = r1(toScreen(view, 0, lng).x);
d += `M${x} 0L${x} ${MAP_H}`;
}
for (let lat = Math.ceil(vb[1] / step) * step; lat <= vb[3]; lat += step) {
if (Math.abs(lat) >= 85) continue;
const y = r1(toScreen(view, lat, 0).y);
d += `M0 ${y}L${MAP_W} ${y}`;
}
return d ? `<path class="map-grat" d="${d}"/>` : "";
};
const renderBase = (view, detail) => {
const key = `${view.zoom}|${Math.round(view.x0)}|${Math.round(view.y0)}|${detail}`;
const hit = cacheGet(key);
if (hit) return hit;
const z = view.zoom;
const tol = detail === "low" ? 4 : z <= 3 ? 1.1 : 0.7;
const vb = viewBoxLatLng(view);
const high = z >= 5;
let out = detail === "low" ? "" : renderGraticule(view, vb);
let borders = "";
for (const c of data.countries(high ? "high" : "low")) {
if (!bboxHit(c.b, vb)) continue;
for (const poly of c.p) {
const d = polygonPath(poly, view, tol);
if (d) borders += `<path class="map-border" d="${d}"/>`;
}
}
out += `<g class="map-countries">${borders}</g>`;
if (high && detail !== "low") {
let st = "";
for (const s of data.states()) {
if (s.z > z || !bboxHit(s.b, vb)) continue;
const d = linePath(s.l, view, tol);
if (d) st += `<path class="map-state" d="${d}"/>`;
}
if (st) out += `<g class="map-states">${st}</g>`;
}
if (z >= CRISP_ZOOM && detail !== "low") {
let co = "";
for (const c of data.coast()) {
if (!bboxHit(c.b, vb)) continue;
const d = linePath(c.l, view, tol);
if (d) co += `<path class="map-coast" d="${d}"/>`;
}
for (const l of data.lakes()) {
if (l.z > z || !bboxHit(l.b, vb)) continue;
for (const poly of l.p) {
const d = polygonPath(poly, view, tol);
if (d) co += `<path class="map-lake" d="${d}"/>`;
}
}
if (co) out += `<g class="map-coasts">${co}</g>`;
}
cacheSet(key, out);
return out;
};
const textWidth = (s, size) => String(s || "").length * size * 0.58;
const collides = (boxes, b) => boxes.some((o) => b.x0 < o.x1 && b.x1 > o.x0 && b.y0 < o.y1 && b.y1 > o.y0);
const renderLabels = (view, boxes) => {
const z = view.zoom;
let out = "";
if (z >= 3 && z <= 7) {
const list = data.countries(z >= 5 ? "high" : "low").filter((c) => c.ml <= z && c.n).sort((a, b) => a.r - b.r);
for (const c of list) {
const p = toScreen(view, c.ly, c.lx);
if (p.x < 20 || p.x > MAP_W - 20 || p.y < 10 || p.y > MAP_H - 10) continue;
const w = textWidth(c.n, 14);
const b = { x0: p.x - w / 2 - 4, x1: p.x + w / 2 + 4, y0: p.y - 10, y1: p.y + 6 };
if (collides(boxes, b)) continue;
boxes.push(b);
out += `<text class="map-label-country" x="${r1(p.x)}" y="${r1(p.y)}">${esc(c.n)}</text>`;
}
}
if (z >= 2) {
const list = data.places(z >= 5 ? "high" : "low").filter((p) => p.z <= z).sort((a, b) => (b.k - a.k) || (b.p - a.p));
for (const pl of list) {
const p = toScreen(view, pl.la, pl.lo);
if (p.x < 4 || p.x > MAP_W - 4 || p.y < 4 || p.y > MAP_H - 4) continue;
const w = textWidth(pl.n, 12);
const dot = { x0: p.x - 5, x1: p.x + 5, y0: p.y - 5, y1: p.y + 5 };
if (collides(boxes, dot)) continue;
const lb = { x0: p.x + 5, x1: p.x + 9 + w, y0: p.y - 8, y1: p.y + 8 };
const withLabel = !collides(boxes, lb) && lb.x1 <= MAP_W;
boxes.push(dot);
if (withLabel) boxes.push(lb);
const cls = pl.k ? "map-place-dot map-place-capital" : "map-place-dot";
out += `<circle class="${cls}" cx="${r1(p.x)}" cy="${r1(p.y)}" r="${pl.k ? 3.4 : 2.4}"/>`;
if (withLabel) out += `<text class="map-place-label${pl.k ? " map-place-label-capital" : ""}" x="${r1(p.x + 7)}" y="${r1(p.y + 4)}">${esc(pl.n)}</text>`;
}
}
return out ? `<g class="map-labels">${out}</g>` : "";
};
const niceScale = (view) => {
const mpp = metersPerPx(view);
const target = 150 * mpp;
const pow = Math.pow(10, Math.floor(Math.log10(target)));
const nice = [1, 2, 5, 10].map((n) => n * pow).filter((n) => n <= target).pop() || pow;
const px = nice / mpp;
const label = nice >= 1000 ? `${Math.round(nice / 1000)} km` : `${Math.round(nice)} m`;
return { px, label };
};
const renderScale = (view) => {
const { px, label } = niceScale(view);
const x = 14, y = MAP_H - 16;
return `<g class="map-scale"><path class="map-scale-line" d="M${x} ${y - 5}L${x} ${y}L${r1(x + px)} ${y}L${r1(x + px)} ${y - 5}"/><text class="map-scale-text" x="${x + 2}" y="${y - 8}">${esc(label)} · z${view.zoom}</text></g>`;
};
const renderAttribution = () => `<text class="map-attrib" x="${MAP_W - 8}" y="${MAP_H - 8}">Natural Earth</text>`;
const truncate = (s, n) => {
const line = String(s || "").split(/\r?\n/)[0].trim();
return line.length > n ? line.slice(0, n - 1) + "…" : line;
};
const pinPath = (s) => `M0 0C${-7 * s} ${-9 * s} ${-11 * s} ${-14 * s} ${-11 * s} ${-21 * s}A${11 * s} ${11 * s} 0 1 1 ${11 * s} ${-21 * s}C${11 * s} ${-14 * s} ${7 * s} ${-9 * s} 0 0Z`;
const clusterPins = (placed, view) => {
if (view.zoom >= MAX_ZOOM) return placed.map((p) => ({ ...p, members: [p] }));
const clusters = [];
for (const p of placed) {
const near = clusters.find((c) => Math.abs(c.x - p.x) <= CLUSTER_PX && Math.abs(c.y - p.y) <= CLUSTER_PX);
if (near) {
near.members.push(p);
near.x = near.members.reduce((s, m) => s + m.x, 0) / near.members.length;
near.y = near.members.reduce((s, m) => s + m.y, 0) / near.members.length;
} else {
clusters.push({ x: p.x, y: p.y, members: [p] });
}
}
return clusters;
};
const renderPins = (view, pins, opts, boxes, hit = {}) => {
const placed = [];
pins.forEach((m, i) => {
const lat = num(m.lat), lng = num(m.lng);
if (lat == null || lng == null) return;
const p = toScreen(view, lat, lng);
if (p.x < -20 || p.x > MAP_W + 20 || p.y < -10 || p.y > MAP_H + 50) return;
placed.push({ ...m, lat, lng, x: p.x, y: p.y, idx: i });
});
if (!placed.length) return "";
const clusters = clusterPins(placed.filter((p) => !p.pick), view);
for (const p of placed.filter((p) => p.pick)) clusters.push({ x: p.x, y: p.y, members: [p], pick: true });
clusters.sort((a, b) => a.y - b.y);
const quiet = !!opts.popup;
let out = "";
for (const c of clusters) {
const x = r1(c.x), y = r1(c.y);
if (c.members.length > 1) {
const n = c.members.length;
const center = fromScreen(view, c.x, c.y);
if (c.members.some((m) => m.focus)) hit.focus = { x: c.x, y: c.y, top: 20 };
let gap = Infinity;
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) gap = Math.min(gap, Math.max(Math.abs(c.members[i].x - c.members[j].x), Math.abs(c.members[i].y - c.members[j].y)));
const splitZoom = gap > 0 ? view.zoom + Math.ceil(Math.log2((CLUSTER_PX + 1) / gap)) : MAX_ZOOM;
const href = opts.viewHref ? opts.viewHref({ zoom: clamp(Math.max(splitZoom, view.zoom + 1), MIN_ZOOM, MAX_ZOOM), lat: center.lat, lng: center.lng }) : null;
const title = opts.clusterTitle ? `${n} ${opts.clusterTitle}` : String(n);
const glyph = `<g class="map-cluster" transform="translate(${x} ${y})"><title>${esc(title)}</title><circle class="map-cluster-ring" r="19"/><circle class="map-cluster-dot" r="14"/><text class="map-cluster-count" y="4.5">${n}</text></g>`;
out += href ? `<a href="${esc(href)}">${glyph}</a>` : glyph;
boxes.push({ x0: c.x - 20, x1: c.x + 20, y0: c.y - 20, y1: c.y + 20 });
continue;
}
const m = c.members[0];
if (m.pick) {
out += `<g class="map-pin-pick" transform="translate(${x} ${y})"><circle class="map-pick-ring" r="11"/><path class="map-pick-cross" d="M-16 0H-6M6 0H16M0 -16V-6M0 6V16"/><circle class="map-pick-center" r="2.5"/></g>`;
continue;
}
const s = m.main ? 1.25 : 1;
const cls = ["map-pin", m.main ? "map-pin-main" : "", m.focus ? "map-pin-focus" : ""].filter(Boolean).join(" ");
let g = `<g class="${cls}" transform="translate(${x} ${y})">`;
if (m.title) g += `<title>${esc(m.title)}</title>`;
g += `<path class="map-pin-body" d="${pinPath(s)}"/><circle class="map-pin-eye" cy="${-21 * s}" r="${4 * s}"/>`;
const silent = quiet && m.focus;
if (silent) hit.focus = { x: c.x, y: c.y, top: 32 * s };
const label = silent ? "" : truncate(m.label, 28);
if (label) {
const w = textWidth(label, 12) + 12;
const ly = -21 * s - 11 * s - 16;
g += `<rect class="map-pin-label-bg" x="${r1(-w / 2)}" y="${r1(ly - 12)}" width="${r1(w)}" height="17" rx="4"/><text class="map-pin-label" y="${r1(ly)}">${esc(label)}</text>`;
boxes.push({ x0: c.x - w / 2, x1: c.x + w / 2, y0: c.y + ly - 12, y1: c.y });
} else {
boxes.push({ x0: c.x - 12, x1: c.x + 12, y0: c.y - 34, y1: c.y });
}
if (m.image && !silent) {
const size = 38;
g += `<rect class="map-pin-img-frame" x="${r1(13 * s)}" y="${r1(-21 * s - size / 2 - 2)}" width="${size + 4}" height="${size + 4}" rx="4"/><image class="map-pin-img" href="${esc(m.image)}" x="${r1(13 * s + 2)}" y="${r1(-21 * s - size / 2)}" width="${size}" height="${size}" preserveAspectRatio="xMidYMid slice"/>`;
boxes.push({ x0: c.x + 13 * s, x1: c.x + 13 * s + size + 4, y0: c.y - 21 * s - size / 2 - 2, y1: c.y - 21 * s + size / 2 + 2 });
}
g += "</g>";
out += m.href ? `<a href="${esc(m.href)}">${g}</a>` : g;
}
return `<g class="map-pins">${out}</g>`;
};
const popupPlacement = (at) => {
const roomAbove = at.y - at.top - POPUP_GAP;
const roomBelow = MAP_H - at.y - POPUP_GAP;
const below = roomAbove < POPUP_H && roomBelow > roomAbove;
const h = Math.min(POPUP_H, Math.max(below ? roomBelow : roomAbove, 60));
const x = clamp(at.x - POPUP_W / 2, 4, MAP_W - POPUP_W - 4);
const y = below ? at.y + POPUP_GAP : at.y - at.top - POPUP_GAP - h;
return { x, y, w: POPUP_W, h, below, tipX: clamp(at.x, x + 14, x + POPUP_W - 14) };
};
const renderPopup = (at, html) => {
if (!at || !html) return "";
const p = popupPlacement(at);
const pointer = p.below
? `M${r1(p.tipX - 8)} ${r1(p.y)}L${r1(p.tipX)} ${r1(p.y - 8)}L${r1(p.tipX + 8)} ${r1(p.y)}Z`
: `M${r1(p.tipX - 8)} ${r1(p.y + p.h)}L${r1(p.tipX)} ${r1(p.y + p.h + 8)}L${r1(p.tipX + 8)} ${r1(p.y + p.h)}Z`;
return `<g class="map-popup${p.below ? " map-popup--below" : ""}"><path class="map-popup-pointer" d="${pointer}"/>` +
`<foreignObject x="${r1(p.x)}" y="${r1(p.y)}" width="${p.w}" height="${r1(p.h)}">` +
`<div xmlns="http://www.w3.org/1999/xhtml" class="map-popup-wrap"><div class="map-popup-card">${html}</div></div></foreignObject></g>`;
};
const renderGrid = (view, opts) => {
const pick = opts.pick;
const legacy = opts.clickUrl;
if (!pick && !legacy) return "";
const cells = [];
for (let gy = 0; gy < GRID_ROWS; gy++) {
for (let gx = 0; gx < GRID_COLS; gx++) {
const c = fromScreen(view, (gx + 0.5) / GRID_COLS * MAP_W, (gy + 0.5) / GRID_ROWS * MAP_H);
const val = `${c.lat},${c.lng}`;
if (pick) {
cells.push(`<button form="${esc(pick.form)}" formmethod="GET" formaction="${esc(pick.action)}" name="${esc(pick.name || "pick")}" value="${val}" class="map-pick-cell" title="${val}"></button>`);
} else {
const latParam = opts.latParam || "lat", lngParam = opts.lngParam || "lng";
cells.push(`<a href="${esc(`${legacy}${latParam}=${c.lat}&${lngParam}=${c.lng}${opts.anchor || ""}`)}" class="map-pick-cell" title="${val}"></a>`);
}
}
}
return `<div class="map-pick-grid">${cells.join("")}</div>`;
};
const renderControls = (view, opts) => {
const nav = opts.nav;
if (!nav) return "";
const t = opts.text || {};
const el = (target, label, cls, title) => {
const value = target ? viewParam(target) : "";
if (nav.form) {
return `<button form="${esc(nav.form)}" formmethod="GET" formaction="${esc(nav.action)}" name="view" value="${value}" class="map-ctrl ${cls}" title="${esc(title)}">${label}</button>`;
}
return `<a href="${esc(nav.href(target))}" class="map-ctrl ${cls}" title="${esc(title)}">${label}</a>`;
};
const pan = (dx, dy) => {
const c = fromScreen(view, MAP_W / 2 + dx * MAP_W * 0.4, MAP_H / 2 + dy * MAP_H * 0.4);
return { zoom: view.zoom, lat: c.lat, lng: c.lng };
};
const zoomIn = view.zoom < MAX_ZOOM ? el({ zoom: view.zoom + 1, lat: view.lat, lng: view.lng }, "+", "map-ctrl-in", t.zoomIn || "+") : `<span class="map-ctrl map-ctrl-off">+</span>`;
const zoomOut = view.zoom > MIN_ZOOM ? el({ zoom: view.zoom - 1, lat: view.lat, lng: view.lng }, "−", "map-ctrl-out", t.zoomOut || "−") : `<span class="map-ctrl map-ctrl-off">−</span>`;
const fit = el(null, "⌖", "map-ctrl-fit", t.fit || "⌖");
return `<div class="map-controls map-controls-zoom">${zoomIn}${zoomOut}</div>` +
`<div class="map-controls map-controls-pan">` +
`<span></span>${el(pan(0, -1), "↑", "map-ctrl-up", t.pan || "↑")}<span></span>` +
`${el(pan(-1, 0), "←", "map-ctrl-left", t.pan || "←")}${fit}${el(pan(1, 0), "→", "map-ctrl-right", t.pan || "→")}` +
`<span></span>${el(pan(0, 1), "↓", "map-ctrl-down", t.pan || "↓")}<span></span>` +
`</div>`;
};
const renderMapHtml = (view, pins, opts = {}) => {
const detail = opts.thumb ? "low" : "normal";
const boxes = [];
const pinList = Array.isArray(pins) ? pins : [];
const grid = renderGrid(view, opts);
const hit = {};
const pinsSvg = renderPins(view, pinList, opts, boxes, hit) + renderPopup(hit.focus, opts.popup);
const labels = opts.thumb ? "" : renderLabels(view, boxes);
const extras = opts.thumb ? "" : renderScale(view) + renderAttribution();
const base = `<rect class="map-ocean" x="0" y="0" width="${MAP_W}" height="${MAP_H}"/>` + renderRaster(view, opts) + renderBase(view, detail);
const svgOpen = `<svg class="map-svg" viewBox="0 0 ${MAP_W} ${MAP_H}" xmlns="http://www.w3.org/2000/svg" role="img">`;
let html = `<div class="map-stage${opts.thumb ? " map-stage-thumb" : ""}" id="${esc(opts.id || "map")}">`;
if (grid || opts.nav) {
html += svgOpen + base + labels + extras + "</svg>";
html += grid;
html += `<svg class="map-svg map-pins-layer" viewBox="0 0 ${MAP_W} ${MAP_H}" xmlns="http://www.w3.org/2000/svg">${pinsSvg}</svg>`;
} else {
html += svgOpen + base + labels + pinsSvg + extras + "</svg>";
}
html += renderControls(view, opts);
html += "</div>";
return html;
};
const renderMapWithPins = (markers, mainIdx) => {
const pins = (Array.isArray(markers) ? markers : []).map((m, i) => ({ ...m, main: i === (mainIdx || 0) }));
const fit = fitView(pins);
return renderMapHtml(makeView(fit.lat, fit.lng, fit.zoom), pins, {});
};
const renderZoomedMapWithPins = (centerLat, centerLng, zoom, markers, mainIdx) => {
const maxZ = getMaxTileZoom();
if (!maxZ || zoom <= 2) return renderMapWithPins(markers, mainIdx);
const effectiveZ = Math.min(zoom, maxZ);
const scale = Math.pow(2, zoom - effectiveZ);
const n = Math.pow(2, effectiveZ);
const tileSize = 256;
const worldPx = n * tileSize;
const latRad = centerLat * Math.PI / 180;
const cx = (centerLng + 180) / 360 * worldPx;
const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
const vw = MAP_W / scale;
const vh = MAP_H / scale;
const x0 = cx - vw / 2;
const y0 = cy - vh / 2;
const pinData = (Array.isArray(markers) ? markers : [])
.filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
.map((m, i) => {
const latR = m.lat * Math.PI / 180;
const wx = (m.lng + 180) / 360 * worldPx;
const wy = (1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * worldPx;
return { px: (wx - x0) * scale, py: (wy - y0) * scale, main: i === (mainIdx || 0) };
});
const hashInput = `z${zoom}_${Math.round(centerLat * 100)}_${Math.round(centerLng * 100)}_` + pinData.map(p => `${Math.round(p.px)},${Math.round(p.py)},${p.main}`).join(";");
const hash = crypto.createHash("md5").update(hashInput).digest("hex").slice(0, 12);
const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
if (fs.existsSync(outFile)) return `map_${hash}.png`;
const txMin = Math.max(0, Math.floor(x0 / tileSize));
const txMax = Math.min(n - 1, Math.floor((x0 + vw) / tileSize));
const tyMin = Math.max(0, Math.floor(y0 / tileSize));
const tyMax = Math.min(n - 1, Math.floor((y0 + vh) / tileSize));
const tiles = [];
for (let tx = txMin; tx <= txMax; tx++) {
for (let ty = tyMin; ty <= tyMax; ty++) {
const tp = path.join(TILES_DIR, String(effectiveZ), `${tx}_${ty}.png`);
tiles.push({ tx, ty, tp, exists: fs.existsSync(tp) });
}
}
const script = `
from PIL import Image, ImageDraw
import sys, json, os
args = json.loads(sys.argv[1])
out_file = sys.argv[2]
tile_size = 256
tx_min = args['txMin']
ty_min = args['tyMin']
tx_max = args['txMax']
ty_max = args['tyMax']
x0 = args['x0']
y0 = args['y0']
vw = args['vw']
vh = args['vh']
scale = args['scale']
pins = args['pins']
tiles = args['tiles']
canvas_w = (tx_max - tx_min + 1) * tile_size
canvas_h = (ty_max - ty_min + 1) * tile_size
canvas = Image.new('RGB', (canvas_w, canvas_h), (170, 211, 223))
for t in tiles:
if t['exists']:
try:
tile = Image.open(t['tp']).convert('RGB')
canvas.paste(tile, ((t['tx'] - tx_min) * tile_size, (t['ty'] - ty_min) * tile_size))
except:
pass
crop_x = x0 - tx_min * tile_size
crop_y = y0 - ty_min * tile_size
cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y + vh)))
result = cropped.resize((1024, 1024), Image.LANCZOS)
draw = ImageDraw.Draw(result)
def draw_pin(draw, x, y, main):
sw = 7 if main else 5
sh = 34 if main else 26
head = 11 if main else 8
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
cy = y - sh
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
for p in pins:
px, py, main = p['px'], p['py'], p.get('main', False)
if -40 <= px <= 1064 and -40 <= py <= 1064:
draw_pin(draw, px, py, main)
result.save(out_file, optimize=True)
`;
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
execFileSync("python3", [
"-c", script,
JSON.stringify({ txMin, tyMin, txMax, tyMax, x0, y0, vw, vh, scale, pins: pinData, tiles }),
outFile
], { timeout: 15000 });
} catch (e) {
return renderMapWithPins(markers, mainIdx);
}
return `map_${hash}.png`;
const pins = (Array.isArray(markers) ? markers : []).map((m, i) => ({ ...m, main: i === (mainIdx || 0) }));
return renderMapHtml(makeView(centerLat, centerLng, zoom), pins, {});
};
module.exports = { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, getMaxTileZoom, latLngToPx, pxToLatLng, MAP_W, MAP_H };
module.exports = {
MAP_W, MAP_H, MIN_ZOOM, MAX_ZOOM, TILE_MAX_ZOOM, TILE_PATH,
project, unproject, latLngToPx, pxToLatLng, makeView, toScreen, fromScreen, fitView, resolveView,
parseView, viewParam, parsePick, getViewportBounds, metersPerPx, clusterPins, niceScale, tileCoverage, popupPlacement,
renderMapHtml, renderMapWithPins, renderZoomedMapWithPins
};

View file

@ -1,8 +1,10 @@
const pull = require('../server/node_modules/pull-stream');
const longText = require('../backend/long_text');
const ssbRef = require('../server/node_modules/ssb-ref');
const { getConfig } = require('../configs/config-manager.js');
const { buildVoteTally } = require('../backend/vote_tally');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { versionGroups, versionEntry } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeFeedId = (v) => {
@ -57,6 +59,8 @@ function inferType(c = {}) {
}
const HIDDEN_ENVELOPE_TYPES = new Set([
'textChunk',
'aiExchange',
'tribe-keys-distrib',
'tribe-keys',
'tribe-invite-msg',
@ -67,7 +71,6 @@ const HIDDEN_ENVELOPE_TYPES = new Set([
'larpJoinHouse',
'larpLeaveLarp',
'larpTestAttempt',
'larpHousePost',
'larpHouseInvite',
'larpHouseInviteRedeem',
'larpHouseTribeAnchor',
@ -75,19 +78,29 @@ const HIDDEN_ENVELOPE_TYPES = new Set([
'larpAutoInvite',
'karmaScore',
'pubAvailability',
'pam',
'calendarReminderSent',
'taskReminderSent',
'ubiClaim',
'ubiClaimResult',
'ubiRefuse',
'ubiAllocation',
'bankClaim',
'courts-key'
]);
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }) => {
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel, larpModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
let _feedCache = null;
let _feedCacheInflight = null;
const logTip = async () => {
try {
const ssbClient = await openSsb();
return await require('./typed_log').logTip(ssbClient);
} catch (_) { return null; }
};
const FEED_CACHE_MS = 15 * 1000;
const buildAccessibleTribeIds = async () => {
@ -95,14 +108,14 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (!tribesModel) return set;
try {
const list = await tribesModel.listAll();
for (const t of list) {
if (!t || !t.id) continue;
await Promise.all(list.map(async (t) => {
if (!t || !t.id) return;
set.add(t.id);
try {
const chain = await tribesModel.getChainIds(t.id);
for (const cid of chain) set.add(cid);
} catch (_) {}
}
}));
} catch (_) {}
return set;
};
@ -143,9 +156,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
// Builds self-contained chatThread items (title + recent messages embedded) for
// general (non-tribe) OPEN chats that have activity. Emitted as one item per chat
// with ts = latest message, so it survives the recent/all/mine filters intact.
const CHAT_THREAD_LIMIT = 6;
const buildChatThreads = async (ssbClient, idToAction, rootOf) => {
const replacedChatIds = new Set();
@ -160,28 +170,31 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const root = rootOf(a.id);
const isTip = !replacedChatIds.has(a.id);
const prev = stateByRoot.get(root);
// Resolve the chain tip by replaces (a non-replaced action wins), breaking ts
// ties correctly — the mock and real SSB can stamp equal timestamps.
if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
const cc = a.content || {};
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
stateByRoot.set(root, { ts: a.ts || 0, isTip, owner: cc.author || a.author || null, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
}
const msgsByRoot = new Map();
for (const a of idToAction.values()) {
if (a.type !== 'chatMessage') continue;
const isPoll = a.type === 'poll';
if (a.type !== 'chatMessage' && !isPoll) continue;
const c = a.content || {};
if (c.tribeId || c.encryptedText) continue;
if (c.tribeId || c.encryptedText || c.encryptedQuestion) continue;
const root = c.chatId;
if (!root || typeof c.text !== 'string' || !c.text) continue;
if (!root) continue;
if (isPoll) {
if (!(typeof c.question === 'string' && c.question.trim())) continue;
} else if (!(typeof c.text === 'string' && c.text) && !c.image) continue;
if (!msgsByRoot.has(root)) msgsByRoot.set(root, []);
msgsByRoot.get(root).push(a);
msgsByRoot.get(root).push(isPoll ? { ...a, content: { chatId: root, text: c.question.trim(), poll: true } } : a);
}
for (const root of msgsByRoot.keys()) {
if (stateByRoot.has(root)) continue;
const val = await getMsg(ssbClient, root);
const missingRoots = [...msgsByRoot.keys()].filter(root => !stateByRoot.has(root));
const rootMsgs = new Map(await Promise.all(missingRoots.map(async root => [root, await getMsg(ssbClient, root)])));
for (const root of missingRoots) {
const val = rootMsgs.get(root);
const cc = val && val.content;
if (cc && typeof cc === 'object' && cc.type === 'chat') {
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
stateByRoot.set(root, { ts: 0, owner: cc.author || val.author || null, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
} else {
stateByRoot.set(root, null);
}
@ -200,11 +213,29 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
content: {
type: 'chatThread',
chatRoot: root,
chatAuthor: info.owner || null,
title: info.title || '',
description: info.description || '',
members: info.members || 0,
members: Math.max(info.members || 0, new Set(asc.map(m => m.author)).size),
messageCount: asc.length,
replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
replies: (() => {
const byId = new Map(asc.map(m => [m.id, m]));
return asc.slice(-CHAT_THREAD_LIMIT).map(m => {
const quotedId = (m.content && typeof m.content.replyTo === 'string') ? m.content.replyTo : null;
const quoted = quotedId ? byId.get(quotedId) : null;
return {
id: m.id,
author: m.author,
ts: m.ts || 0,
text: (m.content && m.content.text) || '',
poll: !!(m.content && m.content.poll),
image: (m.content && m.content.image) || null,
mimeType: (m.content && m.content.mimeType) || '',
replyTo: quotedId,
reply: quoted ? { id: quoted.id, author: quoted.author, text: String((quoted.content && quoted.content.text) || '').slice(0, 140) } : null
};
});
})()
}
});
}
@ -224,16 +255,18 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (entry && now - entry.ts < FEED_CACHE_MS) return entry.value;
if (!_feedCacheInflight) _feedCacheInflight = new Map();
if (_feedCacheInflight.has(cacheKey)) return _feedCacheInflight.get(cacheKey);
const tipNow = await logTip();
if (entry && tipNow && entry.tip === tipNow) { entry.ts = now; return entry.value; }
const promise = (async () => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const results = await new Promise((resolve, reject) => {
const results = (await new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ reverse: true, limit: logLimit }),
ssbClient.createLogStream({}),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
})).reverse();
if (typeof ssbClient.createUserStream === 'function') {
const ownResults = await new Promise((resolve) => {
@ -249,6 +282,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}
const tombstoned = buildValidatedTombstoneSet(results);
const chunkLookup = longText.lookupIn(longText.indexChunks(results));
const parentOf = new Map();
const idToAction = new Map();
const rawById = new Map();
@ -270,6 +304,20 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
const accessibleTribeIds = await buildAccessibleTribeIds();
let larpWall = null;
if (larpModel && results.some(m => m && m.value && m.value.content && m.value.content.type === 'larpHousePost')) {
try {
const s = await openSsb();
larpWall = {
mine: await larpModel.getUserHouse(s.id).catch(() => null),
governing: typeof larpModel.getGoverningHouseKey === 'function' ? larpModel.getGoverningHouseKey() : null,
members: await larpModel.listAllMemberships().catch(() => new Map())
};
} catch (_) { larpWall = null; }
}
const larpPostVisible = (author, house) => !!larpWall && !!house
&& (larpWall.members.get(author) || 'academia') === house
&& (house === 'academia' || house === larpWall.governing || house === larpWall.mine);
for (const msg of results) {
const k = msg.key;
@ -277,7 +325,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
let c = v?.content;
if (!c) continue;
if (typeof c === 'string' && c.endsWith('.box')) continue;
if (c.private === true) continue;
if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
if (c.type === 'larpHousePost' && !larpPostVisible(v.author, String(c.house || '').toLowerCase())) continue;
if (typeof c.type === 'string' && /e2ee/i.test(c.type)) continue;
if (c.type === 'contact') continue;
if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
@ -307,6 +357,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
} catch (_) {}
}
if (!isContentSane(c)) continue;
if ((c.type === 'post' || c.type === 'forum') && longText.hasChunks(c)) c = longText.resolveField(c, 'text', v?.author, chunkLookup);
if (c.type === 'about' && !c.name && !c.description && !c.image) continue;
const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0;
idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c });
rawById.set(k, msg);
@ -315,17 +367,23 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const forgedParentOf = new Map();
const forgedFeedContent = new Map();
const vg = versionGroups(Array.from(idToAction.values()).map(a => versionEntry({ key: a.id, value: { ...(rawById.get(a.id) || {}).value, content: a.content } })));
for (const [child, parent] of Array.from(parentOf.entries())) {
const ca = idToAction.get(child);
const pa = idToAction.get(parent);
if (!pa) { parentOf.delete(child); continue; }
if (!ca || String(ca.author) !== String(pa.author)) {
if (vg.isOrphan(child)) {
parentOf.delete(child);
if (vg.isForeignCopy(child)) { idToAction.delete(child); rawById.delete(child); }
continue;
}
if (!ca || vg.isForeign(child)) {
forgedParentOf.set(child, parent);
if (ca && ca.type === 'feed') forgedFeedContent.set(child, ca.content || {});
parentOf.delete(child);
idToAction.delete(child);
rawById.delete(child);
continue;
}
if (vg.isForeign(parent)) parentOf.set(child, vg.rootOf(child));
}
const replacedIds = new Set(parentOf.values());
@ -381,6 +439,10 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}
const fetchedTargetCache = new Map();
const missingTargets = [...new Set([...idToAction.values()].filter(a => a.type === 'spread').map(a => (a.content || {}).vote?.link || '').filter(link => link && !rawById.get(link)))];
for (const [link, got] of await Promise.all(missingTargets.map(async link => [link, await getMsg(ssbClient, link)]))) {
fetchedTargetCache.set(link, got ? { key: link, value: got } : null);
}
for (const a of idToAction.values()) {
if (a.type !== 'spread') continue;
@ -540,7 +602,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const v = msg && msg.value;
const c = v && v.content;
if (!c || typeof c !== 'object') continue;
const isOpinion = c.type === 'feedOpinion' && typeof c.target === 'string';
const isOpinion = typeof c.type === 'string' && /Opinion$/.test(c.type) && c.type !== 'pollOpinion' && typeof c.target === 'string';
const isVoteAction = c.type === 'feed-action' && c.action === 'vote' && (typeof c.root === 'string' || typeof c.target === 'string');
if (!isOpinion && !isVoteAction) continue;
const target = isOpinion ? c.target : String(c.root || c.target);
@ -570,6 +632,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}
const latest = [];
const docUrls = [...new Set([...idToAction.values()].filter(a => a.type === 'document').map(a => (a.content || {}).url))];
const docBlobOk = new Map(await Promise.all(docUrls.map(async url => [url, await hasBlob(ssbClient, url)])));
for (const a of idToAction.values()) {
if (tombstoned.has(a.id)) continue;
if (a.type === 'tribe' && parentOf.has(a.id)) continue;
@ -580,11 +644,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (c.key && tombstoned.has(c.key)) continue;
if (c.branch && tombstoned.has(c.branch)) continue;
if (c.target && tombstoned.has(c.target)) continue;
if (a.type === 'document') {
const url = c.url;
const ok = await hasBlob(ssbClient, url);
if (!ok) continue;
}
if (a.type === 'document' && !docBlobOk.get(c.url)) continue;
if (a.type === 'forum' && c.root) {
const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || '');
const rootAction = idToAction.get(rootId);
@ -600,6 +660,19 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
a.pollVoters = tally ? tally.voters.size : 0;
}
let content = voteAgg ? { ...c, ...voteAgg } : a.content;
if (a.type !== 'feed' && a.type !== 'votes' && (feedOpinionsByRoot.get(actionRoot) || []).length) {
const base = a.content || {};
const opinions = { ...(base.opinions || {}) };
const voters = Array.isArray(base.opinions_inhabitants) ? base.opinions_inhabitants.slice() : [];
const voterSet = new Set(voters);
for (const op of feedOpinionsByRoot.get(actionRoot)) {
if (!op.author || voterSet.has(op.author)) continue;
voterSet.add(op.author);
voters.push(op.author);
if (op.category) opinions[op.category] = (Number(opinions[op.category]) || 0) + 1;
}
content = { ...content, opinions, opinions_inhabitants: voters };
}
if (a.type === 'feed') {
const base = a.content || {};
const opinions = { ...(base.opinions || {}) };
@ -629,6 +702,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));
const mediaTypes = new Set(['image','video','audio','document','bookmark','map']);
const textTypes = new Set(['feed', 'post']);
const REPEAT_WINDOW_MS = 10 * 60 * 1000;
const perAuthorUnique = new Set();
const byKey = new Map();
const norm = s => String(s || '').trim().toLowerCase();
@ -668,6 +743,19 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
} else if (prevHasImage === newHasImage && effTs > prev.__effTs) {
byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
}
} else if (textTypes.has(a.type)) {
const t = norm(c.text);
const key = t ? `${a.type}:${a.author}:${t}` : null;
const prev = key ? byKey.get(key) : null;
if (!key) {
byKey.set(`id:${a.id}`, { ...a, __effTs: effTs });
} else if (!prev) {
byKey.set(key, { ...a, __effTs: effTs });
} else if (Math.abs(effTs - prev.__effTs) <= REPEAT_WINDOW_MS) {
if (effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
} else {
byKey.set(`id:${a.id}`, { ...a, __effTs: effTs });
}
} else if (a.type === 'tribe') {
const t = norm(c.title);
if (t) {
@ -710,7 +798,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'logPublic', 'gameScore', 'pollVote', 'pollClose', 'pollOpinion', 'curriculum']);
const hiddenTypes = new Set(['wallet', 'bankWallet', 'padEntry', 'roomMember', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'logPublic', 'gameScore', 'pollVote', 'pollClose', 'pollOpinion', 'curriculum', 'schoolLesson', 'schoolEnroll', 'schoolCertificate', 'schoolProgress', 'schoolExam', 'schoolExamResult', 'school-invite', 'schoolExamQuestion', 'schoolLessonMedia']);
const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
deduped = deduped.concat(chatThreadItems);
const isAllowedTribeActivity = (a) => {
@ -732,14 +820,18 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const c = a.content || {};
if (c.encryptedPayload) return false;
if (a.type === 'pad' && c.status !== 'OPEN') return false;
if (a.type === 'room' && (c.status !== 'OPEN' || c.encrypted === true)) return false;
if (a.type === 'chat' && c.status !== 'OPEN') return false;
if (a.type === 'calendar' && c.status !== 'OPEN') return false;
if (a.type === 'event' && String(c.isPublic || '').toLowerCase() === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false;
if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false;
if (a.type === 'schoolCourse' && c.encryptedPayload) return false;
if (a.type === 'schoolCourse' && String(c.visibility || '').toUpperCase() === 'INVITE' && a.author !== userId && !(Array.isArray(c.invited) && c.invited.includes(userId))) return false;
if (a.type === 'housing' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false;
if (a.type === 'market' && c.shopProductId) return false;
if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
if (a.type === 'shopProduct' && c.shopVisibility && String(c.shopVisibility).toUpperCase() === 'CLOSED' && a.author !== userId) return false;
@ -774,7 +866,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
.map(a => ({ ...a, authorActions: perAuthor.get(a.author) || 0 }))
.sort((x, y) => (y.authorActions - x.authorActions) || ((y.ts || 0) - (x.ts || 0)));
}
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
else if (filter === 'parliament')
@ -793,6 +884,13 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
else if (filter === 'industry')
out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
else if (filter === 'room') out = deduped.filter(a => a.type === 'room' && (a.content || {}).status === 'OPEN' && (a.content || {}).encrypted !== true);
else if (filter === 'wiki') out = deduped.filter(a => a.type === 'wikiPage' && isVisible(a));
else if (filter === 'emergency') out = deduped.filter(a => (a.type === 'emergency' || a.type === 'emergencyUpdate') && isVisible(a));
else if (filter === 'campaign') out = deduped.filter(a => (a.type === 'campaign' || a.type === 'campaignUpdate') && isVisible(a));
else if (filter === 'mailing') out = deduped.filter(a => a.type === 'mailingList' && isVisible(a));
else if (filter === 'logistics') out = deduped.filter(a => a.type === 'logisticsRoute' && isVisible(a));
else if (filter === 'podcast') out = deduped.filter(a => (a.type === 'podcast' || a.type === 'podcastEpisode') && isVisible(a));
else if (filter === 'chat') out = deduped.filter(a => (a.type === 'chat' || a.type === 'chatThread') && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
@ -804,7 +902,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
_feedCacheInflight.set(cacheKey, promise);
try {
const value = await promise;
_feedCache.set(cacheKey, { value, ts: Date.now() });
_feedCache.set(cacheKey, { value, ts: Date.now(), tip: tipNow });
return value;
} finally {
_feedCacheInflight.delete(cacheKey);

View file

@ -3,9 +3,11 @@ const path = require('path');
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
const agendaConfigPath = require('../configs/state-manager').statePath('agenda-config.json');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { readTyped } = require('./typed_log');
const { versionGroups, versionEntry } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
function readAgendaConfig() {
@ -19,137 +21,117 @@ function writeAgendaConfig(cfg) {
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
}
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel }) => {
module.exports = ({ cooler, campaignsModel = null, logisticsModel = null, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel, schoolModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const STATUS_ORDER = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => STATUS_ORDER.indexOf(String(s || '').toUpperCase());
const fetchItems = (targetType) =>
new Promise((resolve, reject) => {
openSsb().then((ssbClient) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
const fetchItems = async (targetType) => {
const ssbClient = await openSsb();
const msgs = await readTyped(ssbClient, [targetType, 'tombstone'], { limit: logLimit });
const tomb = buildValidatedTombstoneSet(msgs);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const tomb = buildValidatedTombstoneSet(msgs);
const nodes = new Map();
const versions = [];
for (const m of msgs) {
const k = m.key;
const v = m.value;
const c = v?.content;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type !== targetType) continue;
if (c.encryptedPayload) continue;
nodes.set(k, { key: k, ts: v.timestamp || 0, author: v.author, content: c });
if (c.replaces) parent.set(k, c.replaces);
}
for (const m of msgs) {
const k = m.key;
const v = m.value;
const c = v?.content;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type !== targetType) continue;
if (c.encryptedPayload) continue;
nodes.set(k, { key: k, ts: v.timestamp || 0, author: v.author, content: c });
versions.push(versionEntry(m));
}
for (const [k, p] of Array.from(parent.entries())) {
const cn = nodes.get(k);
const pn = nodes.get(p);
if (!pn) { parent.delete(k); continue; }
if (!cn || String(cn.author) !== String(pn.author)) { parent.delete(k); nodes.delete(k); }
}
for (const [k, p] of parent.entries()) child.set(p, k);
const vg = versionGroups(versions);
const groups = new Map();
for (const r of vg.roots) groups.set(r, new Set(vg.groups.get(r).filter(id => !vg.isForeign(id))));
const rootOf = (id) => { let cur = id; while (parent.has(cur) && nodes.has(parent.get(cur))) cur = parent.get(cur); return cur; };
const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
const groups = new Map();
for (const id of nodes.keys()) {
const r = rootOf(id);
if (!groups.has(r)) groups.set(r, new Set());
groups.get(r).add(id);
}
const out = [];
const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
for (const [root, ids] of groups.entries()) {
const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key));
if (!items.length) continue;
const out = [];
const tip = nodes.get(vg.tipOf(root));
if (tomb.has(tip.key)) continue;
for (const [root, ids] of groups.entries()) {
const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key));
if (!items.length) continue;
if (targetType === 'market') {
let chosen = items[0];
for (const n of items) {
const a = sIdx(n.content.status);
const b = sIdx(chosen.content.status);
if (a > b || (a === b && n.ts > chosen.ts)) chosen = n;
}
const c = chosen.content;
let status = c.status;
if (c.deadline) {
const dl = moment(c.deadline);
if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED';
}
if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
let tipId = Array.from(ids).find(id => !child.has(id));
let tip = tipId ? nodes.get(tipId) : items.reduce((a, b) => a.ts > b.ts ? a : b);
out.push({
...c,
status,
author: chosen.author,
id: chosen.key,
tipId: chosen.key,
createdAt: c.createdAt || chosen.ts
});
continue;
}
if (targetType === 'market') {
let chosen = items[0];
for (const n of items) {
const a = sIdx(n.content.status);
const b = sIdx(chosen.content.status);
if (a > b || (a === b && n.ts > chosen.ts)) chosen = n;
}
const c = chosen.content;
let status = c.status;
if (c.deadline) {
const dl = moment(c.deadline);
if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED';
}
if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
if (targetType === 'job') {
const latest = items.sort((a, b) => b.ts - a.ts)[0];
const withSubsNode = items
.filter(n => Array.isArray(n.content.subscribers))
.sort((a, b) => b.ts - a.ts)[0];
const subscribers = withSubsNode ? withSubsNode.content.subscribers : [];
const latestWithStatus = items
.filter(n => typeof n.content.status !== 'undefined')
.sort((a, b) => b.ts - a.ts)[0];
const resolvedStatus = latestWithStatus
? latestWithStatus.content.status
: latest.content.status;
out.push({
...c,
status,
author: chosen.author,
id: chosen.key,
tipId: chosen.key,
createdAt: c.createdAt || chosen.ts
});
continue;
}
const c = { ...latest.content, status: resolvedStatus, subscribers };
if (targetType === 'job') {
const latest = items.sort((a, b) => b.ts - a.ts)[0];
const withSubsNode = items
.filter(n => Array.isArray(n.content.subscribers))
.sort((a, b) => b.ts - a.ts)[0];
const subscribers = withSubsNode ? withSubsNode.content.subscribers : [];
const latestWithStatus = items
.filter(n => typeof n.content.status !== 'undefined')
.sort((a, b) => b.ts - a.ts)[0];
const resolvedStatus = latestWithStatus
? latestWithStatus.content.status
: latest.content.status;
out.push({
...c,
author: latest.author,
id: latest.key,
tipId: latest.key,
createdAt: c.createdAt || latest.ts
});
continue;
}
const c = { ...latest.content, status: resolvedStatus, subscribers };
out.push({
...tip.content,
author: tip.author,
id: tip.key,
tipId: tip.key,
createdAt: tip.content.createdAt || tip.ts
});
}
out.push({
...c,
author: latest.author,
id: latest.key,
tipId: latest.key,
createdAt: c.createdAt || latest.ts
});
continue;
}
out.push({
...tip.content,
author: tip.author,
id: tip.key,
tipId: tip.key,
createdAt: tip.content.createdAt || tip.ts
});
}
resolve(out);
})
);
}).catch(reject);
});
return out;
};
return {
async listAgenda(filter = 'all') {
const agendaConfig = readAgendaConfig();
const discardedItems = agendaConfig.discardedItems || [];
const removedItems = agendaConfig.removedItems || [];
const ssbClient = await openSsb();
const userId = ssbClient.id;
@ -192,7 +174,19 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
? housingModel.listHousing('ALL', userId).then(normalize).catch(() => [])
: [];
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll, housingAll] = await Promise.all([
const schoolViaModel = schoolModel && typeof schoolModel.listCourses === 'function'
? schoolModel.listCourses('ALL', userId, {}).then(normalize).catch(() => [])
: [];
const campaignsViaModel = campaignsModel && typeof campaignsModel.listAll === 'function'
? campaignsModel.listAll({ filter: 'all' }).catch(() => [])
: [];
const logisticsViaModel = logisticsModel && typeof logisticsModel.listAll === 'function'
? logisticsModel.listAll({ filter: 'all' }).catch(() => [])
: [];
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll, housingAll, schoolAll, campaignsAll, logisticsAll] = await Promise.all([
tasksViaModel,
eventsViaModel,
fetchItems('transfer'),
@ -203,7 +197,10 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
projectsViaModel,
calendarsViaModel,
industryViaModel,
housingViaModel
housingViaModel,
schoolViaModel,
campaignsViaModel,
logisticsViaModel
]);
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
@ -215,26 +212,36 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
).map(m => ({ ...m, type: 'market' }));
const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
const projects = projectsAll.filter(p => p.author === userId || (Array.isArray(p.followers) && p.followers.includes(userId)) || (Array.isArray(p.backers) && p.backers.some(b => b && b.userId === userId)) || (Array.isArray(p.bounties) && p.bounties.some(b => b && b.claimedBy === userId))).map(p => ({ ...p, type: 'project' }));
const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title }));
const schoolCourses = schoolAll
.filter(c => c.author === userId || (Array.isArray(c.students) && c.students.includes(userId)) || (Array.isArray(c.pending) && c.pending.some(pn => pn.author === userId)))
.map(c => ({ ...c, type: 'schoolCourse', title: c.title, date: c.startDate || c.createdAt, teaching: c.author === userId }));
const campaigns = (Array.isArray(campaignsAll) ? campaignsAll : [])
.filter(c => c && c.deadline && (c.author === userId || (Array.isArray(c.signers) && c.signers.includes(userId))))
.map(c => ({ ...c, type: 'campaign', date: c.deadline, status: c.closed ? 'CLOSED' : 'OPEN' }));
const routes = (Array.isArray(logisticsAll) ? logisticsAll : [])
.filter(r => r && r.date && (r.author === userId || !!r.myBooking))
.map(r => ({ ...r, type: 'logisticsRoute', status: r.status === 'CLOSED' ? 'CLOSED' : 'OPEN' }));
const housingPlaces = housingAll
.filter(h => h.author === userId || (Array.isArray(h.requests) && h.requests.includes(userId)))
.map(h => ({ ...h, type: 'housing', date: h.availableFrom || h.createdAt, requested: Array.isArray(h.requests) && h.requests.includes(userId) }));
const myCalendars = calendarsAll
.filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' }));
const calendars = myCalendars.map(c => ({ ...c, id: c.rootId || c.id || c.key, type: 'calendar' }));
const calendarDates = [];
if (calendarsModel && typeof calendarsModel.getDatesForCalendar === 'function') {
for (const cal of myCalendars) {
const datesByCal = await Promise.all(myCalendars.map(cal => calendarsModel.getDatesForCalendar(cal.rootId || cal.id || cal.key).catch(() => [])));
for (const [ci, cal] of myCalendars.entries()) {
try {
const dates = await calendarsModel.getDatesForCalendar(cal.id || cal.key);
const dates = datesByCal[ci];
for (const d of (dates || [])) {
calendarDates.push({
type: 'calendarDate',
id: d.key || d.id,
title: d.label || cal.title || 'Calendar date',
calendarTitle: cal.title || '',
calendarId: cal.id || cal.key,
calendarId: cal.rootId || cal.id || cal.key,
date: d.date || d.createdAt,
label: d.label || '',
author: cal.author,
@ -257,9 +264,12 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
...projects,
...industryBuilds,
...housingPlaces,
...schoolCourses,
...campaigns,
...routes,
...calendars,
...calendarDates
];
].filter(i => !removedItems.includes(i.id));
let filtered;
if (filter === 'discarded') {
@ -278,6 +288,9 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry');
else if (filter === 'housing') filtered = filtered.filter(i => i.type === 'housing');
else if (filter === 'school') filtered = filtered.filter(i => i.type === 'schoolCourse');
else if (filter === 'campaigns') filtered = filtered.filter(i => i.type === 'campaign');
else if (filter === 'logistics') filtered = filtered.filter(i => i.type === 'logisticsRoute');
else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
else if (filter === 'today') {
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
@ -333,6 +346,9 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
projects: mainItems.filter(i => i.type === 'project').length,
industry: mainItems.filter(i => i.type === 'industry').length,
housing: mainItems.filter(i => i.type === 'housing').length,
school: mainItems.filter(i => i.type === 'schoolCourse').length,
campaigns: mainItems.filter(i => i.type === 'campaign').length,
logistics: mainItems.filter(i => i.type === 'logisticsRoute').length,
calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
upcoming: mainItems.filter(i => itemTs(i) > now).length,
@ -354,6 +370,14 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
const agendaConfig = readAgendaConfig();
agendaConfig.discardedItems = agendaConfig.discardedItems.filter(id => id !== itemId);
writeAgendaConfig(agendaConfig);
},
async removeItem(itemId) {
const agendaConfig = readAgendaConfig();
agendaConfig.discardedItems = (agendaConfig.discardedItems || []).filter(id => id !== itemId);
agendaConfig.removedItems = agendaConfig.removedItems || [];
if (!agendaConfig.removedItems.includes(itemId)) agendaConfig.removedItems.push(itemId);
writeAgendaConfig(agendaConfig);
}
};
};

View file

@ -1,6 +1,7 @@
const pull = require("../server/node_modules/pull-stream");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const { dedupeBy, norm, versionGroups } = require('../backend/dedupe');
const { readTyped, memoIndex } = require('./typed_log');
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
@ -31,10 +32,9 @@ module.exports = ({ cooler }) => {
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
});
const AUDIO_TYPES = ["audio", "audioOpinion", "tombstone"];
const getAllMessages = async (ssbClient) => readTyped(ssbClient, AUDIO_TYPES, { limit: logLimit });
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
@ -44,9 +44,6 @@ module.exports = ({ cooler }) => {
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const opinionMsgs = [];
for (const m of messages) {
@ -62,41 +59,16 @@ module.exports = ({ cooler }) => {
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), 'utf8'); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
nodes.set(k, { key: k, ts, seq: v.sequence || 0, c, sizeBytes, author: v.author });
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const vg = versionGroups([...nodes.values()].map(n => ({ key: n.key, author: n.author, seq: n.seq, ts: n.ts, replaces: n.c.replaces || null, claimedAuthor: typeof n.c.author === "string" ? n.c.author : null })));
const rootOf = vg.rootOf;
const contentTipOf = vg.tipOf;
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
@ -113,9 +85,9 @@ module.exports = ({ cooler }) => {
};
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
for (const r of vg.roots) tipByRoot.set(r, contentTipOf(r));
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
return { tomb, nodes, rootOf, tipByRoot, resolveGroup };
};
const buildAudio = (node, rootId, viewerId, agg) => {
@ -132,9 +104,10 @@ module.exports = ({ cooler }) => {
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
tags: tagsArr,
author: c.author,
author: node.author,
title: c.title || "",
description: c.description || "",
license: c.license || "",
mapUrl: c.mapUrl || "",
opinions,
opinions_inhabitants: voters,
@ -163,16 +136,12 @@ module.exports = ({ cooler }) => {
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Audio not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
const root = idx.rootOf(id);
if (idx.tomb.has(idx.tipByRoot.get(root) || id)) throw new Error("Audio not found");
return root;
},
async createAudio(blobMarkdown, tagsRaw, title, description, mapUrl) {
async createAudio(blobMarkdown, tagsRaw, title, description, mapUrl, license) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
@ -189,7 +158,8 @@ module.exports = ({ cooler }) => {
description: description || "",
mapUrl: mapUrl || "",
opinions: {},
opinions_inhabitants: []
opinions_inhabitants: [],
...(license ? { license } : {})
};
return new Promise((resolve, reject) => {
@ -251,7 +221,7 @@ module.exports = ({ cooler }) => {
});
},
async updateAudioById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
async updateAudioById(id, blobMarkdown, tagsRaw, title, description, mapUrl, license) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
@ -275,7 +245,8 @@ module.exports = ({ cooler }) => {
description: description !== undefined ? description || "" : oldMsg.content.description || "",
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
createdAt: oldMsg.content.createdAt,
updatedAt: now
updatedAt: now,
...(license !== undefined ? { license } : {})
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
@ -358,11 +329,11 @@ module.exports = ({ cooler }) => {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const idx = memoIndex('audios', messages, buildIndex);
const rootId = idx.rootOf(id);
const agg = idx.resolveGroup(rootId);
if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Audio not found");
if (!idx.tipByRoot.has(rootId) || !agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Audio not found");
return buildAudio(agg.contentNode, rootId, viewer, agg);
},

779
src/models/backup_model.js Normal file
View file

@ -0,0 +1,779 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const zlib = require('zlib');
const crypto = require('crypto');
const { pipeline } = require('stream');
const pull = require('../server/node_modules/pull-stream');
const toPull = require('../server/node_modules/stream-to-pull-stream');
const KEY_MAGIC = Buffer.from('OASIS1');
const BACKUP_MAGIC = Buffer.from('OASISBK1');
const SNAPSHOT_MAGIC = Buffer.from('OASISSN1');
const SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024 * 1024;
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const REC_META = 1;
const REC_MSG = 2;
const REC_BLOB = 3;
const REC_FILE = 4;
const REC_MAX_LEN = 256 * 1024 * 1024;
const stateRoot = () => {
try { return path.join(require('../configs/state-manager').ssbDir(), 'oasis'); } catch (_) { return null; }
};
const NOT_BACKED_UP = new Set(['oasis-config.json', 'oasis-server-config.json', 'rooms']);
const listStateFiles = (dir, base = dir) => {
const out = [];
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return out; }
for (const entry of entries) {
if (dir === base && NOT_BACKED_UP.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...listStateFiles(full, base));
else if (entry.isFile() && !entry.name.endsWith('.migrated') && !/^snapshot(-recent)?\.oasissn/.test(entry.name)) out.push({ rel: path.relative(base, full), full });
}
return out;
};
const BLOB_RE = /&[A-Za-z0-9+/=]{44}\.sha256/g;
const DEFAULT_REMINDER_DAYS = 30;
const REMINDER_OPTIONS = [7, 30, 90, 0];
const MODULE_GROUPS = [
{ key: 'feed', test: (t) => /^(post|feed|about|contact|pub|vote|subscription)$/.test(t) },
{ key: 'media', test: (t) => /^(audio|video|image|document|bookmark|torrent|file)/i.test(t) },
{ key: 'blogs', test: (t) => /^blog/i.test(t) },
{ key: 'forum', test: (t) => /^forum/i.test(t) },
{ key: 'chats', test: (t) => /^chat/i.test(t) },
{ key: 'pads', test: (t) => /^pad/i.test(t) },
{ key: 'rooms', test: (t) => /^room/i.test(t) },
{ key: 'wiki', test: (t) => /^wiki/i.test(t) },
{ key: 'maps', test: (t) => /^map/i.test(t) },
{ key: 'tribes', test: (t) => /^tribe/i.test(t) },
{ key: 'school', test: (t) => /^school/i.test(t) },
{ key: 'parliament', test: (t) => /^parliament/i.test(t) },
{ key: 'courts', test: (t) => /^courts/i.test(t) },
{ key: 'emergencies', test: (t) => /^emergency/i.test(t) },
{ key: 'votes', test: (t) => /^(votes?|poll)/i.test(t) },
{ key: 'events', test: (t) => /^event/i.test(t) },
{ key: 'calendars', test: (t) => /^calendar/i.test(t) },
{ key: 'tasks', test: (t) => /^task/i.test(t) },
{ key: 'reports', test: (t) => /^report/i.test(t) },
{ key: 'mailing', test: (t) => /^mailing/i.test(t) },
{ key: 'campaigns', test: (t) => /^campaign/i.test(t) },
{ key: 'market', test: (t) => /^market/i.test(t) },
{ key: 'shops', test: (t) => /^shop/i.test(t) },
{ key: 'jobs', test: (t) => /^(job|curriculum)/i.test(t) },
{ key: 'housing', test: (t) => /^housing/i.test(t) },
{ key: 'projects', test: (t) => /^project/i.test(t) },
{ key: 'industry', test: (t) => /^industry/i.test(t) },
{ key: 'banking', test: (t) => /^(bank|ubi|wallet)/i.test(t) },
{ key: 'transfers', test: (t) => /^transfer/i.test(t) },
{ key: 'logistics', test: (t) => /^logistics/i.test(t) },
{ key: 'podcasts', test: (t) => /^podcast/i.test(t) },
{ key: 'games', test: (t) => /^(game|larp|pixelia|melody)/i.test(t) },
{ key: 'logs', test: (t) => /^log/i.test(t) }
];
const MODULE_KEYS = [...MODULE_GROUPS.map(g => g.key), 'inbox', 'other'];
const moduleOf = (content) => {
if (typeof content === 'string') return 'inbox';
const t = content && typeof content === 'object' ? String(content.type || '') : '';
if (!t) return 'other';
const g = MODULE_GROUPS.find(x => x.test(t));
return g ? g.key : 'other';
};
const normalizePassword = (password) => {
if (password && typeof password === 'object' && password.password) password = password.password;
return String(password || '');
};
const deriveKey = (password, salt) => crypto.scryptSync(password, salt, 32);
const encryptBuffer = (plaintext, password) => {
const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_LEN);
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(password, salt), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return Buffer.concat([KEY_MAGIC, salt, iv, cipher.getAuthTag(), ciphertext]);
};
const decryptBuffer = (data, password) => {
if (!Buffer.isBuffer(data) || data.length < KEY_MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN) throw new Error('Unrecognized or corrupt backup file.');
if (!data.slice(0, KEY_MAGIC.length).equals(KEY_MAGIC)) throw new Error('Unrecognized or corrupt backup file.');
let o = KEY_MAGIC.length;
const salt = data.slice(o, o += SALT_LEN);
const iv = data.slice(o, o += IV_LEN);
const tag = data.slice(o, o += TAG_LEN);
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(password, salt), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data.slice(o)), decipher.final()]);
};
const ssbDirOf = () => require('../server/ssb_config').path;
const secretPathOf = () => path.join(ssbDirOf(), 'secret');
const stateFile = () => require('../configs/state-manager').statePath('oasis-backup.json');
const readState = () => {
try { return JSON.parse(fs.readFileSync(stateFile(), 'utf8')) || {}; } catch (_) { return {}; }
};
const writeState = (next) => {
try {
const p = stateFile();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(next, null, 2), { mode: 0o600 });
} catch (_) {}
return next;
};
const reminderState = () => {
const b = readState();
const days = b.reminderDays === undefined ? DEFAULT_REMINDER_DAYS : Number(b.reminderDays);
const lastAt = b.lastAt || null;
const lastTs = lastAt ? Date.parse(lastAt) || 0 : 0;
const overdue = days > 0 && (!lastTs || Date.now() - lastTs > days * 86400000);
return { lastAt, reminderDays: days, overdue, daysSince: lastTs ? Math.floor((Date.now() - lastTs) / 86400000) : null };
};
const setReminderDays = (days) => {
const n = Number(days);
return writeState({ ...readState(), reminderDays: REMINDER_OPTIONS.includes(n) ? n : DEFAULT_REMINDER_DAYS });
};
const markBackup = () => writeState({ ...readState(), lastAt: new Date().toISOString() });
const blobRefsOf = (value) => {
let json;
try { json = JSON.stringify(value); } catch (_) { return []; }
return Array.from(new Set(json.match(BLOB_RE) || []));
};
const record = (type, payload) => {
const head = Buffer.alloc(5);
head.writeUInt8(type, 0);
head.writeUInt32BE(payload.length, 1);
return Buffer.concat([head, payload]);
};
const writeChunk = (stream, chunk) => new Promise((resolve, reject) => {
const ok = stream.write(chunk, (err) => err ? reject(err) : null);
if (ok) resolve();
else stream.once('drain', resolve);
});
module.exports = ({ cooler }) => {
let ssb;
let restoreJob = null;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const readLog = async (ssbClient) => new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ reverse: false }), pull.collect((err, arr) => err ? reject(err) : resolve(arr || [])));
});
const listBlobs = async (ssbClient) => {
if (!ssbClient.blobs || typeof ssbClient.blobs.ls !== 'function') return new Map();
return new Promise((resolve) => {
pull(ssbClient.blobs.ls({ long: true }), pull.collect((err, arr) => {
if (err) return resolve(new Map());
const out = new Map();
for (const b of arr || []) {
if (!b) continue;
if (typeof b === 'string') out.set(b, 0);
else if (b.id) out.set(b.id, Number(b.size) || 0);
}
resolve(out);
}));
});
};
const readBlob = (ssbClient, id) => new Promise((resolve) => {
pull(ssbClient.blobs.get(id), pull.collect((e, chunks) => {
if (e || !chunks || !chunks.length) return resolve(null);
resolve(Buffer.concat(chunks.map(c => Buffer.isBuffer(c) ? c : Buffer.from(c))));
}));
});
const normalizeOptions = (opts = {}) => {
const scope = String(opts.scope || 'all').toLowerCase() === 'mine' ? 'mine' : 'all';
const withBlobs = opts.blobs === undefined ? true : !(opts.blobs === false || String(opts.blobs) === '0' || String(opts.blobs) === 'false' || String(opts.blobs) === 'off');
const rawMods = Array.isArray(opts.modules) ? opts.modules : (opts.modules ? String(opts.modules).split(',') : []);
const modules = rawMods.map(m => String(m || '').trim().toLowerCase()).filter(m => MODULE_KEYS.includes(m));
const sinceTs = opts.since ? (Date.parse(opts.since) || 0) : 0;
return { scope, withBlobs, modules, sinceTs };
};
const selectMessages = async (ssbClient, options) => {
const me = ssbClient.id;
const all = await readLog(ssbClient);
const picked = [];
const blobIds = new Set();
for (const m of all) {
if (!m || !m.value) continue;
if (options.scope === 'mine' && m.value.author !== me) continue;
if (options.sinceTs && Number(m.value.timestamp || m.timestamp || 0) < options.sinceTs) continue;
if (options.modules.length && !options.modules.includes(moduleOf(m.value.content))) continue;
picked.push(m);
if (options.withBlobs) for (const ref of blobRefsOf(m.value.content)) blobIds.add(ref);
}
if (options.withBlobs && options.scope !== 'mine' && !options.sinceTs && !options.modules.length) {
const sizes = await listBlobs(ssbClient);
for (const id of sizes.keys()) blobIds.add(id);
}
return { picked, blobIds };
};
return {
MODULE_KEYS,
REMINDER_OPTIONS,
moduleOf,
encryptBuffer,
decryptBuffer,
async exportKeys(password) {
const pw = normalizePassword(password);
if (pw.length < 32) throw new Error('Password too short');
const secretPath = secretPathOf();
if (!fs.existsSync(secretPath)) throw new Error(".ssb/secret file doesn't exist");
const original = fs.readFileSync(secretPath);
const encrypted = encryptBuffer(original, pw);
if (!decryptBuffer(encrypted, pw).equals(original)) throw new Error('Backup verification failed; nothing was exported.');
return { filename: 'oasis.enc', data: encrypted };
},
async importKeys({ filePath, password }) {
const pw = normalizePassword(password);
if (!fs.existsSync(filePath)) throw new Error('Encrypted file not found.');
let decrypted;
try { decrypted = decryptBuffer(fs.readFileSync(filePath), pw); } catch (_) { throw new Error('Wrong password or corrupt backup file.'); }
let keys = null;
try { keys = JSON.parse(decrypted.toString('utf8').split('\n').filter(l => !l.trim().startsWith('#')).join('\n')); } catch (_) { keys = null; }
const ssbKeys = require('../server/node_modules/ssb-keys');
const probe = Buffer.from('oasis-keys-check');
const valid = (() => {
try {
if (!keys || keys.curve !== 'ed25519' || typeof keys.public !== 'string' || typeof keys.private !== 'string' || keys.id !== '@' + keys.public) return false;
return ssbKeys.verify(keys, ssbKeys.sign(keys, probe), probe) === true;
} catch (_) { return false; }
})();
if (!valid) throw new Error('The file does not contain a valid Oasis identity.');
const ssbDir = ssbDirOf();
fs.mkdirSync(ssbDir, { recursive: true });
const secretPath = path.join(ssbDir, 'secret');
if (fs.existsSync(secretPath)) {
const bakPath = path.join(ssbDir, 'secret.bak-' + new Date().toISOString().replace(/[:.]/g, '-'));
fs.copyFileSync(secretPath, bakPath);
try { fs.chmodSync(bakPath, 0o600); } catch (_) {}
}
const tmpPath = path.join(ssbDir, 'secret.tmp-' + process.pid);
fs.writeFileSync(tmpPath, decrypted, { mode: 0o600 });
fs.renameSync(tmpPath, secretPath);
try { fs.unlinkSync(filePath); } catch (_) {}
let id = '';
try { id = JSON.parse(decrypted.toString('utf8').split('\n').filter(l => !l.trim().startsWith('#')).join('\n')).id || ''; } catch (_) {}
return { path: secretPath, id };
},
recoveryKit() {
const secretPath = secretPathOf();
if (!fs.existsSync(secretPath)) throw new Error(".ssb/secret file doesn't exist");
const raw = fs.readFileSync(secretPath, 'utf8');
const clean = raw.split('\n').filter(l => !l.trim().startsWith('#')).join('\n').trim();
let id = '';
try { id = JSON.parse(clean).id || ''; } catch (_) {}
return { secret: clean, id, createdAt: new Date().toISOString() };
},
async estimate(opts = {}) {
const ssbClient = await openSsb();
const options = normalizeOptions(opts);
const { picked, blobIds } = await selectMessages(ssbClient, options);
let bytes = 0;
for (const m of picked) bytes += Buffer.byteLength(JSON.stringify({ key: m.key, value: m.value, timestamp: m.timestamp }), 'utf8') + 5;
let blobBytes = 0;
let blobsFound = 0;
if (options.withBlobs && blobIds.size) {
const sizes = await listBlobs(ssbClient);
for (const id of blobIds) {
if (!sizes.has(id)) continue;
blobsFound += 1;
blobBytes += sizes.get(id) || 0;
}
}
return { options, messages: picked.length, blobs: blobsFound, blobsReferenced: blobIds.size, messageBytes: bytes, blobBytes, bytes: bytes + blobBytes };
},
async createBackup(opts = {}, password, outPath) {
const pw = normalizePassword(password);
if (pw.length < 32) throw new Error('Password too short');
const ssbClient = await openSsb();
const options = normalizeOptions(opts);
const { picked, blobIds } = await selectMessages(ssbClient, options);
const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_LEN);
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(pw, salt), iv);
const gzip = zlib.createGzip();
const out = fs.createWriteStream(outPath);
const done = new Promise((resolve, reject) => pipeline(gzip, cipher, out, (err) => err ? reject(err) : resolve()));
out.write(Buffer.concat([BACKUP_MAGIC, salt, iv]));
const meta = { version: 1, createdAt: new Date().toISOString(), author: ssbClient.id, options, messages: picked.length };
await writeChunk(gzip, record(REC_META, Buffer.from(JSON.stringify(meta), 'utf8')));
for (const m of picked) {
await writeChunk(gzip, record(REC_MSG, Buffer.from(JSON.stringify({ key: m.key, value: m.value, timestamp: m.timestamp }), 'utf8')));
}
let files = 0;
const root = stateRoot();
if (root) {
for (const f of listStateFiles(root)) {
let data = null;
try { data = fs.readFileSync(f.full); } catch (_) { continue; }
const head = Buffer.from(JSON.stringify({ rel: f.rel.split(path.sep).join('/'), size: data.length }), 'utf8');
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(head.length, 0);
await writeChunk(gzip, record(REC_FILE, Buffer.concat([lenBuf, head, data])));
files += 1;
}
}
let blobs = 0;
if (options.withBlobs) {
for (const id of blobIds) {
const data = await readBlob(ssbClient, id);
if (!data) continue;
const head = Buffer.from(JSON.stringify({ id, size: data.length }), 'utf8');
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(head.length, 0);
await writeChunk(gzip, record(REC_BLOB, Buffer.concat([lenBuf, head, data])));
blobs += 1;
}
}
gzip.end();
await done;
fs.appendFileSync(outPath, cipher.getAuthTag());
markBackup();
return { path: outPath, messages: picked.length, blobs, bytes: fs.statSync(outPath).size, filename: `oasis-backup-${new Date().toISOString().slice(0, 10)}.oasisbk` };
},
async createSnapshot(outPath, { sinceMs = 0, now = Date.now() } = {}) {
const ssbClient = await openSsb();
const all = await readLog(ssbClient);
const byAuthor = new Map();
for (const m of all) {
if (!m || !m.value || !m.value.author || m.value.content === undefined || m.value.content === null) continue;
const list = byAuthor.get(m.value.author) || [];
list.push(m);
byAuthor.set(m.value.author, list);
}
const latest = (list) => list.reduce((t, m) => Math.max(t, Number(m.timestamp) || 0, Number(m.value.timestamp) || 0), 0);
const picked = [...byAuthor.values()]
.filter(list => !(sinceMs > 0) || latest(list) >= now - sinceMs)
.sort((a, b) => latest(b) - latest(a))
.flatMap(list => list.sort((a, b) => (a.value.sequence || 0) - (b.value.sequence || 0)));
const gzip = zlib.createGzip();
const out = fs.createWriteStream(outPath);
const done = new Promise((resolve, reject) => pipeline(gzip, out, (err) => err ? reject(err) : resolve()));
out.write(SNAPSHOT_MAGIC);
const meta = { version: 1, kind: 'snapshot', createdAt: new Date().toISOString(), author: ssbClient.id, messages: picked.length, feeds: new Set(picked.map(m => m.value.author)).size, boxed: picked.filter(m => typeof m.value.content === 'string').length, sinceMs: sinceMs > 0 ? sinceMs : null };
await writeChunk(gzip, record(REC_META, Buffer.from(JSON.stringify(meta), 'utf8')));
for (const m of picked) {
await writeChunk(gzip, record(REC_MSG, Buffer.from(JSON.stringify({ key: m.key, value: m.value, timestamp: m.timestamp }), 'utf8')));
}
gzip.end();
await done;
return { path: outPath, messages: picked.length, feeds: meta.feeds, bytes: fs.statSync(outPath).size, createdAt: meta.createdAt };
},
isSnapshotFile(filePath) {
try {
const fd = fs.openSync(filePath, 'r');
const head = Buffer.alloc(SNAPSHOT_MAGIC.length);
fs.readSync(fd, head, 0, head.length, 0);
fs.closeSync(fd);
return head.equals(SNAPSHOT_MAGIC);
} catch (_) { return false; }
},
async fetchSnapshot({ address, connect = null, tier = 'full' }, outPath) {
const ssbClient = await openSsb();
const doConnect = connect || ((addr, cb) => ssbClient.conn.connect(addr, cb));
const discard = () => { try { fs.unlinkSync(outPath); } catch (_) {} return null; };
try {
const rpc = await new Promise((resolve, reject) => doConnect(address, (err, r) => err ? reject(err) : resolve(r)));
if (!rpc || !rpc.snapshot || typeof rpc.snapshot.get !== 'function') return null;
const info = await new Promise((resolve) => rpc.snapshot.info({ tier }, (err, i) => resolve(err ? null : i)));
if (!info || !(info.bytes > 0) || info.bytes > SNAPSHOT_MAX_BYTES) return null;
const out = fs.createWriteStream(outPath);
let written = 0;
await new Promise((resolve, reject) => {
pull(
rpc.snapshot.get({ tier }),
pull.map(c => Buffer.isBuffer(c) ? c : Buffer.from(c)),
pull.through(c => { written += c.length; }),
toPull.sink(out, (err) => err ? reject(err) : resolve())
);
});
if (written > SNAPSHOT_MAX_BYTES || !this.isSnapshotFile(outPath)) return discard();
return { address, tier, path: outPath, bytes: written, createdAt: info.createdAt };
} catch (_) { return discard(); }
},
startBootstrap({ address, connect = null, tiers = ['recent', 'full'] }) {
if (restoreJob && restoreJob.running) return restoreJob;
const empty = () => ({ messages: 0, skipped: 0, failed: 0, forked: 0, blobs: 0, blobsSkipped: 0, percent: 0 });
const job = { running: true, source: 'snapshot', phase: null, tier: null, tiers: [], address, startedAt: new Date().toISOString(), finishedAt: null, progress: empty(), result: null, error: null };
restoreJob = job;
const total = { ...empty(), files: 0, forks: [], errors: [], meta: null };
const run = async () => {
for (const tier of tiers) {
const tmp = path.join(os.tmpdir(), `oasis-snapshot-${tier}-${Date.now()}-${Math.random().toString(36).slice(2)}.oasissn`);
job.tier = tier;
job.phase = 'download';
try {
const got = await this.fetchSnapshot({ address, connect, tier }, tmp);
if (!got) continue;
job.phase = 'restore';
const res = await this.restoreBackup({ filePath: tmp, password: '', stateFiles: false, onProgress: (p) => { job.progress = { ...total, ...p, messages: total.messages + p.messages, skipped: total.skipped + p.skipped, failed: total.failed + p.failed, forked: total.forked + p.forked }; } });
for (const k of ['messages', 'skipped', 'failed', 'forked', 'blobs', 'blobsSkipped', 'files']) total[k] += Number(res[k]) || 0;
total.forks.push(...(res.forks || []));
total.errors.push(...(res.errors || []));
total.meta = res.meta || total.meta;
job.tiers.push({ tier, messages: res.messages, skipped: res.skipped, bytes: got.bytes });
} finally { try { fs.unlinkSync(tmp); } catch (_) {} }
}
if (!job.tiers.length) throw new Error('snapshot not available');
total.percent = 100;
return total;
};
job.promise = run()
.then((res) => { job.result = res; job.progress = { ...job.progress, ...res }; return res; })
.catch((err) => { job.error = err && err.message ? err.message : String(err); return null; })
.finally(() => { job.running = false; job.phase = null; job.finishedAt = new Date().toISOString(); });
return job;
},
async readBackup(filePath, password, onRecord) {
const size = fs.statSync(filePath).size;
const snapshot = this.isSnapshotFile(filePath);
const pw = snapshot ? '' : normalizePassword(password);
const headLen = snapshot ? SNAPSHOT_MAGIC.length : BACKUP_MAGIC.length + SALT_LEN + IV_LEN;
const tailLen = snapshot ? 0 : TAG_LEN;
if (size < headLen + tailLen) throw new Error('Unrecognized or corrupt backup file.');
const fd = fs.openSync(filePath, 'r');
const head = Buffer.alloc(headLen);
fs.readSync(fd, head, 0, headLen, 0);
const tag = Buffer.alloc(TAG_LEN);
if (!snapshot) fs.readSync(fd, tag, 0, TAG_LEN, size - TAG_LEN);
fs.closeSync(fd);
if (!snapshot && !head.slice(0, BACKUP_MAGIC.length).equals(BACKUP_MAGIC)) throw new Error('Unrecognized or corrupt backup file.');
let decipher = null;
if (!snapshot) {
const salt = head.slice(BACKUP_MAGIC.length, BACKUP_MAGIC.length + SALT_LEN);
const iv = head.slice(BACKUP_MAGIC.length + SALT_LEN, headLen);
decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(pw, salt), iv);
decipher.setAuthTag(tag);
}
const gunzip = zlib.createGunzip();
const total = size - headLen - tailLen;
const source = fs.createReadStream(filePath, { start: headLen, end: size - tailLen - 1 });
let streamError = null;
if (decipher) pipeline(source, decipher, gunzip, (err) => { if (err) streamError = err; });
else pipeline(source, gunzip, (err) => { if (err) streamError = err; });
const chunks = [];
let pendingLen = 0;
let meta = null;
const compact = () => {
if (chunks.length < 2) return;
const all = Buffer.concat(chunks, pendingLen);
chunks.length = 0;
chunks.push(all);
};
const nextRecord = () => {
if (pendingLen < 5) return null;
if (chunks[0].length < 5) compact();
const type = chunks[0].readUInt8(0);
const len = chunks[0].readUInt32BE(1);
if (len > REC_MAX_LEN) throw new Error('Backup record too large.');
if (pendingLen < 5 + len) return null;
compact();
const all = chunks[0];
chunks.length = 0;
pendingLen = all.length - 5 - len;
if (pendingLen) chunks.push(all.slice(5 + len));
return { type, payload: all.slice(5, 5 + len) };
};
const handle = async (type, payload) => {
const read = Math.min(total, source.bytesRead || 0);
if (type === REC_META) { meta = JSON.parse(payload.toString('utf8')); await onRecord({ type: 'meta', meta, read, total }); }
else if (type === REC_MSG) await onRecord({ type: 'msg', msg: JSON.parse(payload.toString('utf8')), read, total });
else if (type === REC_BLOB) {
const hl = payload.readUInt32BE(0);
const header = JSON.parse(payload.slice(4, 4 + hl).toString('utf8'));
await onRecord({ type: 'blob', id: header.id, data: payload.slice(4 + hl), read, total });
}
else if (type === REC_FILE) {
const hl = payload.readUInt32BE(0);
const header = JSON.parse(payload.slice(4, 4 + hl).toString('utf8'));
await onRecord({ type: 'file', rel: header.rel, data: payload.slice(4 + hl), read, total });
}
};
try {
for await (const chunk of gunzip) {
chunks.push(chunk);
pendingLen += chunk.length;
let rec;
while ((rec = nextRecord())) await handle(rec.type, rec.payload);
}
} catch (e) {
source.destroy();
if (streamError || /unsupported state|auth|bad decrypt|incorrect header|invalid/i.test(String(e && e.message))) throw new Error('Wrong password or corrupt backup file.');
throw e;
}
if (streamError) throw new Error('Wrong password or corrupt backup file.');
return meta;
},
async restoreBackup({ filePath, password, onProgress, stateFiles = true }) {
const ssbClient = await openSsb();
let validate = null;
try { validate = require('../server/node_modules/ssb-validate'); } catch (_) {}
const seqKey = (v) => `${v.author}::${v.sequence}`;
const existing = new Map();
for (const m of await readLog(ssbClient)) if (m && m.value) existing.set(seqKey(m.value), m.key);
const stats = { messages: 0, skipped: 0, failed: 0, forked: 0, blobs: 0, blobsSkipped: 0, files: 0, percent: 0, forks: [], errors: [], meta: null };
const forkAuthors = new Map();
const errorCounts = new Map();
const noteError = (err) => {
const reason = String(err && err.message ? err.message : err || 'unknown error').replace(/, on feed:@[^\s]+/g, '').replace(/in state:\{.*?\}/g, '').trim();
errorCounts.set(reason, (errorCounts.get(reason) || 0) + 1);
};
const addMsg = (value) => new Promise((resolve) => {
if (typeof ssbClient.add !== 'function') return resolve(new Error('add is not available'));
ssbClient.add(value, (err) => resolve(err || null));
});
const addBlob = (data) => new Promise((resolve) => {
pull(pull.values([data]), ssbClient.blobs.add((err, ref) => resolve(err ? null : ref)));
});
const getByKey = (key) => new Promise((resolve) => {
if (!key || typeof ssbClient.get !== 'function') return resolve(false);
try { ssbClient.get(key, (err, value) => resolve(!err && !!value)); } catch (_) { resolve(false); }
});
const storedByKey = async (msg) => {
if (await getByKey(msg.key)) return true;
let computed = null;
try { computed = validate ? validate.id(msg.value) : null; } catch (_) { computed = null; }
return computed && computed !== msg.key ? getByKey(computed) : false;
};
const supportsLs = !!(ssbClient.blobs && typeof ssbClient.blobs.ls === 'function');
const present = supportsLs ? await listBlobs(ssbClient) : new Map();
const hasBlob = (id) => supportsLs ? Promise.resolve(present.has(id)) : new Promise((resolve) => ssbClient.blobs.has(id, (err, has) => resolve(!err && !!has)));
const deferred = [];
const report = () => { if (typeof onProgress === 'function') onProgress({ ...stats }); };
const sameMessage = (storedKey, msg) => {
if (storedKey === msg.key) return true;
try { return storedKey === validate.id(msg.value); } catch (_) { return false; }
};
const tryAdd = async (msg) => {
const k = seqKey(msg.value);
if (existing.has(k)) {
if (sameMessage(existing.get(k), msg)) { stats.skipped += 1; return true; }
stats.forked += 1;
forkAuthors.set(msg.value.author, (forkAuthors.get(msg.value.author) || 0) + 1);
return true;
}
const err = await addMsg(msg.value);
if (err) {
if (!(await storedByKey(msg))) return err;
stats.skipped += 1;
existing.set(k, msg.key);
return true;
}
stats.messages += 1;
existing.set(k, msg.key);
return true;
};
const meta = await this.readBackup(filePath, password, async (rec) => {
if (rec.total) stats.percent = Math.min(100, (rec.read / rec.total) * 100);
if (rec.type === 'meta') { stats.meta = rec.meta; return; }
if (rec.type === 'msg') {
if (!rec.msg || !rec.msg.value || !rec.msg.value.author) { stats.failed += 1; noteError('malformed message'); report(); return; }
const res = await tryAdd(rec.msg);
if (res !== true) deferred.push({ msg: rec.msg, err: res });
report();
return;
}
if (rec.type === 'file') {
if (!stateFiles) { stats.skipped += 1; report(); return; }
const root = stateRoot();
const rel = typeof rec.rel === 'string' ? rec.rel.replace(/\\/g, '/') : '';
const parts = rel.split('/').filter(Boolean);
const safe = !!root && parts.length > 0 && !NOT_BACKED_UP.has(parts[0]) && !path.isAbsolute(rel) && parts.every(p => p !== '.' && p !== '..' && !/[\0]/.test(p));
const rootAbs = root ? path.resolve(root) : '';
const target = safe ? path.resolve(rootAbs, ...parts) : '';
if (!safe || !target.startsWith(rootAbs + path.sep)) { stats.failed += 1; noteError('state file rejected'); report(); return; }
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
if (fs.existsSync(target)) { try { fs.copyFileSync(target, `${target}.before-restore`); } catch (_) {} }
fs.writeFileSync(target, rec.data);
stats.files += 1;
} catch (_) { stats.failed += 1; noteError('state file could not be written'); }
report();
return;
}
if (rec.type === 'blob') {
if (await hasBlob(rec.id)) { stats.blobsSkipped += 1; report(); return; }
const ref = await addBlob(rec.data);
if (ref) { stats.blobs += 1; present.set(ref, rec.data.length); } else { stats.failed += 1; noteError('blob could not be stored'); }
report();
}
});
deferred.sort((a, b) => a.msg.value.author === b.msg.value.author ? (a.msg.value.sequence || 0) - (b.msg.value.sequence || 0) : String(a.msg.value.author).localeCompare(String(b.msg.value.author)));
let pending = deferred;
while (pending.length) {
const next = [];
for (const item of pending) {
const res = await tryAdd(item.msg);
if (res !== true) next.push({ msg: item.msg, err: res });
}
report();
if (next.length === pending.length) { pending = next; break; }
pending = next;
}
for (const item of pending) { stats.failed += 1; noteError(item.err); }
stats.percent = 100;
stats.forks = Array.from(forkAuthors.entries()).map(([author, count]) => ({ author, count, mine: author === ssbClient.id }));
stats.errors = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([reason, count]) => ({ reason, count }));
try { fs.unlinkSync(filePath); } catch (_) {}
report();
return { ...stats, meta: stats.meta || meta };
},
startRestore({ filePath, password, source = 'file' }) {
if (restoreJob && restoreJob.running) return restoreJob;
const job = { running: true, source, startedAt: new Date().toISOString(), finishedAt: null, progress: { messages: 0, skipped: 0, failed: 0, forked: 0, blobs: 0, blobsSkipped: 0, percent: 0 }, result: null, error: null };
restoreJob = job;
job.promise = this.restoreBackup({ filePath, password, onProgress: (p) => { job.progress = p; } })
.then((res) => { job.result = res; return res; })
.catch((err) => { job.error = err && err.message ? err.message : String(err); try { fs.unlinkSync(filePath); } catch (_) {} return null; })
.finally(() => { job.running = false; job.finishedAt = new Date().toISOString(); });
return job;
},
restoreStatus() {
if (!restoreJob) return null;
const { promise, ...rest } = restoreJob;
return rest;
},
reminderState() { return reminderState(); },
setReminderDays(days) { return setReminderDays(days); },
markBackup() { return markBackup(); },
async rebuildIndexes() {
const started = Date.now();
const ssbClient = await openSsb();
const indexDir = path.join(require('../configs/state-manager').ssbDir(), 'db2', 'indexes');
const measure = (dir) => {
let files = 0, bytes = 0;
const walk = (d) => {
let entries = [];
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
for (const entry of entries) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
try { bytes += fs.statSync(full).size; files += 1; } catch (_) {}
}
};
walk(dir);
return { files, bytes };
};
const before = measure(indexDir);
let error = null;
try {
await new Promise((resolve, reject) => {
try { ssbClient.rebuild((err) => err ? reject(err) : resolve()); } catch (e) { reject(e); }
});
} catch (e) { error = e && e.message ? e.message : String(e); }
const after = measure(indexDir);
let totalMessages = 0;
try { totalMessages = (await readLog(ssbClient)).length; } catch (_) {}
return {
checkedAt: new Date().toISOString(),
tookMs: Date.now() - started,
ok: !error,
error,
totalMessages,
indexes: { path: indexDir, files: after.files, bytes: after.bytes, deltaBytes: after.bytes - before.bytes }
};
},
async verify({ author = null } = {}) {
const started = Date.now();
const ssbClient = await openSsb();
const me = author || ssbClient.id;
const all = await readLog(ssbClient);
let validate = null;
try { validate = require('../server/node_modules/ssb-validate'); } catch (_) {}
const own = all.filter(m => m && m.value && m.value.author === me).sort((a, b) => (a.value.sequence || 0) - (b.value.sequence || 0));
const feed = { messages: own.length, lastSequence: own.length ? own[own.length - 1].value.sequence : 0, gaps: [], brokenLinks: [], badHashes: [], badSignatures: [], duplicates: [] };
let expected = 1;
let prevKey = null;
const seen = new Set();
for (const m of own) {
const seq = Number(m.value.sequence) || 0;
if (seen.has(seq)) feed.duplicates.push(seq);
seen.add(seq);
if (seq !== expected) { feed.gaps.push({ expected, found: seq }); expected = seq; }
if (prevKey && m.value.previous !== prevKey) feed.brokenLinks.push(seq);
if (validate) {
try { if (validate.id(m.value) !== m.key) feed.badHashes.push(seq); } catch (_) { feed.badHashes.push(seq); }
try { if (validate.checkInvalidOOO(m.value, null)) feed.badSignatures.push(seq); } catch (_) { feed.badSignatures.push(seq); }
}
prevKey = m.key;
expected = seq + 1;
}
feed.ok = !feed.gaps.length && !feed.brokenLinks.length && !feed.badHashes.length && !feed.badSignatures.length && !feed.duplicates.length;
const bySeq = new Map();
for (const m of all) {
if (!m || !m.value || !m.value.author) continue;
const k = `${m.value.author}::${m.value.sequence}`;
if (!bySeq.has(k)) bySeq.set(k, new Set());
bySeq.get(k).add(m.key);
}
const forkMap = new Map();
for (const [k, keys] of bySeq) {
if (keys.size < 2) continue;
const [feedId, seq] = k.split('::');
if (!forkMap.has(feedId)) forkMap.set(feedId, []);
forkMap.get(feedId).push({ sequence: Number(seq), keys: Array.from(keys) });
}
const forks = Array.from(forkMap.entries()).map(([feedId, points]) => ({ author: feedId, mine: feedId === me, points: points.sort((a, b) => a.sequence - b.sequence) }));
const referenced = new Set();
const referencedOwn = new Set();
for (const m of all) {
if (!m || !m.value) continue;
for (const ref of blobRefsOf(m.value.content)) {
referenced.add(ref);
if (m.value.author === me) referencedOwn.add(ref);
}
}
const present = await listBlobs(ssbClient);
const orphan = [];
let orphanBytes = 0;
for (const [id, size] of present) if (!referenced.has(id)) { orphan.push(id); orphanBytes += size || 0; }
const missing = [];
const missingOwn = [];
for (const id of referenced) {
if (!present.size || present.has(id)) continue;
missing.push(id);
if (referencedOwn.has(id)) missingOwn.push(id);
}
return {
author: me, checkedAt: new Date().toISOString(), tookMs: Date.now() - started, totalMessages: all.length,
feed, forks, forkCount: forks.length,
blobs: { present: present.size, referenced: referenced.size, orphan: orphan.length, orphanBytes, missing: missing.length, missingOwn: missingOwn.length, orphanIds: orphan.slice(0, 50), missingIds: missing.slice(0, 50), missingOwnIds: missingOwn.slice(0, 50), listed: present.size > 0 || referenced.size === 0 }
};
}
};
};
Object.assign(module.exports, { reminderState, setReminderDays, markBackup, REMINDER_OPTIONS, MODULE_KEYS });

File diff suppressed because it is too large Load diff

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