OASIS_MOBILE/src/backend/backend.js
s1to e938c8bb42 [0.9.1] FORK_IA_UX v2: fix top-bar (visibility sheet) + Karvan module (ephemeral messages + WebRTC)
Navigation fixes:
- The bottom "Explore" sheet no longer blocks the top bar: closed state is
  visibility:hidden (out of hit-testing), so the top identity buttons and page
  content always receive taps (fixes "top buttons don't work").
- Top bar is now identity only (logo, avatar). The Personal/Community filter
  moved into the Explore sheet, next to the hive, as a segmented control.

New module "Karvan" (self-contained; inspired by karvan-protocol ephemeral rooms):
- Ephemeral/temporary chat rooms held only in RAM, self-destructing on idle
  (30 min) / absolute (2 h) TTL — nothing is written to disk.
- Server relay chat (RAM + polling) as the reliable path, plus a WebRTC
  data-channel layer (perfect negotiation, HTTP signaling mailbox) for instant
  P2P delivery; degrades to the relay if WebRTC is unavailable. Data-channel
  only (no camera/mic) so no wrapper permission changes are needed.
- Files: models/karvan_model.js, views/karvan_view.js, client/public/js/karvan.js;
  routes /karvan* + karvanMod config + nav entry (network) + karvanTitle i18n (11 langs).

Additive and gated by OASIS_MOBILE / karvanMod; Linux and the UX_OASIS branch untouched.
2026-08-06 16:38:36 +02:00

8982 lines
485 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const path = require("path");
const fs = require("fs");
const promisesFs = fs.promises;
const os = require('os');
const envPaths = require("../server/node_modules/env-paths");
const {cli} = require("../client/oasis_client");
const SSBconfig = require('../server/SSB_server.js');
const moment = require('../server/node_modules/moment');
const FileType = require('../server/node_modules/file-type');
const ssbRef = require("../server/node_modules/ssb-ref");
const defaultConfig = {};
const defaultConfigFile = path.join(envPaths("oasis", { suffix: "" }).config, "/default.json");
let haveConfig = false;
try {
Object.assign(defaultConfig, JSON.parse(fs.readFileSync(defaultConfigFile, "utf8")));
haveConfig = true;
} catch (e) { if (e.code !== "ENOENT") { console.log(`Problem loading ${defaultConfigFile}`); throw e; } }
const config = cli(defaultConfig, defaultConfigFile);
if (config.debug) {
process.env.DEBUG = "oasis,oasis:*";
}
const axiosMod = require('../server/node_modules/axios');
const axios = axiosMod.default || axiosMod;
const { spawn } = require('child_process');
let fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer;
try {
({ fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer } = require('../AI/buildAIContext.js'));
} catch (e) {
const noop = () => {};
fieldsForSnippet = noop;
buildContext = noop;
clip = (t) => t;
publishExchange = noop;
publishExchangeVote = noop;
getBestTrainedAnswer = () => null;
}
let aiStarted = false;
function startAI() {
if (aiStarted) return;
aiStarted = true;
try {
const aiProcess = spawn('node', [path.resolve(__dirname, '../AI/ai_service.mjs')], { detached: true, stdio: 'ignore' });
aiProcess.unref();
} catch (e) {}
}
const ADDR_PATH = path.join(__dirname, '..', 'configs', 'wallet-addresses.json');
const readAddrMap = () => { try { return JSON.parse(fs.readFileSync(ADDR_PATH, 'utf8')); } catch { return {}; } };
const writeAddrMap = (map) => { fs.mkdirSync(path.dirname(ADDR_PATH), { recursive: true }); fs.writeFileSync(ADDR_PATH, JSON.stringify(map, null, 2)); };
//parliament model
let electionInFlight = null;
const ensureTerm = async () => {
const cur = await parliamentModel.getCurrentTerm().catch(() => null);
if (cur) return cur;
if (electionInFlight) return electionInFlight;
electionInFlight = parliamentModel.resolveElection().catch(() => null).finally(() => { electionInFlight = null; });
return electionInFlight;
};
let sweepInFlight = null;
const runSweepOnce = async () => {
if (sweepInFlight) return sweepInFlight;
sweepInFlight = parliamentModel.sweepProposals().catch(e => console.error('sweepProposals failed:', e)).finally(() => { sweepInFlight = null; });
return sweepInFlight;
};
async function buildState(filter) {
const f = (filter || 'government').toLowerCase();
await ensureTerm();
await runSweepOnce();
const [govCard, candidatures, proposals, canPropose, laws, historical] = await Promise.all([
parliamentModel.getGovernmentCard(),
parliamentModel.listCandidatures('OPEN'),
parliamentModel.listProposalsCurrent(),
parliamentModel.canPropose(),
parliamentModel.listLaws(),
parliamentModel.listHistorical()
]);
return { filter: f, governmentCard: govCard, candidatures, proposals, canPropose, laws, historical };
}
function pickLeader(cands = []) {
if (!cands.length) return null;
return [...cands].sort((a, b) => {
const d = (x, y) => y - x;
return d(Number(a.votes||0), Number(b.votes||0)) || d(Number(a.karma||0), Number(b.karma||0)) ||
(Number(a.profileSince||0) - Number(b.profileSince||0)) ||
(new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) ||
String(a.targetId).localeCompare(String(b.targetId));
})[0];
}
async function buildLeaderMeta(leader) {
if (!leader) return null;
if (leader.targetType === 'inhabitant') {
let name = null, image = null, description = null;
try { name = about?.name && await about.name(leader.targetId); } catch {}
try { image = about?.image && await about.image(leader.targetId); } catch {}
try { description = about?.description && await about.description(leader.targetId); } catch {}
const imgId = typeof image === 'string' ? image : image?.link || image?.url || null;
return { isTribe: false, name: name || leader.targetId, avatarUrl: imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-avatar.png', bio: typeof description === 'string' ? description : '' };
}
let tribe = null;
try { tribe = await tribesModel.getTribeById(leader.targetId); } catch {}
const imgId = tribe?.image || null;
return { isTribe: true, name: leader.targetTitle || tribe?.title || tribe?.name || leader.targetId, avatarUrl: imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-tribe.png', bio: tribe?.description || '' };
}
const safeArr = v => Array.isArray(v) ? v : [];
const safeText = v => String(v || '').trim();
const safeReturnTo = (ctx, fb, ap) => { const rt = ctx.request?.body?.returnTo || ctx.query?.returnTo; return typeof rt === 'string' && ap?.some(p => rt.startsWith(p)) ? rt : fb; };
// anti-injections
const { stripDangerousTags, sanitizeHtml } = require('./sanitizeHtml');
const sharedState = require('../configs/shared-state');
module.exports = stripDangerousTags;
const sanitizeMsgText = (msg) => {
if (!msg?.value?.content) return msg;
const c = msg.value.content;
if (typeof c.text === 'string') c.text = stripDangerousTags(c.text);
if (typeof c.description === 'string') c.description = stripDangerousTags(c.description);
if (typeof c.title === 'string') c.title = stripDangerousTags(c.title);
if (typeof c.contentWarning === 'string') c.contentWarning = stripDangerousTags(c.contentWarning);
return msg;
};
const sanitizeMessages = (msgs) => Array.isArray(msgs) ? msgs.map(sanitizeMsgText) : msgs;
const parseBool01 = v => String(Array.isArray(v) ? v[v.length - 1] : v || '') === '1';
const sendErrorPage = (ctx, message, { title, status } = {}) => {
const { errorView } = require('../views/main_views');
const ref = ctx.request.header.referer;
let backHref = '/';
try {
if (ref) {
const u = new URL(ref);
if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) {
backHref = u.pathname + u.search + u.hash;
}
}
} catch (_) {}
if (status) ctx.status = status;
ctx.type = 'html';
ctx.body = errorView({ title, message, backHref });
};
const exceedsSsbLimit = (content, extraOverhead = 0) => {
try { return Buffer.byteLength(JSON.stringify(content), 'utf8') + 512 + extraOverhead > 8192; }
catch (_) { return false; }
};
const isSsbTooLargeError = (e) => e && /8192|must not be larger/i.test(String(e && e.message));
const safeRefererRedirect = (ctx, fallback = '/') => {
const ref = ctx.request.header.referer;
if (!ref) { ctx.redirect(fallback); return; }
try {
const u = new URL(ref);
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.host !== ctx.host) {
ctx.redirect(fallback);
return;
}
ctx.redirect(u.pathname + u.search + u.hash);
} catch (_) {
ctx.redirect(fallback);
}
};
const isLoopbackRequest = (ctx) => {
const raw = String((ctx.request && ctx.request.ip) || ctx.ip || (ctx.socket && ctx.socket.remoteAddress) || '');
const ip = raw.replace(/^::ffff:/, '');
return ip === '127.0.0.1' || ip === '::1' || ip === 'localhost';
};
const checkMod = (ctx, mod) => {
const cfg = getConfig();
const serverValue = cfg.modules?.[mod];
if (serverValue === 'off') return false;
const cookieValue = ctx.cookies.get(mod);
if (cookieValue) return cookieValue === 'on';
return serverValue === 'on' || serverValue === undefined;
};
const getViewerId = () => SSBconfig?.config?.keys?.id || SSBconfig?.keys?.id;
const _carbonCache = new Map();
const CARBON_TTL_MS = 5 * 60 * 1000;
async function getCarbonGramsForFeed(feedId) {
if (!feedId) return 0;
const now = Date.now();
const cached = _carbonCache.get(feedId);
if (cached && (now - cached.ts) < CARBON_TTL_MS) return cached.grams;
try {
const pullMod = require("../server/node_modules/pull-stream");
const ssbX = await cooler.open();
const bytes = await new Promise((resolve) => {
let total = 0;
pullMod(
ssbX.createUserStream({ id: feedId }),
pullMod.drain(
(m) => { try { total += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} },
() => resolve(total)
)
);
});
const grams = (bytes / (1024 * 1024)) * 0.095;
_carbonCache.set(feedId, { ts: now, grams });
return grams;
} catch (_) {
return 0;
}
}
function synthesizeMelodyWav(sequence) {
const sampleRate = 8000;
const fadeMs = 1;
const gapMs = 3;
const speed = 3;
const pilotMs = 80;
const pilotFreq = 807;
const pilotSamples = Math.floor(pilotMs * sampleRate / 1000);
const fadeSamplesShort = Math.floor(fadeMs * sampleRate / 1000);
let totalSamples = pilotSamples;
for (const n of sequence) {
totalSamples += Math.floor((((n.durMs || 250) / speed) + gapMs) * sampleRate / 1000);
}
const stegoMaxBytes = 2 + 2 + 4096;
const minSamples = stegoMaxBytes * 8;
if (totalSamples < minSamples) totalSamples = minSamples;
const dataLen = totalSamples * 2;
const buf = Buffer.alloc(44 + dataLen);
buf.write('RIFF', 0);
buf.writeUInt32LE(36 + dataLen, 4);
buf.write('WAVE', 8);
buf.write('fmt ', 12);
buf.writeUInt32LE(16, 16);
buf.writeUInt16LE(1, 20);
buf.writeUInt16LE(1, 22);
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE(sampleRate * 2, 28);
buf.writeUInt16LE(2, 32);
buf.writeUInt16LE(16, 34);
buf.write('data', 36);
buf.writeUInt32LE(dataLen, 40);
let off = 44;
const writeSquare = (freq, samples, fadeSamples) => {
if (samples <= 0) return;
const period = sampleRate / Math.max(1, freq);
for (let i = 0; i < samples; i++) {
let env = 0.45;
if (fadeSamples > 0) {
if (i < fadeSamples) env *= i / fadeSamples;
else if (i > samples - fadeSamples) env *= (samples - i) / fadeSamples;
}
const phase = (i % period) / period;
const s = (phase < 0.5 ? 1 : -1) * env;
buf.writeInt16LE((s * 32767) | 0, off);
off += 2;
}
};
writeSquare(pilotFreq, pilotSamples, fadeSamplesShort);
for (const n of sequence) {
const freq = Number(n.freq) || 440;
const noteSamples = Math.floor(((n.durMs || 250) / speed) * sampleRate / 1000);
const gapSamples = Math.floor(gapMs * sampleRate / 1000);
const fadeSamples = Math.min(noteSamples >> 3, fadeSamplesShort);
writeSquare(freq, noteSamples, fadeSamples);
for (let i = 0; i < gapSamples; i++) {
buf.writeInt16LE(0, off);
off += 2;
}
}
return buf;
}
async function listAvailableBlockIds(ids) {
const list = Array.isArray(ids) ? ids.filter(id => typeof id === 'string' && id.length > 0) : [];
const out = new Set();
if (list.length === 0) return out;
try {
const ssbClient2 = await cooler.open();
await Promise.all(list.map(id => new Promise((resolve) => {
try {
ssbClient2.get(id, (err, msg) => {
if (!err && msg) out.add(id);
resolve();
});
} catch (_) { resolve(); }
})));
} catch (_) {}
return out;
}
async function runTranscode(audio) {
if (!audio || !audio.url) return null;
try {
const ssbClient2 = await cooler.open();
const buf = await new Promise((resolve, reject) => {
pull(
ssbClient2.blobs.get(audio.url),
pull.collect((err, chunks) => err ? reject(err) : resolve(Buffer.concat(chunks)))
);
});
const raw = melodyModel.extractTextFromWav(buf);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
id: typeof parsed.id === 'string' ? parsed.id : null,
ts: Number.isFinite(parsed.ts) ? Number(parsed.ts) : null,
msg: typeof parsed.msg === 'string' ? parsed.msg : ''
};
}
return { id: null, ts: null, msg: String(raw) };
} catch (_) {
return { id: null, ts: null, msg: String(raw) };
}
} catch (_) {
return null;
}
}
let _localLanIPv4 = null;
const getLocalLanIPv4 = () => {
if (_localLanIPv4) return _localLanIPv4;
try {
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
for (const info of (ifaces[name] || [])) {
if (info && info.family === 'IPv4' && !info.internal) {
_localLanIPv4 = info.address;
return _localLanIPv4;
}
}
}
} catch (_) {}
return null;
};
const resolveExternalBaseUrl = (ctx) => {
const protocol = ctx.protocol || 'http';
const rawHost = String(ctx.host || '').trim();
const [hostname, port] = rawHost.split(':');
const loopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
if (loopback) {
const lan = getLocalLanIPv4();
if (lan) return `${protocol}://${lan}${port ? ':' + port : ''}`;
}
return `${protocol}://${rawHost}`;
};
const getUserTribeIds = async (uid) => {
const allTribes = await tribesModel.listAll().catch(() => []);
const memberTribes = allTribes.filter(t => t.members.includes(uid));
const idSets = await Promise.all(memberTribes.map(t => tribesModel.getChainIds(t.id).catch(() => [t.id])));
return new Set(idSets.flat());
};
const refreshInboxCount = async (messagesOpt) => {
const messages = messagesOpt || await pmModel.listAllPrivate();
const userId = getViewerId();
const isToUser = m => Array.isArray(m?.value?.content?.to) && m.value.content.to.includes(userId);
const filtered = messages.filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true);
sharedState.setInboxCount(filtered.filter(isToUser).length);
};
const PM_CRYPTER_MAX = 4600;
const buildInboxMessages = async () => {
let messages = sanitizeMessages(await pmModel.listAllPrivate());
const cfgNow = getConfig();
if (cfgNow.pmVisibility === 'mutuals') {
const viewer = getViewerId();
const mutualCache = new Map();
const isMutual = async (id) => {
if (id === viewer) return true;
if (mutualCache.has(id)) return mutualCache.get(id);
let rel;
try { rel = await friend.getRelationship(id); } catch (e) { rel = null; }
const m = !!(rel && rel.following && rel.followsMe);
mutualCache.set(id, m);
return m;
};
const filtered = [];
for (const msg of messages) {
const author = msg?.value?.author || msg?.author;
if (author === viewer) { filtered.push(msg); continue; }
if (await isMutual(author)) filtered.push(msg);
}
messages = filtered;
}
return messages;
};
const mediaFavorites = require("./media-favorites.js");
const customStyleFile = path.join(envPaths("oasis", { suffix: "" }).config, "/custom-style.css");
let haveCustomStyle = false;
try { fs.readFileSync(customStyleFile, "utf8"); haveCustomStyle = true; } catch (e) { if (e.code !== "ENOENT") { console.log(`Problem loading ${customStyleFile}`); throw e; } }
const { get } = require("node:http");
const debug = require("../server/node_modules/debug")("oasis");
const log = (formatter, ...args) => {
const isDebugEnabled = debug.enabled;
debug.enabled = true;
debug(formatter, ...args);
debug.enabled = isDebugEnabled;
};
delete config._;
delete config.$0;
const { host } = config;
const { port } = config;
const url = `http://${host}:${port}`;
debug("Current configuration: %O", config);
debug(`You can save the above to ${defaultConfigFile} to make \
these settings the default. See the readme for details.`);
const { saveConfig, getConfig } = require('../configs/config-manager');
const configPath = path.join(__dirname, '../configs/oasis-config.json');
const oasisCheckPath = "/.well-known/oasis";
process.on("uncaughtException", function (err) {
if (err["code"] === "EADDRINUSE") {
get(url + oasisCheckPath, (res) => {
let rawData = "";
res.on("data", (chunk) => {
rawData += chunk;
});
res.on("end", () => {
log(rawData);
if (rawData === "oasis") {
log(`Oasis is already running on host ${host} and port ${port}`);
if (config.open === true && process.env.OASIS_MOBILE !== '1') {
log("Opening link to existing instance of Oasis");
open(url);
} else {
log(
"Not opening your browser because opening is disabled by your config"
);
}
process.exit(0);
} else {
throw new Error(`Another server is already running at ${url}.
It might be another copy of Oasis or another program on your computer.
You can run Oasis on a different port number with this option:
oasis --port ${config.port + 1}
Alternatively, you can set the default port in ${defaultConfigFile} with:
{
"port": ${config.port + 1}
}
`);
}
});
});
} else if (err && (err.name === 'OpenError' || (typeof err.message === 'string' && /Resource temporarily unavailable/i.test(err.message) && /\.ssb\/.*LOCK/i.test(err.message)))) {
console.log("");
console.log("Another Oasis instance is already running on this machine. Close the other instance (or kill the process) and try again.");
console.log("");
process.exit(1);
} else {
console.log("");
console.log("Oasis traceback (share below content with devs to report!):");
console.log("===========================================================");
console.log(err);
console.log("");
}
});
process.argv = [];
const http = require("../client/middleware");
const {koaBody} = require("../server/node_modules/koa-body");
const { nav, ul, li, a, form, button, div, section, h2, p } = require("../server/node_modules/hyperaxe");
const open = require("../server/node_modules/open");
const pull = require("../server/node_modules/pull-stream");
const koaRouter = require("../server/node_modules/@koa/router");
const ssbMentions = require("../server/node_modules/ssb-mentions");
const isSvg = require('../server/node_modules/is-svg');
const { isFeed, isMsg, isBlob } = require("../server/node_modules/ssb-ref");
const ssb = require("../client/gui");
const router = new koaRouter();
async function fetchProfileItems(feedId, prefs) {
const MAX_PER_SECTION = 5;
const items = { shops: [], jobs: [], events: [], projects: [], posts: [], audios: [], videos: [], images: [], documents: [], torrents: [] };
const tasks = [];
if (prefs.profileShops) tasks.push((async () => {
try {
const shops = await shopsModel.listAll({ filter: 'all' }).catch(() => []);
items.shops = (shops || []).filter(s => s.author === feedId && String(s.visibility || '').toUpperCase() !== 'CLOSED').slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profileJobs) tasks.push((async () => {
try {
const jobs = await jobsModel.listJobs('ALL', feedId).catch(() => []);
items.jobs = (jobs || []).filter(j => j.author === feedId && String(j.status || '').toUpperCase() !== 'CLOSED').slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profileEvents) tasks.push((async () => {
try {
const events = await eventsModel.listAll(feedId, 'all').catch(() => []);
items.events = (events || []).filter(e => e.organizer === feedId && String(e.status || '').toUpperCase() !== 'CLOSED').slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profileProjects) tasks.push((async () => {
try {
const projects = await projectsModel.listProjects('ALL').catch(() => []);
items.projects = (projects || []).filter(p => p.author === feedId && String(p.status || '').toUpperCase() !== 'CANCELLED').slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profilePosts) tasks.push((async () => {
try {
const ssbX = await cooler.open();
items.posts = await new Promise((resolve) => {
try {
pull(
ssbX.createUserStream({ id: feedId, reverse: true, limit: 200 }),
pull.filter(m => m && m.value && m.value.content && m.value.content.type === 'post' && !m.value.content.root),
pull.collect((err, arr) => {
if (err || !Array.isArray(arr)) return resolve([]);
resolve(arr.slice(0, MAX_PER_SECTION));
})
);
} catch (_) { resolve([]); }
});
} catch (_) {}
})());
if (prefs.profileAudios) tasks.push((async () => {
try {
const audios = await audiosModel.listAll('all').catch(() => []);
items.audios = (audios || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profileVideos) tasks.push((async () => {
try {
const videos = await videosModel.listAll('all').catch(() => []);
items.videos = (videos || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profileImages) tasks.push((async () => {
try {
const images = await imagesModel.listAll('all').catch(() => []);
items.images = (images || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profileDocuments) tasks.push((async () => {
try {
const documents = await documentsModel.listAll('all').catch(() => []);
items.documents = (documents || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profileTorrents) tasks.push((async () => {
try {
const torrents = await torrentsModel.listAll('all').catch(() => []);
items.torrents = (torrents || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
if (prefs.profileBookmarks) tasks.push((async () => {
try {
const bookmarks = await bookmarksModel.listAll('all').catch(() => []);
items.bookmarks = (bookmarks || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION);
} catch (_) {}
})());
await Promise.all(tasks);
return items;
}
const extractMentions = async (text) => {
const mentions = ssbMentions(text) || [];
const resolvedMentions = await Promise.all(mentions.map(async (mention) => {
const name = mention.name || await about.name(mention.link);
return {
link: mention.link,
name: name || 'Anonymous',
};
}));
return resolvedMentions;
};
const cooler = ssb({ offline: config.offline, port: config.port, host: config.host, isPublic: config.public });
const models = require("../models/main_models");
const { about, blob, friend, meta, post, vote, spreads, lifetime } = models({
cooler,
isPublic: config.public,
});
const { handleBlobUpload, serveBlob, FileTooLargeError } = require('../backend/blobHandler.js');
const extractBlobId = (md) => md ? (md.match(/\((&[^)]+)\)/)?.[1] ?? null) : null;
const exportmodeModel = require('../models/exportmode_model');
const panicmodeModel = require('../models/panicmode_model');
const cipherModel = require('../models/cipher_model');
const pmPolicy = require('./pm_policy');
const legacyModel = require('../models/legacy_model');
const devModel = require('../models/dev_model');
const walletModel = require('../models/wallet_model')
const pmModel = require('../models/pm_model')({ cooler, isPublic: config.public });
const fileshareModel = require('../models/fileshare_model')({ cooler });
const FILESHARE_MAX_SIZE = (getConfig().fileShare && Number(getConfig().fileShare.maxSize)) || (1024 * 1024 * 1024);
const FILESHARE_TTL_MS = (((getConfig().fileShare && Number(getConfig().fileShare.ttlDays)) || 30)) * 24 * 60 * 60 * 1000;
const runFileshareCleanup = async () => {
try {
const ssbClient = await cooler.open();
const me = ssbClient.id;
const msgs = await pmModel.listAllPrivate();
const mine = (msgs || [])
.filter(m => m && m.value && m.value.author === me && m.value.content && m.value.content.fileShare)
.map(m => ({ pointer: m.value.content.fileShare, sentAt: m.value.content.sentAt }));
if (mine.length) await fileshareModel.pruneExpired(mine, FILESHARE_TTL_MS, Date.now());
} catch (_) {}
};
const fsCleanupTimer = setTimeout(() => { runFileshareCleanup(); }, 120000);
if (fsCleanupTimer.unref) fsCleanupTimer.unref();
const fsCleanupInterval = setInterval(() => { runFileshareCleanup(); }, 12 * 60 * 60 * 1000);
if (fsCleanupInterval.unref) fsCleanupInterval.unref();
const bookmarksModel = require("../models/bookmarking_model")({ cooler, isPublic: config.public });
const opinionsModel = require('../models/opinions_model')({ cooler, isPublic: config.public });
const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.public, pmModel });
const votesModel = require('../models/votes_model')({ cooler, isPublic: config.public });
const ssbConfig = require('../server/ssb_config');
const tribeCrypto = require('../models/crypto')(ssbConfig.path, 'tribes');
const onboardingModel = require('../models/onboarding_model')({ cooler, ssbPath: ssbConfig.path });
const chatCrypto = require('../models/crypto')(ssbConfig.path, 'chats');
const padCrypto = require('../models/crypto')(ssbConfig.path, 'pads');
const mapCrypto = require('../models/crypto')(ssbConfig.path, 'maps');
const calendarCrypto = require('../models/crypto')(ssbConfig.path, 'calendars');
const eventCrypto = require('../models/crypto')(ssbConfig.path, 'events');
const forumCrypto = require('../models/crypto')(ssbConfig.path, 'forum');
const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto });
const eventsModel = require('../models/events_model')({ cooler, isPublic: config.public, tribeCrypto, eventCrypto, tribesModel });
const larpModel = require('../models/larp_model')({ cooler, tribesModel, tribeCrypto });
const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public });
const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
const calendarsModel = require('../models/calendars_model')({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel });
const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
const imagesModel = require("../models/images_model")({ cooler, isPublic: config.public });
const audiosModel = require("../models/audios_model")({ cooler, isPublic: config.public });
const torrentsModel = require("../models/torrents_model")({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
const videosModel = require("../models/videos_model")({ cooler, isPublic: config.public });
const documentsModel = require("../models/documents_model")({ cooler, isPublic: config.public });
const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public });
const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel });
const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC)
const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel });
const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel });
const industryModel = require("../models/industry_model")({ cooler, isPublic: config.public });
const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel, padsModel, industryModel });
const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
const melodyModel = require('../models/melody_model')({ cooler });
const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto });
const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public, tribeCrypto, forumCrypto });
const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto });
const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto });
const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, chatCrypto, tribesModel });
const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel });
const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public, tribeCrypto, mapCrypto, tribesModel });
const gamesModel = require('../models/games_model')({ cooler });
const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel });
const logsModel = require("../models/logs_model")({ cooler });
const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
const fediverseModel = require('../models/fediverse_model')({ isPublic: config.public });
const { renderGovernance: renderTribeGovernance } = require('../views/tribes_view');
const viewerFilters = require('../models/viewer_filters');
const scanPendingFollows = async (viewerId) => {
if (!viewerId) return;
if (!viewerFilters.isFrictionActive()) return;
const pullStream = require('../server/node_modules/pull-stream');
const ssbClient = await cooler.open();
const limit = getConfig().ssbLogStream?.limit || 1000;
const rows = await new Promise((res, rej) => {
pullStream(
ssbClient.createLogStream({ reverse: true, limit }),
pullStream.collect((err, arr) => err ? rej(err) : res(arr || []))
);
});
const accepted = new Set(viewerFilters.loadAccepted());
const pendingIds = new Set(viewerFilters.listPending().map(x => x.followerId));
for (const msg of rows) {
const c = msg.value?.content;
if (!c || c.type !== 'contact') continue;
if (c.contact !== viewerId) continue;
if (c.following !== true) continue;
const author = msg.value?.author;
if (!author || author === viewerId) continue;
if (accepted.has(author)) continue;
if (pendingIds.has(author)) continue;
viewerFilters.enqueuePending(author);
pendingIds.add(author);
}
};
const { section: hSection } = require('../server/node_modules/hyperaxe');
const renderPendingFollows = (items) => {
const { template: tpl, i18n: i18nLocal } = require('../views/main_views');
const { div, h2, p, form, button, input, ul, li, span, a } = require('../server/node_modules/hyperaxe');
return tpl(
i18nLocal.inhabitantsPendingFollowsTitle || 'Pending follow requests',
hSection(
div({ class: 'tags-header' },
h2(i18nLocal.inhabitantsPendingFollowsTitle || 'Pending follow requests'),
p(i18nLocal.pmMutualNotice || '')
),
(!Array.isArray(items) || items.length === 0)
? p('—')
: ul({}, items.map(it =>
li({},
span({ style: 'font-weight:bold' }, it.name || it.followerId),
' — ',
span({ class: 'muted' }, it.followerId.slice(0, 14) + '…'),
' ',
form({ method: 'POST', action: '/inhabitants/follow/accept', style: 'display:inline' },
input({ type: 'hidden', name: 'followerId', value: it.followerId }),
button({ type: 'submit', class: 'filter-btn' }, i18nLocal.inhabitantsPendingAccept || 'Accept')
),
' ',
form({ method: 'POST', action: '/inhabitants/follow/reject', style: 'display:inline' },
input({ type: 'hidden', name: 'followerId', value: it.followerId }),
button({ type: 'submit', class: 'filter-btn' }, i18nLocal.inhabitantsPendingReject || 'Reject')
)
)
))
)
);
};
const makeCtxMutualCache = () => {
const cache = new Map();
const frictionActive = viewerFilters.isFrictionActive();
return async (otherId) => {
if (!otherId) return false;
if (cache.has(otherId)) return cache.get(otherId);
let rel;
try { rel = await friend.getRelationship(otherId); } catch (e) { rel = null; }
const basic = !!(rel && rel.following && rel.followsMe);
const mutual = frictionActive ? (basic && viewerFilters.isAccepted(otherId)) : basic;
cache.set(otherId, mutual);
return mutual;
};
};
const extractItemAuthor = (item) => {
if (!item) return null;
if (typeof item === 'string') return null;
if (item.value && item.value.author) return item.value.author;
if (item.author) return item.author;
if (item.feed) return item.feed;
if (item.organizer) return item.organizer;
if (item.proposer) return item.proposer;
if (item.owner) return item.owner;
if (item.id && typeof item.id === 'string' && item.id.startsWith('@')) return item.id;
return null;
};
const extractItemTribeId = (item) => {
if (!item || typeof item !== 'object') return null;
if (item.tribeId) return item.tribeId;
if (item.value && item.value.content && item.value.content.tribeId) return item.value.content.tribeId;
if (item.content && item.content.tribeId) return item.content.tribeId;
return null;
};
const getViewerTribeAccessSets = async (userId) => {
if (!userId) return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() };
try {
const all = await tribesModel.listAll();
const memberOf = new Set();
const createdBy = new Set();
const privateNotAccessible = new Set();
for (const t of all) {
const isMember = Array.isArray(t.members) && t.members.includes(userId);
const isCreator = t.author === userId;
if (isCreator) { createdBy.add(t.id); memberOf.add(t.id); }
else if (isMember) memberOf.add(t.id);
const ancestryPrivate = await (async () => {
try { const eff = await tribesModel.getEffectiveStatus(t.id); return eff.isPrivate; } catch (e) { return !!t.isAnonymous; }
})();
if (ancestryPrivate && !isMember && !isCreator) privateNotAccessible.add(t.id);
}
return { memberOf, createdBy, privateNotAccessible };
} catch (e) {
return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() };
}
};
const applyListFilters = async (items, ctx, opts = {}) => {
if (!Array.isArray(items)) return items;
const cfg = getConfig();
const viewer = getViewerId();
const wishMutuals = cfg.wish === 'mutuals';
let out = items;
if (!opts.skipTribeAccess) {
const { memberOf, createdBy, privateNotAccessible } = await getViewerTribeAccessSets(viewer);
out = out.filter(it => {
const tid = extractItemTribeId(it);
if (!tid) return true;
if (memberOf.has(tid) || createdBy.has(tid)) return true;
if (privateNotAccessible.has(tid)) return false;
return true;
});
}
if (wishMutuals && !opts.skipMutual) {
const isMutual = makeCtxMutualCache();
const filtered = [];
for (const it of out) {
const a = extractItemAuthor(it);
if (!a || a === viewer) { filtered.push(it); continue; }
if (await isMutual(a)) filtered.push(it);
}
out = filtered;
}
return out;
};
const enrichItemLifetime = async (item, opts = {}) => {
if (!item) return item;
try {
const key = opts.key ?? (item.id || item.key || item.rootId);
const author = opts.author ?? (item.author || item.organizer || item.createdBy || item.seller || item.from);
const createdAt = opts.createdAt ?? item.createdAt;
item.lifetime = await lifetime.forContent({ key, author, createdAt });
} catch (_) {}
return item;
};
const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel }, tribeCrypto });
tribesModel.processIncomingKeys().then(async () => {
try {
const viewerId = getViewerId();
const mine = (await tribesModel.listAll()).filter(t => t.author === viewerId);
for (const t of mine) {
await tribesModel.ensureTribeKeyDistribution(t.id).catch(() => {});
await tribesModel.ensureFollowTribeMembers(t.id).catch(() => {});
}
await tribesModel.pruneOrphanKeys().catch(() => {});
} catch (_) {}
}).catch(err => {
if (config.debug) console.error('tribe-keys scan error:', err.message);
});
courtsModel.processIncomingCourtsKeys().catch(err => {
if (config.debug) console.error('courts-keys scan error:', err.message);
});
const getVoteComments = async (voteId) => {
const raw = await post.topicComments(voteId);
return (raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId)
.sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0));
};
const enrichWithComments = async (items, idKey = 'id') => {
await Promise.all(items.map(async x => { x.commentCount = (await getVoteComments(x[idKey] || x.key || x.rootId)).length; }));
return items;
};
const enrichMsgSize = async (items, idKey = 'id') => {
try {
const ssbX = await cooler.open();
await Promise.all((items || []).map(async (x) => {
const id = x && (x[idKey] || x.key || x.rootId);
if (!id) return;
try {
const raw = await new Promise((resolve) => ssbX.get(id, (err, v) => resolve(err ? null : v)));
if (raw) x.msgSize = Buffer.byteLength(JSON.stringify(raw), 'utf8');
} catch (_) {}
}));
} catch (_) {}
return items;
};
const withCount = (item, comments) => ({ ...item, commentCount: comments.length });
const resolveMapUrl = async (mapUrl) => {
if (!mapUrl) return null;
try {
const mapKey = decodeURIComponent(String(mapUrl).replace(/^\/maps\//, ''));
return await mapsModel.getMapById(mapKey, null);
} catch (_) { return null; }
};
const mediaResolvers = {
images: id => imagesModel.resolveRootId(id),
audios: id => audiosModel.resolveRootId(id),
videos: id => videosModel.resolveRootId(id),
documents: id => documentsModel.resolveRootId(id),
bookmarks: id => bookmarksModel.resolveRootId(id),
shops: id => shopsModel.resolveRootId(id),
chats: id => chatsModel.resolveRootId(id),
maps: id => mapsModel.resolveRootId(id),
pads: id => padsModel.resolveRootId(id),
calendars: id => calendarsModel.resolveRootId(id),
torrents: id => torrentsModel.resolveRootId(id)
};
const mediaModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', chats: 'chatsMod', maps: 'mapsMod', pads: 'padsMod', calendars: 'calendarsMod', torrents: 'torrentsMod' };
const favAction = async (ctx, kind, action) => {
if (!checkMod(ctx, mediaModCheck[kind])) { ctx.redirect('/modules'); return; }
try {
const rootId = await mediaResolvers[kind](ctx.params.id);
if (rootId) await mediaFavorites[action + 'Favorite'](kind, rootId);
} catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`]));
};
const commentAction = async (ctx, kind, idParam) => {
const modKey = mediaModCheck[kind];
if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
const itemId = ctx.params[idParam];
let text = stripDangerousTags((ctx.request.body.text || '').trim());
const rt = safeReturnTo(ctx, `/${kind}/${encodeURIComponent(itemId)}`, [`/${kind}`]);
const blobMarkdown = await handleBlobUpload(ctx, 'blob');
if (blobMarkdown) text += blobMarkdown;
if (!text) { ctx.redirect(rt); return; }
await post.publish({ text, root: itemId, dest: itemId });
ctx.redirect(rt);
};
const opinionModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel, torrents: torrentsModel };
const deleteModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel, torrents: torrentsModel };
const opinionAction = async (ctx, kind, idParam) => {
const modKey = mediaModCheck[kind];
if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
await opinionModels[kind].createOpinion(ctx.params[idParam], ctx.params.category);
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`]));
};
const deleteAction = async (ctx, kind, deleteFn = 'delete' + kind.charAt(0).toUpperCase() + kind.slice(1, -1) + 'ById') => {
const modKey = mediaModCheck[kind];
if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
await deleteModels[kind][deleteFn](ctx.params.id);
ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=mine`, [`/${kind}`]));
};
const mediaCreateModels = { audios: audiosModel, videos: videosModel };
const mediaCreateAction = async (ctx, kind) => {
const modKey = mediaModCheck[kind];
if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
const blob = await handleBlobUpload(ctx, kind.slice(0, -1));
const { tags, title, description, mapUrl } = ctx.request.body;
await mediaCreateModels[kind][`create${kind.charAt(0).toUpperCase()}${kind.slice(1, -1)}`](blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), stripDangerousTags(mapUrl || ""));
ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=all`, [`/${kind}`]));
};
const mediaUpdateAction = async (ctx, kind) => {
const modKey = mediaModCheck[kind];
if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
const { tags, title, description, mapUrl } = ctx.request.body;
const singular = kind.slice(0, -1);
const blob = ctx.request.files?.[singular] ? await handleBlobUpload(ctx, singular) : null;
await mediaCreateModels[kind][`update${kind.charAt(0).toUpperCase()}${kind.slice(1, -1)}ById`](ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), stripDangerousTags(mapUrl || ""));
ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=mine`, [`/${kind}`]));
};
const qf = (ctx, def = 'all') => ctx.query.filter || def;
const qp = (ctx, def = 1) => Math.max(1, parseInt(ctx.query.page) || def);
const dedupeLarpHouseTribes = (list) => {
const arr = Array.isArray(list) ? list : [];
const earliest = new Map();
for (const t of arr) {
const larpTag = (Array.isArray(t.tags) ? t.tags : []).find(x => String(x).startsWith('larp-'));
if (!larpTag) continue;
const ts = Number(Date.parse(t.createdAt || '')) || 0;
const prev = earliest.get(larpTag);
if (!prev || ts < prev.ts) earliest.set(larpTag, { id: t.id, ts });
}
if (!earliest.size) return arr;
return arr.filter(t => {
const larpTag = (Array.isArray(t.tags) ? t.tags : []).find(x => String(x).startsWith('larp-'));
if (!larpTag) return true;
return earliest.get(larpTag).id === t.id;
});
};
about._startNameWarmup();
// Route-triggered author-name resolution: warms nameCache with the ACTUAL name
// for each feed id about to be rendered, so every userLink() date·author footer
// (and comments) shows @name instead of the raw oasis id. Call before rendering.
const FEED_RE = /^@.+\.ed25519$/;
const AUTHOR_FIELDS = ['author', 'from', 'to', 'organizer', 'proposer', 'seller', 'recipient', 'judgeId', 'accuser', 'respondentId', 'createdBy'];
const warmNames = async (ids) => {
const uniq = [...new Set((ids || []).filter(v => v && FEED_RE.test(String(v))).map(String))];
if (!uniq.length) return;
await Promise.all(uniq.slice(0, 600).map(fid => about.name(fid).catch(() => {})));
};
const collectAuthorIds = (items) => {
const out = [];
for (const it of (Array.isArray(items) ? items : [items])) {
if (!it) continue;
if (it.author) out.push(it.author);
const c = it.value?.content || it.content || it;
if (c && typeof c === 'object') for (const f of AUTHOR_FIELDS) if (c[f]) out.push(c[f]);
}
return out;
};
const warmAuthorNames = async (...lists) => {
try { await warmNames(lists.flatMap(l => collectAuthorIds(l))); } catch (_) {}
};
const OASIS_VERSION = (() => { try { return String(require('../server/package.json').version || ''); } catch (_) { return ''; } })();
const getOasisVersion = async (feedId) => {
if (!feedId) return null;
try {
const ssb = await cooler.open();
return await new Promise((res) => pull(
ssb.createUserStream({ id: feedId, reverse: true }),
pull.filter(m => m && m.value && m.value.content && m.value.content.type === 'oasisVersion'),
pull.take(1),
pull.collect((e, a) => res(e || !a || !a.length ? null : (a[0].value.content.version || null)))
));
} catch (_) { return null; }
};
(async () => {
try {
const ssb = await cooler.open();
const mine = await getOasisVersion(ssb.id);
if (OASIS_VERSION && mine !== OASIS_VERSION) {
ssb.publish({ type: 'oasisVersion', version: OASIS_VERSION, updatedAt: new Date().toISOString() }, () => {});
}
} catch (_) {}
})();
async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) {
if (!text) return '';
const escHtml = (s) => String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const mentionByFeed = {};
Object.values(mentions).forEach(arr => {
arr.forEach(m => {
mentionByFeed[m.feed] = m;
});
});
text = text.replace(/\[@([^\]]+)\]\(([^)]+)\)/g, (_, name, id) => {
return `<a class="mention" href="/author/${encodeURIComponent(id)}">@${escHtml(name)}</a>`;
});
const words = text.split(' ');
text = (await Promise.all(
words.map(async (word) => {
const match = /@([A-Za-z0-9_\-\.+=\/]+\.ed25519)/.exec(word);
if (match && match[1]) {
const feedId = match[1];
const feedWithAt = feedId.startsWith('@') ? feedId : `@${feedId}`;
let resolvedName;
if (feedId === myFeedId || feedWithAt === myFeedId) {
resolvedName = myUsername;
} else {
try { resolvedName = await about.name(feedWithAt); } catch { resolvedName = feedId.slice(0, 8); }
}
return word.replace(match[0], `<a class="mention" href="/author/${encodeURIComponent(feedWithAt)}">@${escHtml(resolvedName)}</a>`);
}
return word;
})
)).join(' ');
text = text
.replace(/!\[image:[^\]]+\]\(([^)]+)\)/g, (_, id) =>
`<img src="/blob/${encodeURIComponent(id)}" alt="image" class="post-image" />`)
.replace(/\[audio:[^\]]+\]\(([^)]+)\)/g, (_, id) =>
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(id)}"></audio>`)
.replace(/\[video:[^\]]+\]\(([^)]+)\)/g, (_, id) =>
`<video controls class="post-video" src="/blob/${encodeURIComponent(id)}"></video>`)
.replace(/\[pdf:([^\]]*)\]\(([^)]+)\)/g, (_, name, id) => {
const { i18n } = require("../views/main_views");
const label = name || (i18n && i18n.pdfFallbackLabel) || 'PDF';
return `<a class="post-pdf" href="/blob/${encodeURIComponent(id)}" target="_blank">${escHtml(label)}</a>`;
});
return text;
}
async function resolveMentionText(text) {
if (!text || typeof text !== 'string') return text;
const mentionRe = /@([A-Za-z0-9_\-\.+=\/]+\.ed25519)/g;
const matches = [...text.matchAll(mentionRe)];
if (!matches.length) return text;
const seen = new Map();
for (const m of matches) {
const raw = m[1];
const feed = raw.startsWith('@') ? raw : `@${raw}`;
if (seen.has(feed)) continue;
let name;
try { name = await about.name(feed); } catch { name = feed.slice(1, 9); }
seen.set(feed, name);
}
return text.replace(mentionRe, (full, id) => {
const feed = id.startsWith('@') ? id : `@${id}`;
const name = seen.get(feed) || feed.slice(1, 9);
return `[@${name}](${feed})`;
});
}
const preparePreview = async function (ctx) {
let text = String(ctx.request.body.text || "")
if (text.length > 8000) text = text.slice(0, 8000)
const contentWarning = stripDangerousTags(String(ctx.request.body.contentWarning || ""))
const ensureAt = (id) => {
const s = String(id || "")
if (!s) return ""
return s.startsWith("@") ? s : `@${s.replace(/^@+/, "")}`
}
const stripAt = (id) => String(id || "").replace(/^@+/, "")
const norm = (s) => String(s || "").trim().toLowerCase()
const ssbClient = await cooler.open()
const authorMeta = {
id: ssbClient.id,
name: await about.name(ssbClient.id),
image: await about.image(ssbClient.id),
}
const myId = String(authorMeta.id)
text = text.replace(
/\[@([^\]]+)\]\s*\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g,
(_m, label, feed) => `[@${label}](@${stripAt(feed)})`
)
const mentions = {}
const normalizeMatch = (m) => {
const feed = ensureAt(m?.feed || m?.link || m?.id || "")
const name = String(m?.name || "")
const img = m?.img || m?.image || null
const rel = m?.rel || {}
return { ...m, feed, name, img, rel }
}
const pushUnique = (key, arr) => {
const prev = Array.isArray(mentions[key]) ? mentions[key] : []
const seen = new Set(prev.map((x) => String(x?.feed || "")))
const out = prev.slice()
for (const x of arr) {
const f = String(x?.feed || "")
if (!f) continue
if (seen.has(f)) continue
seen.add(f)
out.push(x)
}
if (out.length) mentions[key] = out
}
const chooseByPhrase = (matches, phrase) => {
const p = norm(phrase)
const exact = matches.filter((mm) => norm(mm.name) === p)
if (exact.length) return exact
const starts = matches.filter((mm) => norm(mm.name).startsWith(p))
if (starts.length) return starts
const incl = matches.filter((mm) => norm(mm.name).includes(p))
if (incl.length) return incl
return null
}
const rex = /(^|\s)(?!\[)@([a-zA-Z0-9\-/.=+]{3,})(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?\b/g
let m
while ((m = rex.exec(text)) !== null) {
const w1 = m[2]
const w2 = m[3]
const w3 = m[4]
if (/\.ed25519$/.test(w1)) {
const feed = ensureAt(w1)
const [name, img, rel] = await Promise.all([
about.name(feed),
about.image(feed),
friend.getRelationship(feed).catch(() => ({ followsMe: false, following: false, blocking: false, me: false }))
])
pushUnique(w1, [{ feed, name, img, rel }])
continue
}
const phrase1 = w1
const phrase2 = w2 ? `${w1} ${w2}` : null
const phrase3 = w3 ? `${w1} ${w2 ? w2 : ""} ${w3}`.replace(/\s+/g, " ").trim() : null
const matchesRaw = about.named(w1) || []
const matchesAll = matchesRaw.map(normalizeMatch)
const matches = matchesAll.filter((mm) => String(mm.feed) !== myId && !mm?.rel?.me)
let chosenKey = phrase1
let chosenMatches = matches
if (phrase3) {
const best3 = chooseByPhrase(matches, phrase3)
if (best3 && best3.length) {
chosenKey = phrase3
chosenMatches = best3
} else if (phrase2) {
const best2 = chooseByPhrase(matches, phrase2)
if (best2 && best2.length) {
chosenKey = phrase2
chosenMatches = best2
}
}
} else if (phrase2) {
const best2 = chooseByPhrase(matches, phrase2)
if (best2 && best2.length) {
chosenKey = phrase2
chosenMatches = best2
}
}
if (chosenMatches.length > 0) {
pushUnique(chosenKey, chosenMatches)
}
}
Object.keys(mentions).forEach((key) => {
const matches = Array.isArray(mentions[key]) ? mentions[key] : []
const meaningful = matches.filter((mm) => (mm?.rel?.followsMe || mm?.rel?.following) && !mm?.rel?.blocking && String(mm?.feed || "") !== myId && !mm?.rel?.me)
mentions[key] = meaningful.length > 0 ? meaningful : matches
})
const rexReplace = /(^|\s)(?!\[)@([a-zA-Z0-9\-/.=+]{3,})(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?\b/g
const replacer = (match, prefix, w1, w2, w3) => {
const phrase1 = w1
const phrase2 = w2 ? `${w1} ${w2}` : null
const phrase3 = w3 ? `${w1} ${w2 ? w2 : ""} ${w3}`.replace(/\s+/g, " ").trim() : null
const tryKey = (k) => {
const arr = mentions[k]
if (arr && arr.length === 1) {
return `${prefix}[@${arr[0].name}](${ensureAt(arr[0].feed)})`
}
return null
}
if (/\.ed25519$/.test(w1)) {
const arr = mentions[w1]
if (arr && arr.length === 1) return `${prefix}[@${arr[0].name}](${ensureAt(arr[0].feed)})`
return match
}
const r3 = phrase3 ? tryKey(phrase3) : null
if (r3) return r3
const r2 = phrase2 ? tryKey(phrase2) : null
if (r2) return r2
const r1 = tryKey(phrase1)
if (r1) return r1
return match
}
text = text.replace(rexReplace, replacer)
const blobMarkdown = await handleBlobUpload(ctx, "blob")
if (blobMarkdown) {
text += blobMarkdown
}
const renderedText = await renderBlobMarkdown(
text,
mentions,
authorMeta.id,
authorMeta.name
)
const hasBrTags = /<br\s*\/?>/i.test(renderedText)
const hasBlockTags = /<(p|div|ul|ol|li|pre|blockquote|h[1-6]|table|tr|td|th|section|article)\b/i.test(renderedText)
let formattedText = renderedText
if (!hasBrTags && !hasBlockTags && /[\r\n]/.test(renderedText)) {
formattedText = renderedText.replace(/\r\n|\r|\n/g, "<br>")
}
return { authorMeta, text, formattedText, mentions, contentWarning }
}
const megabyte = Math.pow(2, 20);
const maxSize = 50 * megabyte;
const collectFediverseMedia = async (ctx) => {
const files = ctx.request.files && ctx.request.files.media;
if (!files) return [];
const arr = Array.isArray(files) ? files : [files];
const out = [];
for (const f of arr.slice(0, 4)) {
if (!f || !f.filepath || !Number(f.size || 0)) continue;
const m = await fediverseModel.uploadMedia(f).catch(() => null);
if (m && m.id) out.push(m);
}
return out;
};
const fediverseReturnTo = (ctx, fallback) => {
const rt = ctx.request.body && ctx.request.body.returnTo;
return typeof rt === 'string' && rt.startsWith('/fediverse') ? rt : fallback;
};
const homeDir = os.homedir();
const blobsPath = path.join(homeDir, '.ssb', 'blobs', 'tmp');
const FEDIVERSE_TMP_PREFIX = 'fediverse-';
const FEDIVERSE_MIME_BY_EXT = { jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif', webp:'image/webp', avif:'image/avif', mp4:'video/mp4', m4v:'video/mp4', webm:'video/webm', mov:'video/quicktime', ogg:'audio/ogg', mp3:'audio/mpeg', wav:'audio/wav' };
const fediverseExtFromName = (n) => { const m = String(n || '').match(/\.([a-zA-Z0-9]+)$/); return m ? m[1].toLowerCase() : ''; };
const fediverseMimeFromExt = (ext) => FEDIVERSE_MIME_BY_EXT[ext] || 'application/octet-stream';
const isFediverseTmpName = (n) => typeof n === 'string' && /^fediverse-[0-9]+-[0-9]+\.[a-zA-Z0-9]+$/.test(n);
const fediverseTmpType = (name) => {
const mime = fediverseMimeFromExt(fediverseExtFromName(name));
return /^video\//.test(mime) ? 'video' : /^audio\//.test(mime) ? 'audio' : 'image';
};
const sweepFediverseTmp = () => {
try {
const now = Date.now();
for (const f of fs.readdirSync(blobsPath)) {
if (!f.startsWith(FEDIVERSE_TMP_PREFIX)) continue;
const full = path.join(blobsPath, f);
try { if (now - fs.statSync(full).mtimeMs > 30 * 60 * 1000) fs.unlinkSync(full); } catch (_) {}
}
} catch (_) {}
};
const saveFediverseTempMedia = (ctx) => {
const files = ctx.request.files && ctx.request.files.media;
if (!files) return [];
const arr = Array.isArray(files) ? files : [files];
try { fs.mkdirSync(blobsPath, { recursive: true }); } catch (_) {}
const out = [];
let i = 0;
for (const f of arr.slice(0, 4)) {
if (!f || !f.filepath || !Number(f.size || 0)) continue;
const ext = fediverseExtFromName(f.originalFilename) || fediverseExtFromName(f.filepath) || 'bin';
const name = `${FEDIVERSE_TMP_PREFIX}${Date.now()}-${i++}.${ext}`;
try {
fs.copyFileSync(f.filepath, path.join(blobsPath, name));
out.push({ name, type: /^video|^audio/.test(fediverseMimeFromExt(ext)) ? fediverseMimeFromExt(ext).split('/')[0] : 'image' });
} catch (_) {}
}
return out;
};
const publishFediverseTempMedia = async (names) => {
const list = Array.isArray(names) ? names : (names ? [names] : []);
const ids = [];
for (const name of list.slice(0, 4)) {
if (!isFediverseTmpName(name)) continue;
const full = path.join(blobsPath, name);
if (!fs.existsSync(full)) continue;
const ext = fediverseExtFromName(name);
const m = await fediverseModel.uploadMedia({ filepath: full, mimetype: fediverseMimeFromExt(ext), originalFilename: name, size: 1 }).catch(() => null);
if (m && m.id) ids.push(m.id);
try { fs.unlinkSync(full); } catch (_) {}
}
return ids;
};
const gossipPath = path.join(homeDir, '.ssb', 'gossip.json');
const unfollowedPath = path.join(homeDir, '.ssb', 'gossip_unfollowed.json');
const ensureJSONFile = (p, init = []) => { fs.mkdirSync(path.dirname(p), { recursive: true }); if (!fs.existsSync(p)) fs.writeFileSync(p, JSON.stringify(init, null, 2), 'utf8'); };
const readJSON = p => { ensureJSONFile(p, []); try { return JSON.parse(fs.readFileSync(p, 'utf8') || '[]'); } catch { return []; } };
const writeJSON = (p, d) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(d, null, 2), 'utf8'); };
const canonicalKey = k => { let c = String(k).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/'); if (!c.endsWith('=')) c += '='; return `@${c}.ed25519`; };
const msAddrFrom = (h, p, k) => `net:${h}:${Number(p) || 8008}~shs:${canonicalKey(k).slice(1, -9)}`;
ensureJSONFile(gossipPath, []);
ensureJSONFile(unfollowedPath, []);
const koaBodyMiddleware = koaBody({
multipart: true,
formidable: {
uploadDir: blobsPath,
keepExtensions: true,
maxFieldsSize: maxSize,
maxFileSize: maxSize,
hash: 'sha256',
},
parsedMethods: ['POST'],
});
const koaBodyFileshare = koaBody({
multipart: true,
formidable: {
uploadDir: blobsPath,
keepExtensions: true,
maxFieldsSize: maxSize,
maxFileSize: FILESHARE_MAX_SIZE,
hash: false,
},
parsedMethods: ['POST'],
});
const resolveCommentComponents = async function (ctx) {
let parentId;
try {
parentId = decodeURIComponent(ctx.params.message);
} catch {
parentId = ctx.params.message;
}
const parentMessage = await post.get(parentId);
if (!parentMessage || !parentMessage.value) {
throw new Error("Invalid parentMessage or missing 'value'");
}
const myFeedId = await meta.myFeedId();
const hasRoot =
typeof parentMessage?.value?.content?.root === "string" &&
ssbRef.isMsg(parentMessage.value.content.root);
const hasFork =
typeof parentMessage?.value?.content?.fork === "string" &&
ssbRef.isMsg(parentMessage.value.content.fork);
const rootMessage = hasRoot
? hasFork
? parentMessage
: await post.get(parentMessage.value.content.root)
: parentMessage;
const messages = await post.topicComments(rootMessage.key);
messages.push(rootMessage);
let contentWarning;
if (ctx.request.body) {
const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning || "").trim());
contentWarning = rawContentWarning.length > 0 ? rawContentWarning : undefined;
}
return { messages, myFeedId, parentMessage, contentWarning };
};
const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView, tribeAccessDeniedView, inviteRequiredView, clearnetInhabitantView, clearnetBlogView } = require("../views/main_views");
const { activityView } = require("../views/activity_view");
const { cvView, createCVView } = require("../views/cv_view");
const { indexingView } = require("../views/indexing_view");
const { pixeliaView } = require("../views/pixelia_view");
const { melodyView } = require("../views/melody_view");
const { gamesView } = require("../views/games_view");
const { statsView } = require("../views/stats_view");
const { tribesView, tribeView, renderInvitePage } = require("../views/tribes_view");
const { agendaView } = require("../views/agenda_view");
const { documentView, singleDocumentView } = require("../views/document_view");
const { inhabitantsView, inhabitantsProfileView } = require("../views/inhabitants_view");
const { walletViewRender, walletView, walletHistoryView, walletReceiveView, walletSendFormView, walletSendConfirmView, walletSendResultView, walletErrorView } = require("../views/wallet_view");
const { pmView } = require("../views/pm_view");
const { tagsView } = require("../views/tags_view");
const { videoView, singleVideoView } = require("../views/video_view");
const { audioView, singleAudioView, audiosTranscodeView, audioTranscodeDetailView } = require("../views/audio_view");
const { torrentsView, singleTorrentView } = require("../views/torrents_view");
const { eventView, singleEventView, clearnetEventView, renderEventInvitePage } = require("../views/event_view");
const { invitesView } = require("../views/invites_view");
const { modulesView } = require("../views/modules_view");
const { reportView, singleReportView } = require("../views/report_view");
const { taskView, singleTaskView } = require("../views/task_view");
const { voteView } = require("../views/vote_view");
const { bookmarkView, singleBookmarkView } = require("../views/bookmark_view");
const { feedView, feedCreateView, singleFeedView } = require("../views/feed_view");
const { legacyView } = require("../views/legacy_view");
const { devTreeView, devFileView, devSearchView, devMapView } = require("../views/dev_view");
const { welcomeView } = require("../views/welcome_view");
const { opinionsView } = require("../views/opinions_view");
const { peersView } = require("../views/peers_view");
const { graphosView } = require("../views/graphos_view");
const { larpListView, larpHouseView, larpTestView, larpTestResultView } = require("../views/larp_view");
const { searchView } = require("../views/search_view");
const { transferView, singleTransferView } = require("../views/transfer_view");
const { cipherView } = require("../views/cipher_view");
const { imageView, singleImageView } = require("../views/image_view");
const { mapsView, singleMapView } = require("../views/maps_view");
const { settingsView } = require("../views/settings_view");
const { fediverseView, fediverseThreadView, fediverseOverviewView, fediversePreviewView } = require("../views/fediverse_view");
const { trendingView } = require("../views/trending_view");
const { marketView, singleMarketView } = require("../views/market_view");
const { aiView } = require("../views/AI_view");
const { forumView, singleForumView, renderForumInvitePage } = require("../views/forum_view");
const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
const { jobsView, singleJobsView, renderJobForm, clearnetJobView } = require("../views/jobs_view");
const { shopsView, singleShopView, singleProductView, editProductView, shopOrdersView, myPurchasesView, clearnetShopView, renderShopInvitePage } = require("../views/shops_view");
const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view");
const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view");
const { karvanView, karvanRoomView } = require("../views/karvan_view");
const { calendarsView, singleCalendarView, renderCalendarInvitePage } = require("../views/calendars_view");
const { projectsView, singleProjectView, clearnetProjectView } = require("../views/projects_view")
const { industryView, singleFacilityView, singleBuildView, singleBlueprintView, blueprintEditView, buildEditView } = require("../views/industry_view")
const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
const { favoritesView } = require("../views/favorites_view");
const { logsView } = require("../views/logs_view");
const { buildLogsPdf } = require("./logsPdf");
const { buildSmartContractPdf } = require("./smartContractPdf");
const { parliamentView } = require("../views/parliament_view");
const { courtsView, courtsCaseView } = require('../views/courts_view');
let sharp;
try {
sharp = require("sharp");
} catch (e) {
}
const readmePath = path.join(__dirname, "..", ".." ,"README.md");
const packagePath = path.join(__dirname, "..", "server", "package.json");
const readme = fs.readFileSync(readmePath, "utf8");
const version = JSON.parse(fs.readFileSync(packagePath, "utf8")).version;
const nullImageId = '&0000000000000000000000000000000000000000000=.sha256';
const getAvatarUrl = img => !img || img === nullImageId ? '/assets/images/default-avatar.png' : `/image/256/${encodeURIComponent(img)}`;
const MAX_TITLE_LENGTH = 150;
const MAX_TEXT_LENGTH = 8000;
const parseSizeMB = (s) => { if (!s) return 0; const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i); if (!m) return 0; const v = parseFloat(m[1]), u = m[2].toUpperCase(); return u === 'GB' ? v * 1024 : u === 'MB' ? v : u === 'KB' ? v / 1024 : v / (1024 * 1024); };
const tooLong = (ctx, value, max, label) => {
if (value && value.length > max) {
sendErrorPage(ctx, `${label} too long (max ${max})`, { status: 400 });
return true;
}
return false;
};
const buildEffectivePrivateChainIds = async () => {
const ids = new Set();
const all = await tribesModel.listAll().catch(() => []);
for (const tr of all) {
try {
const eff = await tribesModel.getEffectiveStatus(tr.id);
if (!eff || !eff.isPrivate) continue;
const chain = await tribesModel.getChainIds(tr.id).catch(() => [tr.id]);
for (const cid of chain) ids.add(cid);
} catch (_) {}
}
return ids;
};
const isBlockRestricted = (block, effPrivateChainIds) => {
if (!block) return false;
const c = block.content || {};
const t = c.type || block.type || '';
const isPrivate = String(c.isPublic || '').toLowerCase() === 'private';
const tribeMsgInPrivate = t === 'tribe' && (effPrivateChainIds.has(block.id) || (c.replaces && effPrivateChainIds.has(c.replaces)));
const tribeKeysInPrivate = t === 'tribe-keys' && c.tribeId && effPrivateChainIds.has(c.tribeId);
const tribeContentInPrivate = !!c.tribeId && effPrivateChainIds.has(c.tribeId);
return tribeMsgInPrivate ||
tribeKeysInPrivate ||
tribeContentInPrivate ||
t.startsWith('courts') ||
t === 'job' || t === 'job_sub' ||
c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' ||
isPrivate;
};
router
.param("imageSize", (imageSize, ctx, next) => {
const size = Number(imageSize);
const isInteger = size % 1 === 0;
const overMinSize = size > 2;
const underMaxSize = size <= 256;
ctx.assert(
isInteger && overMinSize && underMaxSize,
400,
"Invalid image size"
);
return next();
})
.param("blobId", (blobId, ctx, next) => {
ctx.assert(ssbRef.isBlob(blobId), 400, "Invalid blob link");
return next();
})
.param("message", (message, ctx, next) => {
ctx.assert(ssbRef.isMsg(message), 400, "Invalid message link");
return next();
})
.param("feed", (message, ctx, next) => {
ctx.assert(ssbRef.isFeedId(message), 400, "Invalid feed link");
return next();
})
.get("/", async (ctx) => {
const currentConfig = getConfig();
if (currentConfig.ux?.current === "ainav") {
const { ainavHomeView } = require("../views/main_views");
let recentTags = [];
try {
const all = await tagsModel.listTags('top');
recentTags = (all || []).slice(0, 10);
} catch (_) {}
ctx.body = ainavHomeView({ recentTags });
return;
}
const homePage = currentConfig.homePage || "activity";
ctx.redirect(`/${homePage}`);
})
.get("/robots.txt", (ctx) => {
ctx.body = "User-agent: *\nDisallow: /";
})
.get(oasisCheckPath, (ctx) => {
ctx.body = "oasis";
})
.get('/stats', async (ctx) => {
const filter = qf(ctx, 'ALL'), stats = await statsModel.getStats(filter);
const myId = getViewerId();
const myAddress = await bankingModel.getUserAddress(myId);
const addrRows = await bankingModel.listAddressesMerged();
stats.banking = {
myAddress: myAddress || null,
totalAddresses: Array.isArray(addrRows) ? addrRows.length : 0
};
stats.gpgFingerprint = await about.gpgFingerprint(myId).catch(() => '');
try { stats.logsCount = await logsModel.countLogs(); } catch { stats.logsCount = 0; }
const totalMB = parseSizeMB(stats.statsBlobsSize) + parseSizeMB(stats.statsBlockchainSize);
const hcT = parseFloat((totalMB * 0.0002 * 475).toFixed(2));
const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
const hcH = inhabitants > 0 ? parseFloat((hcT / inhabitants).toFixed(2)) : 0;
sharedState.setCarbonHcT(hcT);
sharedState.setCarbonHcH(hcH);
sharedState.setInhabitantCount(inhabitants);
try { stats.ecoTaxStats = await bankingModel.calculateEcoTaxStats(); } catch (_) { stats.ecoTaxStats = null; }
try { stats.userEcoinTax = await bankingModel.getUserEcoinTax(getViewerId()); } catch (_) { stats.userEcoinTax = 0; }
ctx.body = statsView(stats, filter);
})
.get("/public/popular/:period", async (ctx) => {
if (!checkMod(ctx, 'popularMod')) return ctx.redirect('/modules');
const i18n = require("../client/assets/translations/i18n"), lang = ctx.cookies.get('language') || getConfig().language || 'en', t = i18n[lang] || i18n['en'];
const messages = sanitizeMessages(await post.popular({ period: ctx.params.period }));
const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))), spreadMap });
})
.get("/modules", async (ctx) => {
const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts'];
const cfg = getConfig().modules;
ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}));
})
.get('/ai', async (ctx) => {
if (!checkMod(ctx, 'aiMod')) return ctx.redirect('/modules');
startAI();
const lang = ctx.cookies.get('language') || getConfig().language || 'en', historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
require('../views/main_views').setLanguage(lang);
let chatHistory = []; try { chatHistory = JSON.parse(fs.readFileSync(historyPath, 'utf-8')); } catch {}
ctx.body = aiView(chatHistory, getConfig().ai?.prompt?.trim() || '');
})
.get('/games', async (ctx) => {
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
const filter = qf(ctx, 'all');
const hall = await gamesModel.getHallOfFame();
ctx.body = gamesView(filter, hall);
})
.get('/games/:name', async (ctx) => {
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
const { gameShellView } = require('../views/games_view');
ctx.body = gameShellView(ctx.params.name);
})
.post('/games/submit-score', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
const { game, score } = ctx.request.body;
try { await gamesModel.submitScore(game, score); } catch (_) {}
ctx.redirect('/games?filter=scoring');
})
.get('/pixelia', async (ctx) => {
if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; }
const pixelArt = await pixeliaModel.listPixels();
ctx.body = pixeliaView(pixelArt);
})
.get('/melody', async (ctx) => {
if (!checkMod(ctx, 'melodyMod')) { ctx.redirect('/modules'); return; }
const rawFilter = String(ctx.query?.filter || 'mine').toLowerCase();
const filter = rawFilter === 'all' ? 'all' : 'mine';
const viewerId = getViewerId();
const data = await melodyModel.getUserMelody(viewerId);
let bcsAudios = [];
if (filter === 'all' && checkMod(ctx, 'audiosMod')) {
bcsAudios = await audiosModel.listAll({ filter: 'bcs', viewerId }).catch(() => []);
bcsAudios = bcsAudios.filter(a => String(a.author) !== String(viewerId));
}
ctx.body = melodyView({ ...data, filter, bcsAudios });
})
.get('/melody/audio.wav', async (ctx) => {
if (!checkMod(ctx, 'melodyMod')) { ctx.status = 404; return; }
const viewerId = getViewerId();
const data = await melodyModel.getUserMelody(viewerId);
if (!data.sequence || data.sequence.length === 0) { ctx.status = 404; return; }
ctx.type = 'audio/wav';
ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
if (ctx.query.download === '1') {
const safeName = String(viewerId || 'oasis').replace(/["\r\n\\]/g, '');
ctx.set('Content-Disposition', `attachment; filename="${safeName}.wav"`);
}
ctx.body = synthesizeMelodyWav(data.sequence);
})
.post('/melody/upload', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'melodyMod')) { ctx.redirect('/modules'); return; }
if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
const body = ctx.request.body || {};
const stegoMessage = String(body.stegoMessage || '').slice(0, 280);
const viewerId = getViewerId();
const data = await melodyModel.getUserMelody(viewerId);
if (!data.sequence || data.sequence.length === 0) { ctx.redirect('/melody'); return; }
let wav = synthesizeMelodyWav(data.sequence);
const ssbClient = await cooler.open();
const stegoPayload = JSON.stringify({
id: ssbClient.id,
ts: Date.now(),
msg: stegoMessage
});
try { wav = melodyModel.embedTextInWav(wav, stegoPayload); } catch (_) {}
const blobId = await new Promise((resolve, reject) => {
pull(
pull.values([wav]),
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
);
});
const title = `BCS-${viewerId || ssbClient.id}`;
try {
await audiosModel.createBcsAudio(blobId, title, '', data.sequence);
} catch (_) {
ctx.redirect('/melody');
return;
}
ctx.redirect('/audios?filter=bcs');
})
.get('/melody/transcode/:id', async (ctx) => {
if (!checkMod(ctx, 'melodyMod')) { ctx.redirect('/modules'); return; }
if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
const viewerId = getViewerId();
let audio;
try { audio = await audiosModel.getAudioById(ctx.params.id, viewerId); } catch (_) { audio = null; }
if (!audio) { ctx.redirect('/melody?filter=all'); return; }
let itemSize = null;
try { const blk = await blockchainModel.getBlockById(audio.key, viewerId); if (blk && Number.isFinite(blk.size)) itemSize = blk.size; } catch (_) {}
ctx.body = await audioTranscodeDetailView({ audio, itemSize });
})
.post('/melody/transcode/:id', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'melodyMod')) { ctx.redirect('/modules'); return; }
if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
const viewerId = getViewerId();
let audio;
try { audio = await audiosModel.getAudioById(ctx.params.id, viewerId); } catch (_) { audio = null; }
if (!audio) { ctx.redirect('/melody?filter=all'); return; }
const stegoPayload = await runTranscode(audio);
const compositionIds = (audio.bcsComposition || []).map(n => n && n.id).filter(Boolean);
const availableIds = await listAvailableBlockIds(compositionIds);
let itemSize = null;
try { const blk = await blockchainModel.getBlockById(audio.key, viewerId); if (blk && Number.isFinite(blk.size)) itemSize = blk.size; } catch (_) {}
ctx.body = await audioTranscodeDetailView({ audio, decoded: true, stegoPayload, availableIds, itemSize });
})
.get('/blockexplorer', async (ctx) => {
const userId = getViewerId();
const query = ctx.query || {};
const search = {
id: query.id || '',
author: query.author || '',
from: query.from || '',
to: query.to || ''
};
const searchActive = Object.values(search).some(v => String(v || '').trim().length > 0);
let filter = query.filter || 'recent';
if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
const blockchainData = await blockchainModel.listBlockchain(filter, userId, search);
const effPrivateChainIds = await buildEffectivePrivateChainIds();
for (const block of blockchainData) {
block.restricted = isBlockRestricted(block, effPrivateChainIds);
}
const inspectId = String(query.inspect || '').trim();
let inspect = null;
if (inspectId) {
try {
const blk = await blockchainModel.getBlockById(inspectId, userId);
if (blk && blk.id) {
const sizeBytes = Number(blk.size || 0);
const grams = (sizeBytes / (1024 * 1024)) * 0.095;
const ecoinTax = grams * (bankingModel.ECOIN_PER_GRAM_CO2 || 0.1);
inspect = { block: inspectId, found: true, size: sizeBytes, author: blk.author, blockType: blk.type, ecoinTax };
}
} catch (_) {}
if (!inspect) {
try {
const ssbClient = await cooler.open();
const raw = await new Promise((resolve, reject) => ssbClient.get(inspectId, (err, v) => err ? reject(err) : resolve(v)));
if (raw) {
const sizeBytes = Buffer.byteLength(JSON.stringify(raw), 'utf8');
let bType = (raw.content && typeof raw.content === 'object' && raw.content.type) || null;
if (!bType && typeof raw.content === 'string' && raw.content.endsWith('.box')) bType = 'encrypted';
const grams = (sizeBytes / (1024 * 1024)) * 0.095;
const ecoinTax = grams * (bankingModel.ECOIN_PER_GRAM_CO2 || 0.1);
inspect = { block: inspectId, found: true, size: sizeBytes, author: raw.author, blockType: bType || 'unknown', ecoinTax };
}
} catch (_) {}
}
if (!inspect) inspect = { block: inspectId, found: false };
}
ctx.body = renderBlockchainView(blockchainData, filter, userId, search, { inspect });
})
.get('/blockexplorer/block/:id', async (ctx) => {
const userId = getViewerId();
const query = ctx.query || {};
const search = {
id: query.id || '',
author: query.author || '',
from: query.from || '',
to: query.to || ''
};
const searchActive = Object.values(search).some(v => String(v || '').trim().length > 0);
let filter = query.filter || 'recent';
if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
const blockId = ctx.params.id;
let block = await blockchainModel.getBlockById(blockId, userId);
if (!block) block = { id: blockId, notAvailable: true };
const viewMode = query.view || 'block';
let restricted = false;
if (block) {
const effPrivateChainIds = await buildEffectivePrivateChainIds();
restricted = isBlockRestricted(block, effPrivateChainIds);
const c = block.content || {};
if (!restricted && tribeCrypto && (c.encryptedPayload || tribeCrypto.isTribeMsg(c))) {
try {
const decrypted = await tribeCrypto.decryptFromTribe(c, tribesModel);
if (decrypted && !decrypted._undecryptable) {
block = { ...block, content: decrypted };
}
} catch (_) {}
}
}
ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode, restricted);
})
.get("/public/latest", async (ctx) => {
if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; }
const messages = sanitizeMessages(await post.latest());
const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
ctx.body = await latestView({ messages, spreadMap });
})
.get("/public/latest/extended", async (ctx) => {
if (!checkMod(ctx, 'extendedMod')) { ctx.redirect('/modules'); return; }
const messages = sanitizeMessages(await post.latestExtended());
const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
ctx.body = await extendedView({ messages, spreadMap });
})
.get("/public/latest/topics", async (ctx) => {
if (!checkMod(ctx, 'topicsMod')) { ctx.redirect('/modules'); return; }
const messages = sanitizeMessages(await post.latestTopics());
const channels = await post.channels();
const list = channels.map((c) => {
return li(a({ href: `/hashtag/${c}` }, `#${c}`));
});
const prefix = nav(ul(list));
const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
ctx.body = await topicsView({ messages, prefix, spreadMap });
})
.get("/public/latest/summaries", async (ctx) => {
if (!checkMod(ctx, 'summariesMod')) { ctx.redirect('/modules'); return; }
const messages = sanitizeMessages(await post.latestSummaries());
const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
ctx.body = await summaryView({ messages, spreadMap });
})
.get("/public/latest/threads", async (ctx) => {
if (!checkMod(ctx, 'threadsMod')) { ctx.redirect('/modules'); return; }
const messages = sanitizeMessages(await post.latestThreads());
const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
ctx.body = await threadsView({ messages, spreadMap });
})
.get('/author/:feed', async (ctx) => {
const feedId = decodeURIComponent(ctx.params.feed || ''), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1);
if (lt > 0 && gt > 0 && gt >= lt) throw new Error('Given search range is empty');
const visibilityPrefs = await about.visibilityPrefs(feedId).catch(() => null);
const deviceSource = await about.deviceSource(feedId).catch(() => null);
const stats = await inhabitantsModel.getInhabitantStats(feedId, getViewerId()).catch(() => ({}));
const rawPrefs = visibilityPrefs || {};
const needsBanking = (rawPrefs.karma !== false) || rawPrefs.ubi === true;
const needsWallet = rawPrefs.wallet === true;
const needsCarbon = rawPrefs.ecoTax !== false;
const needsLarp = rawPrefs.larpSign === true;
const [description, name, image, messages, firstPost, lastPost, relationship, ecoAddress, bankData, allActions, carbonGrams, larpHouseKey, gpgFingerprint, lastUserActivityTs] = await Promise.all([
about.description(feedId),
about.name(feedId),
about.image(feedId),
post.fromPublicFeed(feedId, gt, lt),
post.firstBy(feedId),
post.latestBy(feedId),
friend.getRelationship(feedId),
needsWallet ? bankingModel.getUserAddress(feedId).catch(() => null) : Promise.resolve(null),
needsBanking ? bankingModel.getBankingData(feedId).catch(() => ({ karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 })) : Promise.resolve({ karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 }),
activityModel.listFeed('all').catch(() => []),
needsCarbon ? getCarbonGramsForFeed(feedId).catch(() => 0) : Promise.resolve(0),
needsLarp ? larpModel.getUserHouse(feedId).catch(() => null) : Promise.resolve(null),
about.gpgFingerprint(feedId).catch(() => ''),
inhabitantsModel.getLastActivityTimestampByUserId(feedId).catch(() => null)
]);
const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null;
const sanitizedMsgs = sanitizeMessages(messages);
const userActions = (allActions || []).filter(a => a && a.author === feedId && a.type !== 'tombstone' && a.type !== 'post');
const normTs = t => { const n = Number(t || 0); return !isFinite(n) || n <= 0 ? 0 : n < 1e12 ? n * 1000 : n; };
const pickTs = obj => { if (!obj) return 0; const v = obj.value || obj; return normTs(v.timestamp || v.ts || v.time || v.meta?.timestamp || 0); };
const latestFromStream = Math.max(pickTs(lastPost), pickTs(firstPost), Array.isArray(messages) && messages.length ? Math.max(...messages.map(pickTs)) : 0);
const fullLastTs = Math.max(latestFromStream, Number(lastUserActivityTs) || 0);
const { bucket: lastActivityBucket } = inhabitantsModel.bucketLastActivity(fullLastTs || null);
const profileItems = await fetchProfileItems(feedId, rawPrefs);
const profileFilterType = String(ctx.query.type || '').toLowerCase();
const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']);
const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id);
const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map());
await warmAuthorNames(allActions, sanitizedMsgs, profileItems);
ctx.body = await authorView({ feedId, oasisVersion: (String(feedId) === String(getViewerId()) ? OASIS_VERSION : null) || await getOasisVersion(feedId), messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, deviceSource, stats, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() });
})
.get("/search", async (ctx) => {
const inhabitantQ = String(ctx.query.inhabitant || '').trim();
if (inhabitantQ && /^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(inhabitantQ)) {
ctx.redirect(`/author/${encodeURIComponent(inhabitantQ)}`);
return;
}
const fromTs = ctx.query.from ? new Date(ctx.query.from).getTime() : null;
const toTs = ctx.query.to ? new Date(ctx.query.to).getTime() : null;
const query = ctx.query.query || '';
if (!query) return ctx.body = await searchView({ messages: [], query, types: [] });
const userId = getViewerId();
const allTribes = await tribesModel.listAll();
const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id));
const applySearchPrivacy = (msgs) => msgs.filter(msg => {
const c = msg.value?.content;
if (!c) return true;
if (Array.isArray(c.recps)) return false;
if (c.type === 'post' && (c.private === true || c.recps)) return false;
if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
if (c.status === 'PRIVATE') return false;
if (c.type === 'shop' && (c.visibility === 'CLOSED' || c.encryptedPayload) && c.author !== userId) return false;
return true;
});
const results = await searchModel.search({ query, types: [] });
const cfgNow = getConfig();
const wishMutuals = cfgNow.wish === 'mutuals';
const wishOnlyLan = cfgNow.wish === 'only-lan';
const mutualCache = wishMutuals ? makeCtxMutualCache() : null;
let lanKeys = null;
if (wishOnlyLan) {
try {
const ssbX = await cooler.open();
const snapshot = await ssbX.conn.dbPeers();
lanKeys = new Set();
for (const entry of (snapshot || [])) {
const data = Array.isArray(entry) ? entry[1] : entry;
if (!data) continue;
if (data.type === 'lan' && data.key) lanKeys.add(data.key);
}
lanKeys.add(userId);
} catch (_) { lanKeys = new Set([userId]); }
}
const accessSets = await getViewerTribeAccessSets(userId);
const finalResults = {};
for (const [type, msgs] of Object.entries(results)) {
const privacyFiltered = applySearchPrivacy(msgs).filter(msg => {
const c = msg.value?.content;
if (c && c.tribeId && accessSets.privateNotAccessible.has(c.tribeId)) return false;
return true;
});
let after = privacyFiltered;
if (wishMutuals) {
const out = [];
for (const m of privacyFiltered) {
const a = m.value?.author || m.value?.content?.author;
if (!a || a === userId) { out.push(m); continue; }
if (await mutualCache(a)) out.push(m);
}
after = out;
}
if (wishOnlyLan && lanKeys) {
after = after.filter(m => {
const a = m.value?.author || m.value?.content?.author;
return a && lanKeys.has(a);
});
}
const mapped = after.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
let scoped = mapped;
if (Number.isFinite(fromTs)) scoped = scoped.filter(m => (m.value?.timestamp || m.timestamp || 0) >= fromTs);
if (Number.isFinite(toTs)) scoped = scoped.filter(m => (m.value?.timestamp || m.timestamp || 0) <= toTs);
if (scoped.length > 0) finalResults[type] = scoped;
}
ctx.body = await searchView({ results: finalResults, query, types: [] });
})
.get("/images", async (ctx) => {
if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
const items = await imagesModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
const fav = await mediaFavorites.getFavoriteSet('images');
let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
enriched = await applyListFilters(enriched, ctx);
await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
const spreadMap = await spreads.forMessages(enriched.map(x => x && x.key));
await warmAuthorNames(enriched);
ctx.body = await imageView(enriched, filter, null, { q, sort, viewerPrefs, spreadMap });
})
.get("/images/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
const img = await imagesModel.getImageById(ctx.params.id, getViewerId());
const fav = await mediaFavorites.getFavoriteSet('images');
ctx.body = await imageView([{ ...img, isFavorite: fav.has(String(img.rootId || img.key)) }], 'edit', img.key, { returnTo: ctx.query.returnTo || '' });
})
.get("/images/:imageId", async (ctx) => {
if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
const { imageId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const img = await imagesModel.getImageById(imageId, getViewerId());
const fav = await mediaFavorites.getFavoriteSet('images');
const comments = await getVoteComments(img.key);
const imgAuthorPrefs = await about.visibilityPrefs(img.author).catch(() => null);
await enrichItemLifetime(img, { key: img.key });
ctx.body = await singleImageView({ ...img, isFavorite: fav.has(String(img.rootId || img.key)), commentCount: comments.length }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/images?filter=${encodeURIComponent(filter)}`, ['/images']), spreads: await spreads.forMessage(img.key), authorPrefs: imgAuthorPrefs });
})
.get("/maps", async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
const { filter = 'all', q = '', lat, lng, zoom, tribeId, title, description, markerLabel, tags, mapType } = ctx.query;
const uid = getViewerId();
const items = await mapsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, viewerId: uid });
const fav = await mediaFavorites.getFavoriteSet('maps');
let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
const myTribeIds = await getUserTribeIds(uid);
enriched = enriched.filter(x => !x.tribeId);
enriched = await applyListFilters(enriched, ctx);
try { enriched = await lifetime.enrichAndFilter(enriched, { getKey: (x) => x.rootId || x.key }); } catch (_) {}
const spreadMap = await spreads.forMessages((enriched || []).map(x => x && (x.key || x.id)));
try {
ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}), spreadMap });
} catch (e) {
console.error("maps render:", e.message);
ctx.body = await mapsView(enriched, filter, null, { q, spreadMap });
}
})
.get("/maps/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
let mapItem;
try { mapItem = await mapsModel.getMapById(ctx.params.id, getViewerId()); } catch (_) { ctx.redirect('/maps?filter=all'); return; }
if (!mapItem) { ctx.redirect('/maps?filter=all'); return; }
if (mapItem.author !== getViewerId()) { ctx.redirect(`/maps/${encodeURIComponent(mapItem.key)}`); return; }
const fav = await mediaFavorites.getFavoriteSet('maps');
ctx.body = await mapsView([{ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }], 'edit', mapItem.key, { returnTo: ctx.query.returnTo || '' });
})
.get("/maps/:mapId", async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
await mapsModel.ingestKeys().catch(() => {});
const { mapId } = ctx.params; const { filter = 'all', q = '', zoom = '0', mkLat = '', mkLng = '', label: mkMarkerLabel = '' } = ctx.query;
const uid = getViewerId();
let mapItem;
try {
mapItem = await mapsModel.getMapById(mapId, uid);
} catch (e) {
ctx.redirect('/maps?filter=all');
return;
}
if (!mapItem) { ctx.redirect('/maps?filter=all'); return; }
const fav = await mediaFavorites.getFavoriteSet('maps');
let tribeMembers = [];
let parentTribe = null;
if (mapItem.tribeId) {
try {
parentTribe = await tribesModel.getTribeById(mapItem.tribeId);
if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
tribeMembers = parentTribe.members;
} catch { ctx.redirect('/tribes'); return; }
} else {
const members = Array.isArray(mapItem.members) ? mapItem.members : [];
const mt = String(mapItem.mapType || '').toUpperCase();
const isOpenAccess = mt === 'OPEN' || mt === 'SINGLE';
if (!isOpenAccess && mapItem.author !== uid && !members.includes(uid)) { ctx.redirect('/maps?filter=all'); return; }
}
if (String(mapItem.mapType || '').toUpperCase() === 'CLOSED' && mapItem.author !== uid) {
ctx.body = tribeAccessDeniedView(parentTribe); return;
}
ctx.body = await singleMapView({ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }, filter, { q, zoom, mkLat, mkLng, mkMarkerLabel, tribeMembers, returnTo: safeReturnTo(ctx, `/maps?filter=${encodeURIComponent(filter)}`, ['/maps']) });
})
.get("/audios", async (ctx) => {
if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
const items = await audiosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
const fav = await mediaFavorites.getFavoriteSet('audios');
let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
enriched = await applyListFilters(enriched, ctx);
await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
const spreadMap = await spreads.forMessages(enriched.map(x => x && x.key));
await warmAuthorNames(enriched);
ctx.body = await audioView(enriched, filter, null, { q, sort, viewerPrefs, spreadMap });
})
.get("/audios/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
const audio = await audiosModel.getAudioById(ctx.params.id, getViewerId());
const fav = await mediaFavorites.getFavoriteSet('audios');
ctx.body = await audioView([{ ...audio, isFavorite: fav.has(String(audio.rootId || audio.key)) }], 'edit', audio.key, { returnTo: ctx.query.returnTo || '' });
})
.get("/audios/:audioId", async (ctx) => {
if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
const { audioId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const audio = await audiosModel.getAudioById(audioId, getViewerId());
const fav = await mediaFavorites.getFavoriteSet('audios');
const comments = await getVoteComments(audio.key);
const audioAuthorPrefs = await about.visibilityPrefs(audio.author).catch(() => null);
await enrichItemLifetime(audio, { key: audio.key });
ctx.body = await singleAudioView({ ...audio, isFavorite: fav.has(String(audio.rootId || audio.key)), commentCount: comments.length }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/audios?filter=${encodeURIComponent(filter)}`, ['/audios']), spreads: await spreads.forMessage(audio.key), authorPrefs: audioAuthorPrefs });
})
.get("/torrents", async (ctx) => {
if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
const { filter = 'all', q = '', sort = 'recent', tribeId = '' } = ctx.query;
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
const items = await torrentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
const fav = await mediaFavorites.getFavoriteSet('torrents');
let enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
enriched = await applyListFilters(enriched, ctx);
const spreadMap = await spreads.forMessages((enriched || []).map(x => x && x.key));
await warmAuthorNames(enriched);
ctx.body = await torrentsView(enriched, filter, null, { q, sort, viewerPrefs, ...(tribeId ? { tribeId } : {}), spreadMap });
})
.get("/torrents/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
const torrent = await torrentsModel.getTorrentById(ctx.params.id, getViewerId());
const fav = await mediaFavorites.getFavoriteSet('torrents');
ctx.body = await torrentsView([{ ...torrent, isFavorite: fav.has(String(torrent.rootId || torrent.key)) }], 'edit', torrent.key, { returnTo: ctx.query.returnTo || '' });
})
.get("/torrents/:torrentId", async (ctx) => {
if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
const { torrentId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const torrent = await torrentsModel.getTorrentById(torrentId, getViewerId());
const fav = await mediaFavorites.getFavoriteSet('torrents');
const comments = await getVoteComments(torrent.key);
const torrentAuthorPrefs = await about.visibilityPrefs(torrent.author).catch(() => null);
await enrichItemLifetime(torrent, { key: torrent.key });
ctx.body = await singleTorrentView({ ...torrent, isFavorite: fav.has(String(torrent.rootId || torrent.key)), commentCount: comments.length }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/torrents?filter=${encodeURIComponent(filter)}`, ['/torrents']), spreads: await spreads.forMessage(torrent.key), authorPrefs: torrentAuthorPrefs });
})
.get("/videos", async (ctx) => {
if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; }
const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
const items = await videosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
const fav = await mediaFavorites.getFavoriteSet('videos');
let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
enriched = await applyListFilters(enriched, ctx);
await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
const spreadMap = await spreads.forMessages(enriched.map(x => x && x.key));
await warmAuthorNames(enriched);
ctx.body = await videoView(enriched, filter, null, { q, sort, viewerPrefs, spreadMap });
})
.get("/videos/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; }
const video = await videosModel.getVideoById(ctx.params.id, getViewerId());
const fav = await mediaFavorites.getFavoriteSet('videos');
ctx.body = await videoView([{ ...video, isFavorite: fav.has(String(video.rootId || video.key)) }], 'edit', video.key, { returnTo: ctx.query.returnTo || '' });
})
.get("/videos/:videoId", async (ctx) => {
if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; }
const { videoId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const video = await videosModel.getVideoById(videoId, getViewerId());
const fav = await mediaFavorites.getFavoriteSet('videos');
const comments = await getVoteComments(video.key);
const videoAuthorPrefs = await about.visibilityPrefs(video.author).catch(() => null);
await enrichItemLifetime(video, { key: video.key });
ctx.body = await singleVideoView({ ...video, isFavorite: fav.has(String(video.rootId || video.key)), commentCount: comments.length }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/videos?filter=${encodeURIComponent(filter)}`, ['/videos']), spreads: await spreads.forMessage(video.key), authorPrefs: videoAuthorPrefs });
})
.get("/documents", async (ctx) => {
const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
const items = await documentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort });
const fav = await mediaFavorites.getFavoriteSet('documents');
let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
enriched = await applyListFilters(enriched, ctx);
await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.rootId || x.key)).length; }));
const spreadMap = await spreads.forMessages(enriched.map(x => x && x.key));
await warmAuthorNames(enriched);
ctx.body = await documentView(enriched, filter, null, { q, sort, viewerPrefs, spreadMap });
})
.get("/documents/edit/:id", async (ctx) => {
const doc = await documentsModel.getDocumentById(ctx.params.id);
const fav = await mediaFavorites.getFavoriteSet('documents');
ctx.body = await documentView([{ ...doc, isFavorite: fav.has(String(doc.rootId || doc.key)) }], 'edit', doc.key, { returnTo: ctx.query.returnTo || '' });
})
.get("/documents/:documentId", async (ctx) => {
const { filter = "all", q = "", sort = "recent" } = ctx.query;
const document = await documentsModel.getDocumentById(ctx.params.documentId);
const fav = await mediaFavorites.getFavoriteSet('documents');
Object.assign(document, { isFavorite: fav.has(String(document.rootId || document.key)) });
const comments = await getVoteComments(document.rootId || document.key);
const docAuthorPrefs = await about.visibilityPrefs(document.author).catch(() => null);
await enrichItemLifetime(document, { key: document.key });
ctx.body = await singleDocumentView(withCount(document, comments), filter, comments, {
q, sort,
returnTo: safeReturnTo(ctx, `/documents/${encodeURIComponent(document.key)}?filter=${encodeURIComponent(filter)}${q ? `&q=${encodeURIComponent(q)}` : ""}${sort ? `&sort=${encodeURIComponent(sort)}` : ""}`, ["/documents"]),
spreads: await spreads.forMessage(document.key),
authorPrefs: docAuthorPrefs
});
})
.get('/cv', async ctx => {
const cv = await cvModel.getCVByUserId()
ctx.body = await cvView(cv)
})
.get('/cv/create', async ctx => {
ctx.body = await createCVView()
})
.get('/cv/edit/:id', async ctx => {
const cv = await cvModel.getCVByUserId()
ctx.body = await createCVView(cv, true)
})
.get('/pm', async ctx => {
const { recipients = '', subject = '', quote = '', preview = '', fileerror = '' } = ctx.query;
const quoted = quote ? quote.split('\n').map(l => '> ' + l).join('\n') + '\n\n' : '';
const showPreview = preview === '1';
ctx.body = await pmView(recipients, subject, quoted, showPreview, '', false, null, false, String(fileerror || ''));
})
.get('/inbox', async ctx => {
if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; }
const messages = await buildInboxMessages();
await refreshInboxCount(messages);
const notice = ctx.query.filestatus === 'unavailable' ? 'unavailable' : (ctx.query.filekey === 'bad' ? 'badkey' : '');
ctx.body = await privateView({ messages }, ctx.query.filter || undefined, null, notice);
})
.post('/inbox/decrypt', koaBody(), async ctx => {
if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; }
const { id, key, returnFilter } = ctx.request.body;
const messages = await buildInboxMessages();
let decrypted = null;
const msg = messages.find(m => m && m.key === id);
if (msg && msg.value?.content?.crypter && typeof key === 'string' && key) {
try {
decrypted = { key: id, text: cipherModel.decryptData(msg.value.content.text, key) };
} catch (_) {
decrypted = { key: id, error: true };
}
}
ctx.body = await privateView({ messages }, returnFilter || 'all', decrypted);
})
.get('/tags', async ctx => {
const filter = qf(ctx), tags = await tagsModel.listTags(filter);
ctx.body = await tagsView(tags, filter);
})
.get('/reports', async ctx => {
const filter = qf(ctx);
let reports = await enrichWithComments(await reportsModel.listAll());
reports = await applyListFilters(reports, ctx);
try { reports = await lifetime.enrichAndFilter(reports); } catch (_) {}
await enrichMsgSize(reports);
const spreadMap = await spreads.forMessages((reports || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(reports);
ctx.body = await reportView(reports, filter, null, ctx.query.category || '', { spreadMap, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' });
})
.get('/reports/edit/:id', async ctx => {
const report = await reportsModel.getReportById(ctx.params.id);
ctx.body = await reportView([report], 'edit', ctx.params.id);
})
.get('/reports/:reportId', async ctx => {
const { reportId } = ctx.params, filter = qf(ctx), report = await reportsModel.getReportById(reportId);
const comments = await getVoteComments(reportId);
await enrichMsgSize([report]);
await enrichItemLifetime(report);
ctx.body = await singleReportView(withCount(report, comments), filter, comments, { spreads: await spreads.forMessage(report.id).catch(() => null) });
})
.get('/trending', async (ctx) => {
const filter = qf(ctx, 'TOP');
let { filtered = [] } = await trendingModel.listTrending(filter);
filtered = await applyListFilters(filtered, ctx);
const spreadMap = new Map();
const results = await Promise.all(filtered.map(it => it && it.key ? spreads.forMessage(it.key).catch(() => null) : Promise.resolve(null)));
filtered.forEach((it, i) => { if (it && it.key && results[i]) spreadMap.set(it.key, results[i]); });
await warmAuthorNames(filtered);
ctx.body = await trendingView(filtered, filter, trendingModel.categories, spreadMap);
})
.get('/agenda', async (ctx) => {
const filter = qf(ctx);
let data = await agendaModel.listAgenda(filter);
if (Array.isArray(data)) data = await applyListFilters(data, ctx);
ctx.body = await agendaView(data, filter);
})
.get("/hashtag/:hashtag", async (ctx) => {
const { hashtag } = ctx.params;
const messages = sanitizeMessages(await post.fromHashtag(hashtag));
ctx.body = await hashtagView({ hashtag, messages });
})
.get('/inhabitants', async (ctx) => {
const filter = qf(ctx);
const query = { search: ctx.query.search || '' };
const userId = getViewerId();
if (filter === 'pending') {
try { await scanPendingFollows(userId); } catch (e) {}
const pending = viewerFilters.listPending();
const enriched = await Promise.all(pending.map(async (p) => {
let name = p.followerId;
try { name = await about.name(p.followerId); } catch (_) {}
return { ...p, name };
}));
ctx.body = renderPendingFollows(enriched);
return;
}
if (['CVs', 'MATCHSKILLS'].includes(filter)) {
Object.assign(query, {
location: ctx.query.location || '',
language: ctx.query.language || '',
skills: ctx.query.skills || ''
});
}
const inhabitants = await inhabitantsModel.listInhabitants({ filter, ...query });
const [addresses, karmaList] = await Promise.all([
bankingModel.listAddressesMerged(),
Promise.all(
inhabitants.map(async (u) => {
try {
const bank = await bankingModel.getBankingData(u.id);
return { id: u.id, karmaScore: bank?.karmaScore || 0, estimatedUBI: bank?.estimatedUBI || 0, lastClaimedDate: bank?.lastClaimedDate || null, totalClaimed: bank?.totalClaimed || 0 };
} catch {
return { id: u.id, karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 };
}
})
)
]);
const activityList = await Promise.all(
inhabitants.map(async (u) => {
try {
const ts = await inhabitantsModel.getLastActivityTimestampByUserId(u.id);
const { bucket } = inhabitantsModel.bucketLastActivity(ts || null);
return { id: u.id, lastActivityBucket: bucket };
} catch {
return { id: u.id, lastActivityBucket: 'red' };
}
})
);
const prefsList = await Promise.all(
inhabitants.map(async (u) => {
try { return { id: u.id, prefs: await about.visibilityPrefs(u.id) }; }
catch { return { id: u.id, prefs: null }; }
})
);
const relList = await Promise.all(
inhabitants.map(async (u) => {
try { return { id: u.id, rel: await friend.getRelationship(u.id) }; }
catch { return { id: u.id, rel: null }; }
})
);
const gpgList = await Promise.all(
inhabitants.map(async (u) => {
try { return { id: u.id, fp: await about.gpgFingerprint(u.id) }; }
catch { return { id: u.id, fp: '' }; }
})
);
const deviceList = await Promise.all(
inhabitants.map(async (u) => {
try { return { id: u.id, src: await about.deviceSource(u.id) }; }
catch { return { id: u.id, src: null }; }
})
);
const statsList = await Promise.all(
inhabitants.map(async (u) => {
try { return { id: u.id, stats: await inhabitantsModel.getInhabitantStats(u.id, userId) }; }
catch { return { id: u.id, stats: {} }; }
})
);
const carbonList = await Promise.all(
inhabitants.map(async (u) => {
try { return { id: u.id, carbon: await getCarbonGramsForFeed(u.id) }; }
catch { return { id: u.id, carbon: 0 }; }
})
);
const larpHouseByUser = await (async () => {
const ssbX = await cooler.open();
return new Promise((resolve) => {
const byAuthor = new Map();
pull(
ssbX.createLogStream({ reverse: true }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'larpJoinHouse') return;
const a = m.value.author;
const ts = m.value.timestamp || 0;
const prev = byAuthor.get(a);
if (!prev || ts > prev.ts) byAuthor.set(a, { house: c.house, ts });
}, () => {
const out = new Map();
for (const [a, v] of byAuthor.entries()) out.set(a, v.house);
resolve(out);
})
);
});
})();
const addrMap = new Map(addresses.map(x => [x.id, x.address]));
const karmaMap = new Map(karmaList.map(x => [x.id, { karmaScore: x.karmaScore, estimatedUBI: x.estimatedUBI, lastClaimedDate: x.lastClaimedDate, totalClaimed: x.totalClaimed }]));
const activityMap = new Map(activityList.map(x => [x.id, x.lastActivityBucket]));
const prefsMap = new Map(prefsList.map(x => [x.id, x.prefs]));
const relMap = new Map(relList.map(x => [x.id, x.rel]));
const gpgMap = new Map(gpgList.map(x => [x.id, x.fp]));
const deviceMap = new Map(deviceList.map(x => [x.id, x.src]));
const statsMap = new Map(statsList.map(x => [x.id, x.stats]));
const carbonMap = new Map(carbonList.map(x => [x.id, x.carbon]));
let enriched = inhabitants.map(u => {
const kd = karmaMap.get(u.id) || {};
const lhKey = larpHouseByUser.get(u.id) || 'academia';
const lh = larpModel.getHouse(lhKey);
return {
...u,
ecoAddress: addrMap.get(u.id) || null,
karmaScore: kd.karmaScore ?? (typeof u.karmaScore === 'number' ? u.karmaScore : 0),
estimatedUBI: kd.estimatedUBI || 0,
lastClaimedDate: kd.lastClaimedDate || null,
totalClaimed: kd.totalClaimed || 0,
lastActivityBucket: activityMap.get(u.id),
visibilityPrefs: prefsMap.get(u.id) || null,
relationship: relMap.get(u.id) || null,
larpHouse: lh ? { key: lhKey, ...lh } : null,
gpgFingerprint: gpgMap.get(u.id) || '',
deviceSource: deviceMap.get(u.id) || null,
stats: statsMap.get(u.id) || {},
carbonGrams: carbonMap.get(u.id) || 0
};
});
enriched = enriched.filter(u => u.id === userId || u.lastActivityBucket !== 'red');
if (filter === 'TOP KARMA') {
enriched = enriched.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
}
if (filter === 'TOP ACTIVITY') {
const order = { green: 0, orange: 1, red: 2 };
enriched = enriched.sort(
(a, b) => (order[a.lastActivityBucket] ?? 3) - (order[b.lastActivityBucket] ?? 3)
);
}
ctx.body = await inhabitantsView(enriched, filter, query, userId, fediverseModel.hasAccount());
})
.get('/inhabitant/:id', async (ctx) => {
const id = ctx.params.id;
const aboutModel = about;
const [aboutMsg, cv, feed, photo, bank, lastTs, visibilityPrefs, carbonGrams, larpHouseKey, deviceSource] = await Promise.all([
inhabitantsModel.getLatestAboutById(id),
inhabitantsModel.getCVByUserId(id),
inhabitantsModel.getFeedByUserId(id),
inhabitantsModel.getPhotoUrlByUserId(id, 256),
bankingModel.getBankingData(id).catch(() => ({ karmaScore: 0 })),
inhabitantsModel.getLastActivityTimestampByUserId(id).catch(() => null),
aboutModel.visibilityPrefs(id).catch(() => null),
getCarbonGramsForFeed(id).catch(() => 0),
larpModel.getUserHouse(id).catch(() => null),
aboutModel.deviceSource(id).catch(() => null)
]);
const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null;
const bucketInfo = inhabitantsModel.bucketLastActivity(lastTs || null);
const currentUserId = getViewerId();
const stats = await inhabitantsModel.getInhabitantStats(id, currentUserId).catch(() => ({}));
const karmaScore = bank && typeof bank.karmaScore === 'number' ? bank.karmaScore : 0;
const estimatedUBI = bank?.estimatedUBI || 0;
const lastClaimedDate = bank?.lastClaimedDate || null;
const totalClaimed = bank?.totalClaimed || 0;
const oasisVersion = (String(id) === String(currentUserId) ? OASIS_VERSION : null) || await getOasisVersion(id);
await warmAuthorNames(feed);
ctx.body = await inhabitantsProfileView({ about: aboutMsg, cv, feed, photo, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, carbonGrams, larpHouse, lastActivityBucket: bucketInfo.bucket, viewedId: id, visibilityPrefs, deviceSource, stats, oasisVersion }, currentUserId, fediverseModel.hasAccount());
})
.get('/parliament', async (ctx) => {
if (!checkMod(ctx, 'parliamentMod')) return ctx.redirect('/modules');
const filter = (ctx.query.filter || 'government').toLowerCase();
await ensureTerm();
await runSweepOnce();
const [governmentCardRaw, candidatures, proposals, futureLaws, canPropose, laws, historical, leaders, revocations, futureRevocations, revocationsEnactedCount, inhabitantsAll] = await Promise.all([
parliamentModel.getGovernmentCard(),
parliamentModel.listCandidatures('OPEN'),
parliamentModel.listProposalsCurrent(),
parliamentModel.listFutureLawsCurrent(),
parliamentModel.canPropose(),
parliamentModel.listLaws(),
parliamentModel.listHistorical(),
parliamentModel.listLeaders(),
parliamentModel.listRevocationsCurrent(),
parliamentModel.listFutureRevocationsCurrent(),
parliamentModel.countRevocationsEnacted(),
inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true })
]);
const inhabitantsTotal = Array.isArray(inhabitantsAll) ? inhabitantsAll.length : 0;
let governmentCard = governmentCardRaw ? { ...governmentCardRaw, inhabitantsTotal, expired: false } : null;
if (!governmentCard) {
const latest = await parliamentModel.getLatestGovernmentCard().catch(() => null);
if (latest) governmentCard = { ...latest, inhabitantsTotal };
}
const leader = pickLeader(candidatures || []);
const getActorMeta = async (type, id) => (type === 'tribe' || type === 'inhabitant') ? parliamentModel.getActorMeta({ targetType: type, targetId: id }) : null;
const leaderMeta = leader ? await getActorMeta(leader.targetType || leader.powerType || 'inhabitant', leader.targetId || leader.powerId) : null;
const powerMeta = governmentCard ? await getActorMeta(governmentCard.powerType, governmentCard.powerId) : null;
const buildMetas = async (items, limit) => {
const m = {};
for (const g of (items || []).slice(0, limit)) {
if (g.powerType === 'tribe' || g.powerType === 'inhabitant') {
const k = `${g.powerType}:${g.powerId}`;
if (!m[k]) m[k] = await getActorMeta(g.powerType, g.powerId);
}
}
return m;
};
const [historicalMetas, leadersMetas] = await Promise.all([buildMetas(historical, 12), buildMetas(leaders, 20)]);
ctx.body = await parliamentView({
filter,
inhabitantsTotal,
governmentCard,
candidatures,
proposals,
futureLaws,
canPropose,
laws,
historical,
leaders,
leaderMeta,
powerMeta,
historicalMetas,
leadersMetas,
revocations,
futureRevocations,
revocationsEnactedCount
});
})
.get('/courts', async (ctx) => {
if (!checkMod(ctx, 'courtsMod')) return ctx.redirect('/modules');
try { await courtsModel.sweepCases(); } catch (_) {}
const filter = String(ctx.query.filter || 'cases').toLowerCase(), search = String(ctx.query.search || '').trim();
const currentUserId = await courtsModel.getCurrentUserId();
const state = { filter, search, cases: [], myCases: [], trials: [], history: [], nominations: [], userId: currentUserId, prefill: { titleSuffix: ctx.query.titleSuffix || '', respondentId: ctx.query.respondentId || '', method: (ctx.query.method || '').toUpperCase(), titlePreset: ctx.query.titlePreset || '' } };
const searchFilter = (items) => !search ? items : items.filter(c => [c.title, c.description].some(s => String(s || '').toLowerCase().includes(search.toLowerCase())));
if (filter === 'cases') state.cases = searchFilter((await courtsModel.listCases('open')).map(c => ({ ...c, respondent: c.respondentId || c.respondent })));
if (filter === 'mycases' || filter === 'actions') {
let myCases = searchFilter(await courtsModel.listCasesForUser(currentUserId));
if (filter === 'actions') myCases = myCases.filter(c => {
const s = String(c.status || '').toUpperCase(), m = String(c.method || '').toUpperCase(), id = String(currentUserId || '');
const roles = { a: !!c.isAccuser, r: !!c.isRespondent, m: !!c.isMediator, j: !!c.isJudge, d: !!c.isDictator };
const open = s === 'OPEN' || s === 'IN_PROGRESS';
return (roles.r && open) || (m === 'JUDGE' && !c.judgeId && (roles.a || roles.r) && open) || ((roles.j || roles.d || roles.m) && s === 'OPEN') || ((roles.a || roles.r || roles.m) && m === 'MEDIATION' && open) || ((roles.a || roles.r || roles.m || roles.j || roles.d) && open);
});
state.myCases = myCases;
}
if (filter === 'judges') state.nominations = (await courtsModel.listNominations()) || [];
if (filter === 'history') {
const id = String(currentUserId || '');
state.history = searchFilter((await courtsModel.listCases('history')).map(c => {
const ma = Array.isArray(c.mediatorsAccuser) ? c.mediatorsAccuser : [], mr = Array.isArray(c.mediatorsRespondent) ? c.mediatorsRespondent : [];
return { ...c, respondent: c.respondentId || c.respondent, mine: [c.accuser, c.respondentId, c.judgeId].map(String).includes(id) || ma.includes(id) || mr.includes(id), publicDetails: c.publicPrefAccuser && c.publicPrefRespondent, decidedAt: c.verdictAt || c.closedAt || c.decidedAt };
}));
}
ctx.body = await courtsView(state);
})
.get('/courts/cases/:id', async (ctx) => {
if (!checkMod(ctx, 'courtsMod')) return ctx.redirect('/modules');
ctx.body = await courtsCaseView({ caseData: await courtsModel.getCaseDetails({ caseId: ctx.params.id }).catch(() => null) });
})
.get('/tribes', async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId();
const filter = qf(ctx), search = ctx.query.search || '';
let tribes = await tribesModel.listTribesForViewer(uid);
tribes = dedupeLarpHouseTribes(tribes);
try {
if (tribes.some(t => (Array.isArray(t.tags) ? t.tags : []).some(x => String(x).startsWith('larp-')))) {
const houses = await larpModel.listHousesWithCounts().catch(() => []);
const countByTag = new Map(houses.map(h => [`larp-${h.name}`, h.memberCount]));
for (const t of tribes) {
const lt = (Array.isArray(t.tags) ? t.tags : []).find(x => String(x).startsWith('larp-'));
if (lt && countByTag.has(lt)) t.memberCount = countByTag.get(lt);
}
}
} catch (_) {}
let filteredTribes = search ? tribes.filter(t => t.title.toLowerCase().includes(search.toLowerCase())) : tribes;
try { filteredTribes = await lifetime.enrichAndFilter(filteredTribes, { getKey: (x) => x.id || x.key }); } catch (_) {}
try { await tribesModel.enrichOpenInvites(filteredTribes); } catch (_) {}
for (const t of filteredTribes) {
if (t.openInviteCode) t.openInviteQr = `/qr-invite/tribe/${encodeURIComponent(t.id)}`;
}
ctx.body = await tribesView(filteredTribes, filter, null, ctx.query, tribes);
})
.get('/tribes/create', async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
ctx.body = await tribesView([], 'create', null)
})
.get('/tribes/edit/:id', async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id)
ctx.body = await tribesView([tribe], 'edit', ctx.params.id)
})
.get('/tribe/:tribeId', async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
await tribesModel.forceSync().catch(() => {});
await tribesModel.processIncomingKeys().catch(() => {});
await tribesModel.ensureTribeKeyDistribution(ctx.params.tribeId).catch(() => {});
await tribesModel.ensureFollowTribeMembers(ctx.params.tribeId).catch(() => {});
await tribesModel.pruneOrphanKeys().catch(() => {});
const listByTribeAllChain = async (tribeId, contentType) => {
const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]);
const results = await Promise.all(chainIds.map(id => tribesContentModel.listByTribe(id, contentType).catch(() => [])));
const seen = new Set();
return results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; });
};
const tribe = await tribesModel.getTribeById(ctx.params.tribeId).catch(() => null);
if (!tribe) { ctx.redirect('/tribes'); return; }
const uid = getViewerId();
const query = { feedFilter: 'TOP', ...ctx.query };
if (tribe.isAnonymous === true && !tribe.members.includes(uid)) {
ctx.redirect('/tribes');
return;
}
const section = ctx.query.section || 'activity';
const contentTypeMap = { events: 'event', tasks: 'task', reports: 'report', votations: 'votation', market: 'market', jobs: 'job', projects: 'project', media: 'media' };
const mediaSections = { 'media-audio': 'media', 'media-video': 'media', 'media-images': 'media', 'media-documents': 'media', 'media-bookmarks': 'media', 'images': 'media', 'audios': 'media', 'videos': 'media', 'documents': 'media', 'bookmarks': 'media' };
let sectionData = null;
if (section === 'inhabitants') {
const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true });
sectionData = allInhabitants.filter(u => tribe.members.includes(u.id));
} else if (section === 'feed') {
sectionData = await listByTribeAllChain(tribe.id, 'feed').catch(() => []);
} else if (section === 'forum') {
const forums = await listByTribeAllChain(tribe.id, 'forum');
const replies = await listByTribeAllChain(tribe.id, 'forum-reply');
sectionData = [...forums, ...replies];
} else if (section === 'subtribes') {
sectionData = await tribesModel.listSubTribes(tribe.id, uid);
} else if (mediaSections[section]) {
sectionData = await listByTribeAllChain(tribe.id, 'media');
} else if (contentTypeMap[section]) {
sectionData = await listByTribeAllChain(tribe.id, contentTypeMap[section]);
} else if (section === 'activity') {
const allContent = await listByTribeAllChain(tribe.id, null);
const subTribes = await tribesModel.listSubTribes(tribe.id, uid);
const subContent = [];
for (const st of subTribes) {
const stItems = await listByTribeAllChain(st.id, null).catch(() => []);
subContent.push(...stItems.map(item => ({ ...item, tribeName: st.title })));
}
const [allPadsRaw, allChatsRaw, allCalsRaw, allMapsRaw, allTorrentsRaw, tribeChain] = await Promise.all([
padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []),
torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
]);
const tribeChainSet = new Set(tribeChain);
const toStandalone = (type, url) => (item) => ({ contentType: type, id: item.rootId || item.key, title: item.title || '', author: item.author, createdAt: item.createdAt, directUrl: url(item) });
const standaloneItems = [
...allPadsRaw.filter(p => tribeChainSet.has(p.tribeId)).map(toStandalone('pad', p => `/pads/${encodeURIComponent(p.rootId)}`)),
...allChatsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('chat', c => `/chats/${encodeURIComponent(c.rootId || c.key)}`)),
...allCalsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('calendar', c => `/calendars/${encodeURIComponent(c.rootId)}`)),
...allMapsRaw.filter(m => tribeChainSet.has(m.tribeId)).map(toStandalone('map', m => `/maps/${encodeURIComponent(m.key || m.id)}`)),
...allTorrentsRaw.filter(t => tribeChainSet.has(t.tribeId)).map(toStandalone('torrent', t => `/torrents/${encodeURIComponent(t.rootId || t.key)}`))
];
const subTribeNameById = new Map(subTribes.map(st => [st.id, st.title]));
const chatThreadItems = [];
for (const c of allChatsRaw) {
const inChain = tribeChainSet.has(c.tribeId);
const inSub = subTribeNameById.has(c.tribeId);
if (!inChain && !inSub) continue;
const root = c.rootId || c.key;
if (!root) continue;
let msgs = [];
try { msgs = await chatsModel.listMessages(root); } catch (_) { msgs = []; }
const withText = (msgs || []).filter(m => m && typeof m.text === 'string' && m.text.trim());
if (!withText.length) continue;
withText.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
const last = withText[withText.length - 1];
chatThreadItems.push({
contentType: 'chatThread',
id: root,
title: c.title || '',
author: c.author,
createdAt: last.createdAt,
_ts: Date.parse(last.createdAt) || 0,
directUrl: `/chats/${encodeURIComponent(root)}`,
...(inSub ? { tribeName: subTribeNameById.get(c.tribeId) } : {}),
description: c.description || '',
replies: withText.slice(-6).map(m => ({ author: m.author, text: m.text, createdAt: m.createdAt }))
});
}
const combined = [...allContent, ...subContent, ...standaloneItems, ...chatThreadItems];
const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true });
const allMembers = [...new Set([...tribe.members, ...subTribes.flatMap(st => st.members || [])])];
const memberMap = new Map(allInhabitants.filter(u => allMembers.includes(u.id)).map(u => [u.id, u]));
const activities = combined.map(item => ({ ...item, authorName: memberMap.get(item.author)?.name || item.author, timestamp: Date.parse(item.createdAt) || item._ts || 0 })).sort((a, b) => b.timestamp - a.timestamp);
sectionData = { activities, memberMap };
} else if (section === 'trending') {
const allContent = await listByTribeAllChain(tribe.id, null);
const period = ctx.query.period || 'all';
let items = allContent.filter(i => i.contentType !== 'forum-reply' && i.contentType !== 'pixelia');
if (period === 'day') items = items.filter(i => (Date.parse(i.createdAt) || i._ts || 0) >= Date.now() - 86400000);
else if (period === 'week') items = items.filter(i => (Date.parse(i.createdAt) || i._ts || 0) >= Date.now() - 7 * 86400000);
items.sort((a, b) => {
const score = i => (i.refeeds || 0) + (Array.isArray(i.attendees) ? i.attendees.length : 0) + Object.values(i.votes || {}).reduce((s, arr) => s + (Array.isArray(arr) ? arr.length : 0), 0) + (Array.isArray(i.assignees) ? i.assignees.length : 0) + (Array.isArray(i.opinions_inhabitants) ? i.opinions_inhabitants.length : 0);
return score(b) - score(a);
});
sectionData = { items, period };
} else if (section === 'tags') {
const allContent = await listByTribeAllChain(tribe.id, null);
const [allPadsT, allChatsT, allCalsT, allMapsT, allTorrentsT, subTribesT, tribeChainT] = await Promise.all([
padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []),
torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
tribesModel.listSubTribes(tribe.id, uid).catch(() => []),
tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
]);
const tribeChainSetT = new Set(tribeChainT);
const standaloneTagged = [
...allPadsT.filter(p => tribeChainSetT.has(p.tribeId)).map(p => ({ ...p, contentType: 'pad', id: p.rootId || p.key })),
...allChatsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'chat', id: c.rootId || c.key })),
...allCalsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'calendar', id: c.rootId || c.key })),
...allMapsT.filter(m => tribeChainSetT.has(m.tribeId)).map(m => ({ ...m, contentType: 'map', id: m.rootId || m.key })),
...allTorrentsT.filter(t => tribeChainSetT.has(t.tribeId)).map(t => ({ ...t, contentType: 'torrent', id: t.rootId || t.key })),
...subTribesT.map(st => ({ ...st, contentType: 'tribe', tags: Array.isArray(st.tags) ? st.tags : [], title: st.title, description: st.description, author: st.author, createdAt: st.createdAt }))
];
const allTaggable = [...allContent, ...standaloneTagged];
const tagMap = new Map();
for (const item of allTaggable) {
for (const tag of (item.tags || []).filter(Boolean)) {
const lower = String(tag).toLowerCase().trim();
if (!lower) continue;
if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0, items: [] });
const entry = tagMap.get(lower);
entry.count++;
entry.items.push(item);
}
}
const selectedTag = (ctx.query.tag || '').toLowerCase().trim();
sectionData = { tags: [...tagMap.values()].sort((a, b) => b.count - a.count), selectedTag, filteredItems: selectedTag && tagMap.has(selectedTag) ? tagMap.get(selectedTag).items : [] };
} else if (section === 'maps') {
const [allMaps, tribeChain] = await Promise.all([
mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []),
tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
]);
const tribeChainSet = new Set(tribeChain);
sectionData = allMaps.filter(m => tribeChainSet.has(m.tribeId));
} else if (section === 'pads') {
const [allPads, tribeChain] = await Promise.all([
padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
]);
const tribeChainSet = new Set(tribeChain);
sectionData = allPads.filter(p => tribeChainSet.has(p.tribeId));
} else if (section === 'chats') {
const [allChats, tribeChain] = await Promise.all([
chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
]);
const tribeChainSet = new Set(tribeChain);
sectionData = allChats.filter(c => tribeChainSet.has(c.tribeId));
} else if (section === 'calendars') {
const [allCals, tribeChain] = await Promise.all([
calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
]);
const tribeChainSet = new Set(tribeChain);
sectionData = allCals.filter(c => tribeChainSet.has(c.tribeId));
} else if (section === 'torrents') {
const [allTorrents, tribeChain] = await Promise.all([
torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
]);
const tribeChainSet = new Set(tribeChain);
const standaloneTorrents = allTorrents.filter(t => tribeChainSet.has(t.tribeId));
const mediaTorrents = (await listByTribeAllChain(tribe.id, 'media').catch(() => []))
.filter(m => m.mediaType === 'torrent')
.map(m => ({
key: m.id,
rootId: m.id,
title: m.title || '',
description: m.description || '',
url: m.image || '',
tags: Array.isArray(m.tags) ? m.tags : [],
author: m.author,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
tribeId: m.tribeId,
_isMedia: true
}));
sectionData = [...standaloneTorrents, ...mediaTorrents].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
} else if (section === 'search') {
const sq = (ctx.query.q || '').trim().toLowerCase();
let results = [];
if (sq.length >= 2) {
const allContent = await listByTribeAllChain(tribe.id, null);
results = allContent.filter(item => (item.title || '').toLowerCase().includes(sq) || (item.description || '').toLowerCase().includes(sq) || (item.tags || []).join(' ').toLowerCase().includes(sq));
}
sectionData = { query: ctx.query.q || '', results };
} else if (section === 'opinions') {
const allContent = await listByTribeAllChain(tribe.id, null);
const opinionated = allContent.filter(i => i.opinions && Object.keys(i.opinions).length > 0).sort((a, b) => {
const sum = o => Object.values(o.opinions || {}).reduce((s, n) => s + n, 0);
return sum(b) - sum(a);
});
sectionData = { items: allContent.filter(i => i.contentType !== 'forum-reply' && i.contentType !== 'pixelia'), opinionated };
} else if (section === 'pixelia') {
const pixels = await listByTribeAllChain(tribe.id, 'pixelia');
const coordMap = new Map();
for (const px of pixels) { const existing = coordMap.get(px.title); if (!existing || (Date.parse(px.createdAt) || 0) > (Date.parse(existing.createdAt) || 0)) coordMap.set(px.title, px); }
sectionData = { pixels: [...coordMap.values()] };
} else if (section === 'overview') {
const events = await listByTribeAllChain(tribe.id, 'event').catch(() => []);
const tasks = await listByTribeAllChain(tribe.id, 'task').catch(() => []);
const feed = await listByTribeAllChain(tribe.id, 'feed').catch(() => []);
sectionData = { events, tasks, feed };
} else if (section === 'governance') {
if (tribe.parentTribeId) { ctx.redirect(`/tribe/${encodeURIComponent(tribe.id)}?section=activity`); return; }
const gf = String(ctx.query.filter || 'government');
const isCreator = tribe.author === uid;
const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
if (isCreator) { try { await parliamentModel.tribe.ensureTerm(tribe.id); } catch (_) {} }
let [term, candidatures, rules, globalTermBase] = await Promise.all([
parliamentModel.tribe.getCurrentTerm(tribe.id).catch(() => null),
parliamentModel.tribe.listCandidatures(tribe.id).catch(() => []),
parliamentModel.tribe.listRules(tribe.id).catch(() => []),
parliamentModel.getCurrentTerm().catch(() => null)
]);
if (term && term.startAt) {
const tribeTermDays = parliamentModel.tribe.TERM_DAYS || 60;
let cStart = moment(term.startAt);
let cEnd = term.endAt ? moment(term.endAt) : cStart.clone().add(tribeTermDays, 'days');
const now = moment();
let rolled = false;
while (cEnd.isValid() && cEnd.isSameOrBefore(now)) { cStart = cEnd.clone(); cEnd = cEnd.clone().add(tribeTermDays, 'days'); rolled = true; }
if (rolled) term = { ...term, startAt: cStart.toISOString(), endAt: cEnd.toISOString(), method: 'ANARCHY', powerType: 'none', powerId: null, leaders: [], winnerVotes: 0, totalVotes: 0 };
}
const globalStart = globalTermBase?.startAt || null;
const alreadyPublishedThisGlobalCycle = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribe.id, globalStart).catch(() => false);
const leaders = Array.isArray(term?.leaders) ? term.leaders : [];
const hasElectedCandidate = Array.isArray(candidatures) && candidatures.some(c => (c.status || 'OPEN') === 'OPEN' && Number(c.votes || 0) > 0);
sectionData = { filter: gf, term, candidatures, rules, leaders, isCreator, isMember, canPublishToGlobal: isMember || isCreator, alreadyPublishedThisGlobalCycle, hasElectedCandidate };
}
const subTribes = await tribesModel.listSubTribes(tribe.id, uid);
tribe.subTribes = subTribes;
if (tribe.parentTribeId) {
try { tribe.parentTribe = await tribesModel.getTribeById(tribe.parentTribeId); } catch (_) {}
}
const resolveItemMentions = async (items) => {
if (!Array.isArray(items)) return items;
for (const item of items) {
if (item.description) item.description = await resolveMentionText(item.description);
}
return items;
};
if (Array.isArray(sectionData)) {
await resolveItemMentions(sectionData);
} else if (sectionData && typeof sectionData === 'object') {
if (sectionData.activities) await resolveItemMentions(sectionData.activities);
if (sectionData.items) await resolveItemMentions(sectionData.items);
if (sectionData.results) await resolveItemMentions(sectionData.results);
if (sectionData.events) await resolveItemMentions(sectionData.events);
if (sectionData.tasks) await resolveItemMentions(sectionData.tasks);
if (sectionData.feed) await resolveItemMentions(sectionData.feed);
}
try {
const HK = ['academia','solaris','arrakis','terraverde','unsystem','dogma','helix','quark','hermandad'];
const larpTag = Array.isArray(tribe.tags) ? tribe.tags.find(x => String(x).startsWith('larp-')) : null;
if (larpTag) {
const norm = larpTag.slice(5).toLowerCase().replace(/[^a-z0-9]/g, '');
if (HK.includes(norm)) {
tribe.larpHouseKey = norm;
const houses = await larpModel.listHousesWithCounts().catch(() => []);
const h = (houses || []).find(x => x.key === norm);
if (h && typeof h.memberCount === 'number') tribe.memberCount = h.memberCount;
}
}
} catch (_) {}
try {
const openInvite = await tribesModel.getOpenInvite(tribe.id).catch(() => null);
if (openInvite) {
tribe.openInviteCode = openInvite.code;
tribe.openInviteBy = openInvite.by;
tribe.openInviteQr = `/qr-invite/tribe/${encodeURIComponent(tribe.id)}`;
}
} catch (_) {}
try { await warmAuthorNames(Array.isArray(sectionData) ? sectionData : (sectionData && sectionData.items) || []); } catch (_) {}
ctx.body = await tribeView(tribe, uid, query, section, sectionData);
})
.get('/activity', async ctx => {
const filter = qf(ctx, 'recent'), userId = getViewerId();
const q = String((ctx.query && ctx.query.q) || '');
try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {}
try { await bankingModel.getUserEngagementScore(userId); } catch (_) {}
let allActions = await activityModel.listFeed('all');
for (const action of allActions) {
if (action.type === 'pad') {
const c = action.value?.content || action.content || {};
const rootId = action.id || action.key || '';
const decrypted = await padsModel.decryptContent(c, rootId);
if (decrypted.title) {
if (action.value?.content) { action.value.content.title = decrypted.title; action.value.content.deadline = decrypted.deadline; }
else if (action.content) { action.content.title = decrypted.title; action.content.deadline = decrypted.deadline; }
}
}
if (action.type === 'parliamentProposal' || action.type === 'parliamentRevocation') {
const c = action.value?.content || action.content || {};
if (c.voteId) {
const derived = await parliamentModel.deriveProposalStatus(c.method, c.voteId).catch(() => null);
if (derived) {
if (action.value?.content) action.value.content.status = derived;
else if (action.content) action.content.status = derived;
}
}
}
}
if (filter === 'larp') {
try {
const gk = larpModel.getGoverningHouseKey();
const myHouse = await larpModel.getUserHouse(userId).catch(() => null);
const houseKeys = [...new Set([gk, myHouse].filter(Boolean))];
const seenPosts = new Set();
for (const hk of houseKeys) {
const posts = await larpModel.listHousePosts(hk, { viewerHouse: myHouse, isGoverning: hk === gk });
for (const p of posts) {
if (seenPosts.has(p.id)) continue;
seenPosts.add(p.id);
allActions.push({ type: 'larpHousePost', id: p.id, author: p.author, ts: p.ts, content: { type: 'larpHousePost', text: p.text, createdAt: p.createdAt, house: hk } });
}
}
} catch (_) {}
}
allActions = await applyListFilters(allActions, ctx);
const spreadMap = new Map();
const SPREADABLE = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']);
const targets = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && SPREADABLE.has(a.type));
const results = await Promise.all(targets.map(a => spreads.forMessage(a.id).catch(() => null)));
targets.forEach((a, i) => { if (results[i]) spreadMap.set(a.id, results[i]); });
try {
const uniqAuthors = new Set();
const collect = (v) => { if (v && /^@.+\.ed25519$/.test(String(v))) uniqAuthors.add(String(v)); };
for (const a of (allActions || [])) {
collect(a && a.author);
const c = a && (a.value?.content || a.content);
if (c) { collect(c.author); collect(c.proposer); collect(c.organizer); }
const replies = c && Array.isArray(c.replies) ? c.replies : [];
for (const r of replies) collect(r && r.author);
}
const nameOf = {};
await Promise.all([...uniqAuthors].slice(0, 400).map(async (fid) => {
try { const nm = await about.name(fid); if (nm && nm !== fid.slice(1, 9)) nameOf[fid] = nm; } catch (_) {}
}));
if (Object.keys(nameOf).length) {
for (const a of (allActions || [])) {
const c = a && (a.value?.content || a.content);
const map = {};
const push = (id) => { const s = id && String(id); if (s && nameOf[s]) map[s] = nameOf[s]; };
push(a && a.author); push(c && c.author); push(c && c.proposer); push(c && c.organizer);
const replies = c && Array.isArray(c.replies) ? c.replies : [];
for (const r of replies) push(r && r.author);
if (Object.keys(map).length) a.authorNames = map;
}
}
} catch (_) {}
ctx.body = activityView(allActions, filter, userId, q, { spreadMap });
})
.get("/profile", async (ctx) => {
const myFeedId = await meta.myFeedId(), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1);
if (lt > 0 && gt > 0 && gt >= lt) throw new Error("Given search range is empty");
const visibilityPrefs = await about.visibilityPrefs(myFeedId).catch(() => null);
const rawPrefs = visibilityPrefs || {};
const needsBanking = (rawPrefs.karma !== false) || rawPrefs.ubi === true;
const needsWallet = rawPrefs.wallet === true;
const needsCarbon = rawPrefs.ecoTax !== false;
const needsLarp = rawPrefs.larpSign === true;
const [description, name, image, messages, firstPost, lastPost, ecoAddress, bankData, allActions, carbonGrams, larpHouseKey, gpgFingerprint, lastUserActivityTs] = await Promise.all([
about.description(myFeedId),
about.name(myFeedId),
about.image(myFeedId),
post.fromPublicFeed(myFeedId, gt, lt),
post.firstBy(myFeedId),
post.latestBy(myFeedId),
needsWallet ? bankingModel.getUserAddress(myFeedId).catch(() => null) : Promise.resolve(null),
needsBanking ? bankingModel.getBankingData(myFeedId).catch(() => ({ karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 })) : Promise.resolve({ karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 }),
activityModel.listFeed('all').catch(() => []),
needsCarbon ? getCarbonGramsForFeed(myFeedId).catch(() => 0) : Promise.resolve(0),
needsLarp ? larpModel.getUserHouse(myFeedId).catch(() => null) : Promise.resolve(null),
about.gpgFingerprint(myFeedId).catch(() => ''),
inhabitantsModel.getLastActivityTimestampByUserId(myFeedId).catch(() => null)
]);
const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null;
const stats = await inhabitantsModel.getInhabitantStats(myFeedId, myFeedId).catch(() => ({}));
const userActions = (allActions || []).filter(a => a && a.author === myFeedId && a.type !== 'tombstone' && a.type !== 'post');
const normTs = t => { const n = Number(t || 0); return !isFinite(n) || n <= 0 ? 0 : n < 1e12 ? n * 1000 : n; };
const pickTs = obj => { if (!obj) return 0; const v = obj.value || obj; return normTs(v.timestamp || v.ts || v.time || v.meta?.timestamp || 0); };
const postActivityTs = Math.max(Array.isArray(messages) && messages.length ? Math.max(...messages.map(pickTs)) : 0, pickTs(lastPost), pickTs(firstPost));
const lastActivityTs = Math.max(postActivityTs, Number(lastUserActivityTs) || 0);
const { bucket: lastActivityBucket } = inhabitantsModel.bucketLastActivity(lastActivityTs || null);
const baseUrl = resolveExternalBaseUrl(ctx);
const profileItems = await fetchProfileItems(myFeedId, rawPrefs);
const profileFilterType = String(ctx.query.type || '').toLowerCase();
const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']);
const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id);
const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map());
ctx.body = await authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() });
})
.get("/profile/edit", async (ctx) => {
const myFeedId = await meta.myFeedId();
const [visibilityPrefs, name, description, gpgFingerprint] = await Promise.all([
about.visibilityPrefs(myFeedId).catch(() => null),
about.name(myFeedId).catch(() => ''),
about.description(myFeedId).catch(() => ''),
about.gpgFingerprint(myFeedId).catch(() => '')
]);
ctx.body = await editProfileView({
name,
description,
visibilityPrefs: visibilityPrefs || {},
feedId: myFeedId,
baseUrl: resolveExternalBaseUrl(ctx),
gpgFingerprint
});
})
.post("/profile/edit", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const imageFile = ctx.request.files?.image;
const mime = imageFile?.mimetype || imageFile?.type || '';
const isImage = mime.startsWith('image/');
const imageData = isImage && imageFile?.filepath ? await promisesFs.readFile(imageFile.filepath).catch(() => undefined) : undefined;
const gpgFile = ctx.request.files?.gpgKey;
let gpgKeyFingerprint;
let gpgKeyBlob;
if (gpgFile?.filepath && Number(gpgFile.size || 0) > 0) {
if (Number(gpgFile.size) > 51200) throw new Error("GPG key file too large (max 50KB)");
const raw = await promisesFs.readFile(gpgFile.filepath, 'utf8').catch(() => '');
const armored = String(raw || '').trim();
if (armored) {
if (!armored.includes('-----BEGIN PGP PUBLIC KEY BLOCK-----')) {
throw new Error("Invalid GPG key: only armored public keys are accepted");
}
if (armored.includes('-----BEGIN PGP PRIVATE KEY BLOCK-----')) {
throw new Error("Refusing to publish a private GPG key");
}
const openpgp = require('../server/node_modules/openpgp');
const parsed = await openpgp.readKey({ armoredKey: armored }).catch(() => null);
if (!parsed) throw new Error("Could not parse GPG key");
if (parsed.isPrivate && parsed.isPrivate()) throw new Error("Refusing to publish a private GPG key");
gpgKeyFingerprint = String(parsed.getFingerprint() || '').toUpperCase();
gpgKeyBlob = Buffer.from(armored, 'utf8');
const myFeedId = await meta.myFeedId();
const keysDir = path.join(ssbConfig.path, 'keys');
await promisesFs.mkdir(keysDir, { recursive: true }).catch(() => {});
const safeName = encodeURIComponent(myFeedId) + '.asc';
await promisesFs.writeFile(path.join(keysDir, safeName), armored, 'utf8');
}
}
const body = ctx.request.body || {};
const flag = (v) => v === '1' || v === 'on' || v === true;
const clearnetShops = flag(body.vis_clearnetShops);
const clearnetJobs = flag(body.vis_clearnetJobs);
const clearnetEvents = flag(body.vis_clearnetEvents);
const clearnetProjects = flag(body.vis_clearnetProjects);
const clearnetPosts = flag(body.vis_clearnetPosts);
const clearnetAudios = flag(body.vis_clearnetAudios);
const clearnetVideos = flag(body.vis_clearnetVideos);
const clearnetImages = flag(body.vis_clearnetImages);
const clearnetDocuments = flag(body.vis_clearnetDocuments);
const clearnetTorrents = flag(body.vis_clearnetTorrents);
const clearnetBookmarks = flag(body.vis_clearnetBookmarks);
const profileShops = flag(body.vis_profileShops);
const profileJobs = flag(body.vis_profileJobs);
const profileEvents = flag(body.vis_profileEvents);
const profileProjects = flag(body.vis_profileProjects);
const profilePosts = flag(body.vis_profilePosts);
const profileAudios = flag(body.vis_profileAudios);
const profileVideos = flag(body.vis_profileVideos);
const profileImages = flag(body.vis_profileImages);
const profileDocuments = flag(body.vis_profileDocuments);
const profileTorrents = flag(body.vis_profileTorrents);
const profileBookmarks = flag(body.vis_profileBookmarks);
const visibilityPrefs = {
activity: flag(body.vis_activity),
device: true,
karma: flag(body.vis_karma),
ubi: flag(body.vis_ubi),
wallet: flag(body.vis_wallet),
ecoTax: flag(body.vis_ecoTax),
larpSign: flag(body.vis_larpSign),
gpg: flag(body.vis_gpg),
fediverse: flag(body.vis_fediverse),
fediverseHandle: typeof body.fediverseHandle === 'string' ? body.fediverseHandle : '',
clearnet: clearnetShops || clearnetJobs || clearnetEvents || clearnetProjects || clearnetPosts || clearnetAudios || clearnetVideos || clearnetImages || clearnetDocuments || clearnetTorrents || clearnetBookmarks,
clearnetShops,
clearnetJobs,
clearnetEvents,
clearnetProjects,
clearnetPosts,
clearnetAudios,
clearnetVideos,
clearnetImages,
clearnetDocuments,
clearnetTorrents,
clearnetBookmarks,
profileShops,
profileJobs,
profileEvents,
profileProjects,
profilePosts,
profileAudios,
profileVideos,
profileImages,
profileDocuments,
profileTorrents,
profileBookmarks
};
await post.publishProfileEdit({
name: stripDangerousTags(String(body.name || '')),
description: stripDangerousTags(String(body.description || '')),
image: imageData,
visibilityPrefs,
gpgFingerprint: gpgKeyFingerprint,
gpgBlob: gpgKeyBlob
});
ctx.redirect("/profile");
})
.post("/profile/gpg/remove", koaBody(), async (ctx) => {
const myFeedId = await meta.myFeedId();
const keyPath = path.join(ssbConfig.path, 'keys', encodeURIComponent(myFeedId) + '.asc');
await promisesFs.unlink(keyPath).catch(() => {});
await post.publishProfileEdit({ gpgFingerprint: '', gpgBlobId: '' });
ctx.redirect("/profile");
})
.get("/profile/:feed/gpg.asc", async (ctx) => {
const feedId = ctx.params.feed;
const blobId = await about.gpgBlobId(feedId).catch(() => '');
let armored = '';
if (blobId) {
const ssbX = await cooler.open();
armored = await new Promise((resolve) => {
const chunks = [];
pull(
ssbX.blobs.get(blobId),
pull.collect((err, arr) => {
if (err || !arr) return resolve('');
for (const ch of arr) chunks.push(Buffer.isBuffer(ch) ? ch : Buffer.from(ch));
resolve(Buffer.concat(chunks).toString('utf8'));
})
);
});
}
if (!armored) {
const keyPath = path.join(ssbConfig.path, 'keys', encodeURIComponent(feedId) + '.asc');
armored = await promisesFs.readFile(keyPath, 'utf8').catch(() => '');
}
if (!armored) { ctx.status = 404; ctx.body = ''; return; }
ctx.set('Content-Type', 'application/pgp-keys; charset=utf-8');
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(feedId)}.asc"`);
ctx.body = armored;
})
.post("/profile/clearnet-toggle", koaBody(), async (ctx) => {
const myFeedId = await meta.myFeedId();
const current = await about.visibilityPrefs(myFeedId).catch(() => null) || {};
const subKeys = [
'clearnetShops', 'clearnetJobs', 'clearnetEvents', 'clearnetProjects',
'clearnetPosts', 'clearnetAudios', 'clearnetVideos', 'clearnetImages',
'clearnetDocuments', 'clearnetTorrents'
];
const anyEnabled = subKeys.some(k => current[k] === true) || current.clearnet === true;
const nextOn = !anyEnabled;
const nextPrefs = { ...current, clearnet: nextOn };
for (const k of subKeys) nextPrefs[k] = nextOn;
try {
await post.publishProfileEdit({ visibilityPrefs: nextPrefs });
} catch (e) { console.error('profile/clearnet-toggle:', e.message); }
ctx.redirect('/profile');
})
.get("/publish/custom", async (ctx) => {
ctx.body = await publishCustomView();
})
.get("/json/:message", async (ctx) => {
if (config.public) {
throw new Error(
"Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again."
);
}
const { message } = ctx.params;
ctx.type = "application/json";
const json = async (message) => {
const json = await meta.get(message);
return JSON.stringify(json, null, 2);
};
ctx.body = await json(message);
})
.get("/blob/:blobId", serveBlob)
.get("/c/blob/:cnBlobId", async (ctx) => {
const blobId = ctx.params.cnBlobId;
if (!isBlob(blobId)) { ctx.status = 404; ctx.body = ''; return; }
let buffer;
try { buffer = await blob.getResolved({ blobId }); } catch (_) {}
if (!buffer) {
ctx.status = 404; ctx.body = ''; return;
}
let mime = 'application/octet-stream';
try {
const ft = await FileType.fromBuffer(buffer);
if (ft && ft.mime) mime = ft.mime;
} catch (_) {}
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
const head = buffer.slice(0, 128).toString('ascii');
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
}
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
if (mime.startsWith('image/') && typeof sharp === 'function') {
try {
const img = sharp(buffer, { failOn: 'none' });
const meta = await img.metadata().catch(() => ({}));
const format = (meta.format && ['jpeg','png','webp','avif','gif'].includes(meta.format)) ? meta.format : 'jpeg';
const out = await img.rotate().toFormat(format).toBuffer();
ctx.type = `image/${format === 'jpeg' ? 'jpeg' : format}`;
ctx.body = out;
return;
} catch (_) {}
}
ctx.type = mime;
if (mime === 'application/x-bittorrent') {
ctx.set('Content-Disposition', `attachment; filename="download.torrent"`);
}
ctx.body = buffer;
})
.get("/qr/:feedId", async (ctx) => {
const feedId = decodeURIComponent(ctx.params.feedId || '');
const reqSize = parseInt(ctx.query.size, 10);
const width = Number.isFinite(reqSize) ? Math.max(64, Math.min(512, reqSize)) : 240;
try {
const QRCode = require('../server/node_modules/qrcode');
const targetUrl = `oasis://author/${encodeURIComponent(feedId)}`;
const buf = await QRCode.toBuffer(targetUrl, { type: 'png', width, margin: 1, errorCorrectionLevel: 'M' });
ctx.set('Content-Type', 'image/png');
ctx.set('Cache-Control', 'public, max-age=86400');
ctx.body = buf;
} catch (e) {
ctx.status = 500;
ctx.body = '';
}
})
.get("/qr-invite/tribe/:id", async (ctx) => {
if (!checkMod(ctx, 'tribesMod')) { ctx.status = 404; ctx.body = ''; return; }
try {
const oi = await tribesModel.getOpenInvite(ctx.params.id).catch(() => null);
if (!oi) { ctx.status = 404; ctx.body = ''; return; }
const QRCode = require('../server/node_modules/qrcode');
const joinUrl = `${resolveExternalBaseUrl(ctx)}/tribes/open-invite/join/${encodeURIComponent(ctx.params.id)}`;
const buf = await QRCode.toBuffer(joinUrl, { type: 'png', width: 240, margin: 1, errorCorrectionLevel: 'M' });
ctx.set('Content-Type', 'image/png');
ctx.set('Cache-Control', 'public, max-age=3600');
ctx.body = buf;
} catch (e) { ctx.status = 500; ctx.body = ''; }
})
.get("/qr-invite/shop/:id", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.status = 404; ctx.body = ''; return; }
try {
const oi = await shopsModel.getOpenInvite(ctx.params.id).catch(() => null);
if (!oi) { ctx.status = 404; ctx.body = ''; return; }
const QRCode = require('../server/node_modules/qrcode');
const joinUrl = `${resolveExternalBaseUrl(ctx)}/shops/open-invite/join/${encodeURIComponent(ctx.params.id)}`;
const buf = await QRCode.toBuffer(joinUrl, { type: 'png', width: 240, margin: 1, errorCorrectionLevel: 'M' });
ctx.set('Content-Type', 'image/png');
ctx.set('Cache-Control', 'public, max-age=3600');
ctx.body = buf;
} catch (e) { ctx.status = 500; ctx.body = ''; }
})
.get("/qr-clearnet/:feedId", async (ctx) => {
const feedId = decodeURIComponent(ctx.params.feedId || '');
const reqSize = parseInt(ctx.query.size, 10);
const width = Number.isFinite(reqSize) ? Math.max(64, Math.min(512, reqSize)) : 240;
try {
const QRCode = require('../server/node_modules/qrcode');
const targetUrl = `${resolveExternalBaseUrl(ctx)}/c/inhabitant/${encodeURIComponent(feedId)}`;
const buf = await QRCode.toBuffer(targetUrl, { type: 'png', width, margin: 1, errorCorrectionLevel: 'M' });
ctx.set('Content-Type', 'image/png');
ctx.set('Cache-Control', 'public, max-age=86400');
ctx.body = buf;
} catch (e) {
ctx.status = 500;
ctx.body = '';
}
})
.get("/image/:imageSize/:blobId", async (ctx) => {
const { blobId, imageSize } = ctx.params;
const size = Number(imageSize);
const fallbackPixel = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
);
const fakeImage = () => {
if (typeof sharp !== "function") {
return Promise.resolve(fallbackPixel);
}
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0.5 },
},
}).png().toBuffer();
};
try {
const buffer = await blob.getResolved({ blobId });
if (!buffer) {
ctx.set("Content-Type", "image/png");
ctx.body = await fakeImage();
return;
}
const fileType = await FileType.fromBuffer(buffer);
const mimeType = fileType?.mime || "application/octet-stream";
ctx.set("Content-Type", mimeType);
if (typeof sharp === "function") {
ctx.body = await sharp(buffer)
.resize(size, size)
.png()
.toBuffer();
} else {
ctx.body = buffer;
}
} catch (err) {
ctx.set("Content-Type", "image/png");
ctx.body = await fakeImage();
}
})
.get("/settings", async (ctx) => {
const cfg = getConfig(), theme = ctx.cookies.get("theme") || "Dark-SNH";
ctx.body = await settingsView({ theme, version: version.toString(), aiPrompt: cfg.ai?.prompt || "", fediverseAccount: fediverseModel.getAccount(), fediverseError: typeof ctx.query.fediverseError === "string" ? ctx.query.fediverseError : "" });
})
.get("/peers", async (ctx) => {
const { discoveredPeers, unknownPeers } = await meta.discovered();
const lanBroadcastActive = !ssbConfig.pub;
const peerMap = new Map();
const mergePeer = (key, info) => {
if (!key) return;
const prev = peerMap.get(key) || {};
peerMap.set(key, {
key,
host: info.host || prev.host || null,
port: info.port || prev.port || null,
state: info.state || prev.state || 'idle',
stateChange: info.stateChange || prev.stateChange || null,
source: info.source || prev.source || null
});
};
try {
const gossipPathLocal = path.join(os.homedir(), '.ssb', 'gossip.json');
let gossip = [];
try { gossip = JSON.parse(await promisesFs.readFile(gossipPathLocal, 'utf8')); } catch (_) {}
if (Array.isArray(gossip)) {
for (const g of gossip) {
if (!g || !g.key) continue;
mergePeer(g.key, { host: g.host, port: g.port, state: g.state, stateChange: g.stateChange, source: 'gossip.json' });
}
}
} catch (_) {}
try {
const ssbX = await cooler.open();
try {
const gp = (ssbX.gossip && typeof ssbX.gossip.peers === 'function') ? ssbX.gossip.peers() : [];
for (const p of (gp || [])) {
if (!p || !p.key) continue;
mergePeer(p.key, { host: p.host, port: p.port, state: p.state, stateChange: p.stateChange, source: 'gossip' });
}
} catch (_) {}
try {
const snapshot = (ssbX.conn && typeof ssbX.conn.dbPeers === 'function') ? await ssbX.conn.dbPeers() : [];
for (const entry of (snapshot || [])) {
const data = Array.isArray(entry) ? entry[1] : entry;
const addr = Array.isArray(entry) ? entry[0] : null;
if (!data || !data.key) continue;
let host = data.host, port = data.port;
if ((!host || !port) && addr) {
const m = String(addr).match(/^net:([^:]+):(\d+)/);
if (m) { host = host || m[1]; port = port || Number(m[2]); }
}
mergePeer(data.key, { host, port, state: data.state, stateChange: data.stateChange, source: 'conn.dbPeers' });
}
} catch (_) {}
try {
const livePeers = (ssbX.peers && typeof ssbX.peers === 'object') ? ssbX.peers : {};
for (const rawKey of Object.keys(livePeers)) {
if (!rawKey || rawKey === ssbX.id) continue;
const rpcs = livePeers[rawKey];
if (!Array.isArray(rpcs) || rpcs.length === 0) continue;
const addr = rpcs[0]?.stream?.address || null;
let host = null, port = null;
if (addr) {
const m = String(addr).match(/^net:([^:]+):(\d+)/);
if (m) { host = m[1]; port = Number(m[2]); }
}
mergePeer(rawKey, { host, port, state: 'connected', source: 'rpc' });
}
} catch (_) {}
try {
if (ssbX.conn && typeof ssbX.conn.stagedPeers === 'function') {
const staged = await new Promise((resolve) => {
try {
pull(
ssbX.conn.stagedPeers(),
pull.take(1),
pull.collect((err, results) => {
if (err || !results || !results[0]) return resolve([]);
resolve(Array.isArray(results[0]) ? results[0] : []);
})
);
} catch (_) { resolve([]); }
});
for (const entry of staged) {
const data = Array.isArray(entry) ? entry[1] : entry;
const addr = Array.isArray(entry) ? entry[0] : null;
if (!data || !data.key) continue;
let host = data.host, port = data.port;
if ((!host || !port) && addr) {
const m = String(addr).match(/^net:([^:]+):(\d+)/);
if (m) { host = host || m[1]; port = port || Number(m[2]); }
}
mergePeer(data.key, { host, port, state: 'staged', source: data.type === 'lan' ? 'lan' : (data.type || 'staged') });
}
}
} catch (_) {}
} catch (_) {}
const PEER_IDLE_MAX_MS = 10 * 24 * 60 * 60 * 1000;
const nowTs = Date.now();
const technicalPeers = Array.from(peerMap.values())
.filter(p => {
const st = String(p.state || '');
if (st === 'connected' || st === 'connecting' || st === 'staged') return true;
const sc = Number(p.stateChange) || 0;
return !sc || (nowTs - sc) < PEER_IDLE_MAX_MS;
})
.sort((a, b) => (a.state === 'connected' ? -1 : 1) - (b.state === 'connected' ? -1 : 1));
ctx.body = await peersView({ onlinePeers: await meta.onlinePeers(), discoveredPeers, unknownPeers, lanBroadcastActive, technicalPeers });
})
.get("/graphos", async (ctx) => {
if (!checkMod(ctx, 'graphosMod')) return ctx.redirect('/modules');
try {
const ssbForLan = await cooler.open();
try { if (ssbForLan.lan && typeof ssbForLan.lan.stop === 'function') ssbForLan.lan.stop(); } catch (_) {}
try { if (ssbForLan.lan && typeof ssbForLan.lan.start === 'function') ssbForLan.lan.start(); } catch (_) {}
} catch (_) {}
const filter = String(ctx.query?.filter || 'ALL').toUpperCase() === 'MINE' ? 'MINE' : 'ALL';
const onlinePeers = await meta.onlinePeers();
const { discoveredPeers, unknownPeers } = filter === 'MINE'
? { discoveredPeers: [], unknownPeers: [] }
: await meta.discovered();
const ssb = await require('../client/gui')({ offline: require('../server/ssb_config').offline }).open();
const myId = ssb.id;
const shortId = (key) => {
const core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '');
return '@' + core.slice(0, 8) + '…';
};
const resolveName = async (key) => {
try {
const n = await about.name(key);
if (!n) return shortId(key);
if (n === 'Redacted') return shortId(key);
if (n === String(key).replace(/^@/, '').slice(0, 8)) return shortId(key);
return n;
} catch {
return shortId(key);
}
};
const GRAPHOS_MAX_NODES = 48;
const FEED_RE = /^@[A-Za-z0-9+/]+=?\.ed25519$/;
const centerRaw = ctx.query?.center ? decodeURIComponent(String(ctx.query.center)) : null;
const isFocus = !!(centerRaw && FEED_RE.test(centerRaw) && centerRaw !== myId);
if (isFocus) {
const graph = await new Promise((res) => {
try { ssb.friends.graph((err, g) => res(err ? {} : (g || {}))); } catch (_) { res({}); }
});
const rel = graph[centerRaw] || {};
const followedAll = Object.entries(rel).filter(([, v]) => v >= 0).map(([k]) => k).filter(k => k !== centerRaw && k !== myId);
const followedIds = followedAll.slice(0, GRAPHOS_MAX_NODES);
const focusPeers = await Promise.all(followedIds.map(async (k) => ({ key: k, name: await resolveName(k), kind: 'discovered' })));
focusPeers.push({ key: myId, name: await resolveName(myId), kind: 'me' });
const focusMe = { key: centerRaw, name: await resolveName(centerRaw), kind: 'online' };
const kpis = { total: followedAll.length, online: 0, discovered: followedAll.length, unknown: 0 };
ctx.body = await graphosView({ filter, me: focusMe, peers: focusPeers, kpis, focus: String(focusMe.name || centerRaw).replace(/^@/, ''), focusId: centerRaw, shown: followedIds.length, total: followedAll.length });
return;
}
const keysOf = (entries) => {
const s = new Set();
for (const [, data] of entries) if (data && data.key) s.add(data.key);
return s;
};
const onlineKeys = keysOf(onlinePeers);
const unknownKeys = keysOf(unknownPeers);
// Nodes partition into discovered vs unknown (total = discovered + unknown);
// "online" is an orthogonal status flag (a subset of the nodes).
const seen = new Set([myId]);
const nodes = [];
const addFrom = (entries) => {
for (const [, data] of entries) {
if (!data || !data.key || seen.has(data.key)) continue;
seen.add(data.key);
const isOnline = onlineKeys.has(data.key);
const cat = unknownKeys.has(data.key) ? 'unknown' : 'discovered';
nodes.push({ key: data.key, isOnline, cat, kind: isOnline ? 'online' : cat });
}
};
addFrom(onlinePeers);
addFrom(discoveredPeers);
addFrom(unknownPeers);
const shownNodes = nodes.slice(0, GRAPHOS_MAX_NODES);
const peers = await Promise.all(shownNodes.map(async (p) => ({
key: p.key,
name: await resolveName(p.key),
kind: p.kind
})));
const me = { key: myId, name: await resolveName(myId), kind: 'online' };
const discoveredCount = nodes.filter(n => n.cat === 'discovered').length;
const unknownCount = nodes.filter(n => n.cat === 'unknown').length;
const onlineCount = nodes.filter(n => n.isOnline).length;
const kpis = {
total: discoveredCount + unknownCount,
online: onlineCount,
discovered: discoveredCount,
unknown: unknownCount
};
ctx.body = await graphosView({ filter, me, peers, kpis, shown: peers.length, total: nodes.length });
})
.get("/larp", async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const rawFilter = String(ctx.query?.filter || 'ruling').toLowerCase();
const filter = rawFilter === 'houses' ? 'houses' : rawFilter === 'rules' ? 'rules' : 'ruling';
const myFeedId = await meta.myFeedId();
const [houses, myHouseKey] = await Promise.all([
larpModel.listHousesWithCounts(),
larpModel.getUserHouse(myFeedId).catch(() => null)
]);
const cycle = larpModel.computeCycle();
const governingKey = larpModel.getGoverningHouseKey();
const governingHouseRaw = larpModel.getHouse(governingKey);
const governingHouse = { key: governingKey, ...governingHouseRaw, memberCount: (houses.find(h => h.key === governingKey) || {}).memberCount || 0 };
let governingMembers = [];
let governingPosts = [];
if (filter === 'ruling') {
[governingMembers, governingPosts] = await Promise.all([
larpModel.getMembersOfHouse(governingKey),
larpModel.listHousePosts(governingKey, { viewerHouse: myHouseKey, isGoverning: true })
]);
}
const canPost = myHouseKey === governingKey;
try {
const allTribes = await tribesModel.listAll();
await tribesModel.enrichOpenInvites(allTribes);
for (const h of houses) {
const tr = allTribes.find(t => (t.tags || []).some(x => String(x).startsWith('larp-') && String(x).slice(5).toLowerCase() === h.key));
if (tr && tr.openInviteCode) h.openInviteCode = tr.openInviteCode;
}
} catch (_) {}
ctx.body = larpListView({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost });
})
.get("/larp/test", async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const myFeedId = await meta.myFeedId();
const [myHouseKey, houses, testStatus] = await Promise.all([
larpModel.getUserHouse(myFeedId).catch(() => null),
larpModel.listHousesWithCounts(),
larpModel.canTakeTest(myFeedId)
]);
if (myHouseKey !== 'academia') return ctx.redirect(myHouseKey ? `/larp/${encodeURIComponent(myHouseKey)}` : '/larp');
const cycle = larpModel.computeCycle();
const governingKey = larpModel.getGoverningHouseKey();
const questions = testStatus.allowed ? larpModel.getProfileTest() : [];
ctx.body = larpTestView({ questions, cycle, houses, myHouseKey, governingKey, testStatus });
})
.post("/larp/test", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const myFeedId = await meta.myFeedId();
const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null);
if (myHouseKey !== 'academia') return ctx.redirect(myHouseKey ? `/larp/${encodeURIComponent(myHouseKey)}` : '/larp');
const body = ctx.request.body || {};
const answers = [];
for (let i = 0; i < larpModel.TEST_QUESTIONS_COUNT; i += 1) {
const raw = body[`q${i}`];
const n = parseInt(raw, 10);
answers.push(Number.isFinite(n) ? n : -1);
}
let result = null;
try { result = await larpModel.submitProfileTest({ answers }); } catch (_) { result = null; }
if (!result || result.ok === false) return ctx.redirect('/larp/test');
const [houses, cycle] = [await larpModel.listHousesWithCounts(), larpModel.computeCycle()];
const governingKey = larpModel.getGoverningHouseKey();
const houseKey = result.house || 'academia';
const houseRaw = larpModel.getHouse(houseKey) || {};
const house = { key: houseKey, ...houseRaw };
ctx.body = larpTestResultView({ house, result, cycle, houses, myHouseKey: houseKey, governingKey });
})
.get("/larp/test/:house", async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
return ctx.redirect('/larp/test');
})
.post("/larp/invite/create", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const houseKey = String((ctx.request.body && ctx.request.body.house) || '').toLowerCase();
let result = null;
try { result = await larpModel.createHouseInvite(houseKey); } catch (_) { result = null; }
if (!result) return ctx.redirect(`/larp/${encodeURIComponent(houseKey || '')}`);
ctx.redirect(`/larp/${encodeURIComponent(houseKey)}?invite=${encodeURIComponent(result.code)}`);
})
.post("/larp/invite/redeem", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const code = String((ctx.request.body && ctx.request.body.code) || '').trim();
const ret = String((ctx.request.body && ctx.request.body.returnTo) || '').trim();
let result = null;
try { result = await larpModel.redeemHouseInvite(code); } catch (_) { result = null; }
if (result && result.ok) return ctx.redirect(`/larp/${encodeURIComponent(result.house)}`);
const back = ret && ret.startsWith('/') ? ret : '/larp';
ctx.redirect(back);
})
.get("/larp/:house", async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const houseKey = String(ctx.params.house || '').toLowerCase();
const houseRaw = larpModel.getHouse(houseKey);
if (!houseRaw) return ctx.redirect('/larp');
const myFeedId = await meta.myFeedId();
const [members, myHouseKey, houses] = await Promise.all([
larpModel.getMembersOfHouse(houseKey),
larpModel.getUserHouse(myFeedId).catch(() => null),
larpModel.listHousesWithCounts()
]);
if (myHouseKey === houseKey) {
Promise.resolve().then(async () => {
try { await larpModel.ensureHouseTribe(houseKey); } catch (_) {}
try { await larpModel.issueAutoInvitesForMyHouse(); } catch (_) {}
try { await larpModel.redeemPendingAutoInvites(); } catch (_) {}
});
}
const cycle = larpModel.computeCycle();
const governingKey = larpModel.getGoverningHouseKey();
const canPost = myHouseKey === houseKey;
const posts = await larpModel.listHousePosts(houseKey, { viewerHouse: myHouseKey, isGoverning: houseKey === governingKey });
const testStatus = houseKey === 'academia' ? await larpModel.canTakeTest(myFeedId) : null;
const questions = (houseKey === 'academia' && myHouseKey === 'academia' && testStatus && testStatus.allowed)
? larpModel.getProfileTest() : [];
const house = { key: houseKey, ...houseRaw };
const rawInvite = String(ctx.query?.invite || '').trim();
const inviteCode = /^[0-9a-f]{32}$/i.test(rawInvite) && myHouseKey === houseKey ? rawInvite : null;
ctx.body = larpHouseView({ house, members, myHouseKey, cycle, governingKey, houses, posts, canPost, testStatus, inviteCode, questions });
})
.post("/larp/join", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const houseKey = String((ctx.request.body && ctx.request.body.house) || '').toLowerCase();
if (houseKey !== 'academia') { ctx.redirect('/larp'); return; }
try { await larpModel.publishJoin('academia'); } catch (_) {}
const larpReturnTo = String((ctx.request.body && ctx.request.body.returnTo) || '');
ctx.redirect(larpReturnTo === '/welcome' ? '/welcome' : '/larp/academia');
})
.post("/larp/leave", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
try { await larpModel.publishLeaveLarp(); } catch (_) {}
ctx.redirect('/larp');
})
.get("/larp/tribe/:house", async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const houseKey = String(ctx.params.house || '').toLowerCase();
const myFeedId = await meta.myFeedId();
const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null);
if (myHouseKey !== houseKey) return ctx.redirect(`/larp/${encodeURIComponent(houseKey)}`);
let tribe = null;
try { tribe = await larpModel.ensureHouseTribe(houseKey); } catch (_) {}
if (!tribe || !tribe.id) return ctx.redirect(`/larp/${encodeURIComponent(houseKey)}`);
ctx.redirect(`/tribe/${encodeURIComponent(tribe.id)}`);
})
.post("/larp/post", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules');
const b = ctx.request.body || {};
const houseKey = String(b.house || '').toLowerCase();
const text = stripDangerousTags(String(b.text || ''));
const myFeedId = await meta.myFeedId();
const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null);
if (myHouseKey !== houseKey || houseKey === 'academia') { ctx.redirect('/larp'); return; }
try { await larpModel.publishHousePost({ house: houseKey, text }); }
catch (e) {
if (isSsbTooLargeError(e)) { sendErrorPage(ctx, require('../views/main_views').i18n.publishTooLong || 'Your post is too long. Please shorten it.', { status: 400 }); return; }
}
ctx.redirect(`/larp/${encodeURIComponent(houseKey)}`);
})
.get("/invites", async (ctx) => {
if (!checkMod(ctx, 'invitesMod')) return ctx.redirect('/modules');
ctx.body = await invitesView({ flash: String(ctx.query.flash || '') });
})
.get("/likes/:feed", async (ctx) => {
const { feed } = ctx.params;
const messages = await post.likes({ feed });
const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
ctx.body = await likesView({ messages, feed, name: await about.name(feed), spreadMap });
})
.get("/mentions", async (ctx) => {
const { messages, myFeedId } = await post.mentionsMe();
const tribeMentions = [];
try {
const allTribes = await tribesModel.listAll();
const myTribes = allTribes.filter(t => t.members.includes(myFeedId));
for (const t of myTribes) {
const items = await tribesContentModel.listByTribe(t.id, null).catch(() => []);
for (const item of items) {
const text = (item.description || '') + ' ' + (item.title || '');
if (text.includes(myFeedId) || text.includes(myFeedId.slice(1))) {
tribeMentions.push({
key: item.id,
value: {
author: item.author,
timestamp: Date.parse(item.createdAt) || item._ts || Date.now(),
content: {
type: 'tribe-content',
text: item.description || item.title || '',
tribeId: t.id,
tribeName: t.title,
contentType: item.contentType,
mentions: { _self: [{ link: myFeedId }] }
}
}
});
}
}
}
} catch (_) {}
const combined = [...(Array.isArray(messages) ? messages : []), ...tribeMentions];
for (const msg of combined) {
if (!msg.value) continue;
const authorId = msg.value.author;
if (authorId) {
if (!msg.value.meta) msg.value.meta = {};
if (!msg.value.meta.author) msg.value.meta.author = {};
if (!msg.value.meta.author.name) {
try { msg.value.meta.author.name = await about.name(authorId); } catch (_) {}
}
}
}
ctx.body = await mentionsView({ messages: combined, myFeedId });
})
// ===== FORK_IA_UX: módulo KARVAN (mensajes temporales RAM + WebRTC data-channel) =====
.get('/karvan', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
ctx.body = karvanView(karvanModel.listRooms(), { selfId });
})
.post('/karvan/create', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const room = karvanModel.createRoom({
title: stripDangerousTags(String(b.title || '').trim()).slice(0, 80),
ttlMinutes: b.ttl,
});
ctx.redirect('/karvan/' + room.id);
})
.get('/karvan/:id', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
const room = karvanModel.getRoom(ctx.params.id);
if (!room) { ctx.redirect('/karvan'); return; }
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msgs = karvanModel.listMessages(ctx.params.id, 0) || [];
ctx.body = karvanRoomView(room, msgs, { selfId });
})
.post('/karvan/:id/msg', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const b = ctx.request.body || {};
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msg = karvanModel.postMessage(ctx.params.id, {
from: String(b.from || selfId || '?').slice(0, 80),
text: stripDangerousTags(String(b.text || '')).slice(0, 2000),
mid: b.mid,
});
if (!msg) { ctx.status = 404; ctx.body = { error: 'no room' }; return; }
const wantsJson = (ctx.get('accept') || '').indexOf('application/json') !== -1
|| (ctx.get('content-type') || '').indexOf('application/json') !== -1;
if (wantsJson) ctx.body = { ok: true, seq: msg.seq };
else ctx.redirect('/karvan/' + ctx.params.id);
})
.get('/karvan/:id/msgs', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const msgs = karvanModel.listMessages(ctx.params.id, ctx.query.since || 0);
ctx.body = { messages: msgs || [] };
})
.post('/karvan/:id/signal', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const b = ctx.request.body || {};
karvanModel.postSignal(ctx.params.id, { from: b.from, to: b.to, payload: b.payload });
ctx.body = { ok: true };
})
.get('/karvan/:id/signal', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const r = karvanModel.getSignals(ctx.params.id, { forId: ctx.query.for || '', since: ctx.query.since || 0 });
ctx.body = r || { signals: [], members: [] };
})
.get('/opinions', async (ctx) => {
const filter = qf(ctx, 'TOP');
let opinions = await opinionsModel.listOpinions(filter);
if (Array.isArray(opinions)) opinions = await applyListFilters(opinions, ctx);
const spreadMap = new Map();
const list = Array.isArray(opinions) ? opinions : [];
const results = await Promise.all(list.map(it => it && it.key ? spreads.forMessage(it.key).catch(() => null) : Promise.resolve(null)));
list.forEach((it, i) => { if (it && it.key && results[i]) spreadMap.set(it.key, results[i]); });
ctx.body = await opinionsView(opinions, filter, spreadMap);
})
.get("/feed", async (ctx) => {
const filter = String(ctx.query.filter || "ALL").toUpperCase();
const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : "";
const msg = typeof ctx.query.msg === "string" ? ctx.query.msg : "";
let feeds = await feedModel.listFeeds({ filter, q, tag });
feeds = await applyListFilters(feeds, ctx);
await warmAuthorNames(feeds);
ctx.body = feedView(feeds, { filter, q, tag, msg });
})
.get("/feed/create", async (ctx) => {
const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : "";
ctx.body = feedCreateView({ q, tag });
})
.get("/feed/:feedId", async (ctx) => {
const feed = await feedModel.getFeedById(ctx.params.feedId);
if (!feed) { ctx.redirect('/feed'); return; }
const comments = await feedModel.getComments(ctx.params.feedId).catch(() => []);
ctx.body = singleFeedView(feed, comments);
})
.get("/fediverse", async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
const account = fediverseModel.getAccount();
const stats = account ? await fediverseModel.getAccountStats() : null;
ctx.body = fediverseOverviewView({ account, stats });
})
.get("/fediverse/mastodon", async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
if (!fediverseModel.hasAccount()) { ctx.redirect('/fediverse'); return; }
if (ctx.query.refresh) { try { fediverseModel.invalidateCache(); } catch (_) {} }
const data = await fediverseModel.getTimeline();
if (data && data.connected) { data.stats = await fediverseModel.getAccountStats(); }
if (data && typeof ctx.query.error === 'string' && ctx.query.error) data.error = ctx.query.error;
ctx.body = fediverseView(data);
})
.get("/fediverse/mastodon/thread/:id", async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
if (!fediverseModel.hasAccount()) { ctx.redirect('/fediverse'); return; }
const account = fediverseModel.getAccount();
const stats = account ? await fediverseModel.getAccountStats() : null;
const thread = await fediverseModel.getThread(ctx.params.id);
const error = (thread && thread.error) || (typeof ctx.query.error === 'string' && ctx.query.error ? ctx.query.error : undefined);
ctx.body = fediverseThreadView({ account, stats, thread, error });
})
.get("/fediverse/media", async (ctx) => {
const u = typeof ctx.query.u === 'string' ? ctx.query.u : '';
const media = await fediverseModel.proxyMedia(u);
if (!media) { ctx.status = 404; ctx.body = ''; return; }
ctx.set('Cache-Control', 'private, max-age=3600');
ctx.type = media.contentType;
ctx.body = media.buffer;
})
.get("/fediverse/tmp/:name", async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.status = 404; ctx.body = ''; return; }
const name = ctx.params.name;
if (!isFediverseTmpName(name)) { ctx.status = 404; ctx.body = ''; return; }
try {
const buf = fs.readFileSync(path.join(blobsPath, name));
ctx.type = fediverseMimeFromExt(fediverseExtFromName(name));
ctx.set('Cache-Control', 'private, max-age=300');
ctx.body = buf;
} catch (_) { ctx.status = 404; ctx.body = ''; }
})
.get("/fediverse/mastodon/preview", async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
if (!fediverseModel.hasAccount()) { ctx.redirect('/fediverse'); return; }
sweepFediverseTmp();
const account = fediverseModel.getAccount();
const stats = account ? await fediverseModel.getAccountStats() : null;
ctx.body = fediversePreviewView({ account, stats });
})
.post("/fediverse/mastodon/preview", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
sweepFediverseTmp();
const text = ctx.request.body?.text || '';
let existing = ctx.request.body?.tmp || [];
if (!Array.isArray(existing)) existing = existing ? [existing] : [];
existing = existing.filter(isFediverseTmpName).map(name => ({ name, type: fediverseTmpType(name) }));
const fresh = saveFediverseTempMedia(ctx);
const media = [...existing, ...fresh].slice(0, 4);
const account = fediverseModel.getAccount();
const stats = account ? await fediverseModel.getAccountStats() : null;
const error = (!String(text).trim() && !media.length) ? 'fediverseErrEmpty' : undefined;
ctx.body = fediversePreviewView({ account, stats, text, media, error });
})
.post("/fediverse/mastodon/post", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
try {
const text = ctx.request.body?.text || '';
const tmpIds = await publishFediverseTempMedia(ctx.request.body?.tmp);
const fileIds = (await collectFediverseMedia(ctx)).map(m => m.id);
const mediaIds = [...tmpIds, ...fileIds].slice(0, 4);
await fediverseModel.postStatus({ text, mediaIds });
} catch (err) {
ctx.redirect('/fediverse/mastodon?error=' + encodeURIComponent(err && err.message ? err.message : 'fediverseErrPost'));
return;
}
ctx.redirect('/fediverse/mastodon');
})
.post("/fediverse/mastodon/reply/:id/preview", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
if (!fediverseModel.hasAccount()) { ctx.redirect('/fediverse'); return; }
sweepFediverseTmp();
const id = ctx.params.id;
const text = ctx.request.body?.text || '';
let existing = ctx.request.body?.tmp || [];
if (!Array.isArray(existing)) existing = existing ? [existing] : [];
existing = existing.filter(isFediverseTmpName).map(name => ({ name, type: fediverseTmpType(name) }));
const fresh = saveFediverseTempMedia(ctx);
const media = [...existing, ...fresh].slice(0, 4);
const account = fediverseModel.getAccount();
const stats = account ? await fediverseModel.getAccountStats() : null;
const thread = await fediverseModel.getThread(id);
const parent = thread && thread.status ? thread.status : null;
const error = (!String(text).trim() && !media.length) ? 'fediverseErrEmpty' : undefined;
ctx.body = fediversePreviewView({ account, stats, text, media, error, replyToId: id, parent });
})
.post("/fediverse/mastodon/reply/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
const id = ctx.params.id;
try {
const text = ctx.request.body?.text || '';
const tmpIds = await publishFediverseTempMedia(ctx.request.body?.tmp);
const fileIds = (await collectFediverseMedia(ctx)).map(m => m.id);
const mediaIds = [...tmpIds, ...fileIds].slice(0, 4);
await fediverseModel.postStatus({ text, inReplyToId: id, mediaIds });
} catch (err) {
ctx.redirect(`/fediverse/mastodon/thread/${encodeURIComponent(id)}?error=` + encodeURIComponent(err && err.message ? err.message : 'fediverseErrPost'));
return;
}
ctx.redirect(`/fediverse/mastodon/thread/${encodeURIComponent(id)}`);
})
.post("/fediverse/mastodon/boost/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
try { await fediverseModel.reblog(ctx.params.id); } catch (_) {}
ctx.redirect(fediverseReturnTo(ctx, '/fediverse/mastodon'));
})
.post("/fediverse/mastodon/unboost/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
try { await fediverseModel.unreblog(ctx.params.id); } catch (_) {}
ctx.redirect(fediverseReturnTo(ctx, '/fediverse/mastodon'));
})
.post("/fediverse/mastodon/fav/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
try { await fediverseModel.favourite(ctx.params.id); } catch (_) {}
ctx.redirect(fediverseReturnTo(ctx, '/fediverse/mastodon'));
})
.post("/fediverse/mastodon/unfav/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; }
try { await fediverseModel.unfavourite(ctx.params.id); } catch (_) {}
ctx.redirect(fediverseReturnTo(ctx, '/fediverse/mastodon'));
})
.post("/settings/fediverse", koaBody(), async (ctx) => {
try {
const acc = await fediverseModel.connectMastodon({ instance: ctx.request.body?.instance, token: ctx.request.body?.token });
try {
const host = String(acc.instance || '').replace(/^https?:\/\//, '');
if (acc.acct && host) await post.publishFediverseHandle(`${acc.acct}@${host}`);
} catch (_) {}
ctx.redirect('/fediverse');
} catch (err) {
ctx.redirect(`/settings?fediverseError=${encodeURIComponent(err.message || 'fediverseError')}`);
}
})
.post("/settings/fediverse/disconnect", koaBody(), async (ctx) => {
try { fediverseModel.disconnect(); } catch (_) {}
try { await post.publishFediverseHandle(''); } catch (_) {}
ctx.redirect('/settings');
})
.get('/forum', async ctx => {
if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; }
const filter = qf(ctx, 'hot');
let forums = await forumModel.listAll(filter);
forums = await applyListFilters(forums, ctx);
try { forums = await lifetime.enrichAndFilter(forums); } catch (_) {}
const spreadMap = await spreads.forMessages((forums || []).map(x => x && (x.key || x.id)));
await warmAuthorNames(forums);
ctx.body = await forumView(forums, filter, { spreadMap });
})
.get('/forum/:forumId', async ctx => {
const msg = await forumModel.getMessageById(ctx.params.forumId), isReply = Boolean(msg.root), forumId = isReply ? msg.root : ctx.params.forumId;
const spreadInfo = await spreads.forMessage(forumId).catch(() => null);
const forumObj = await forumModel.getForumById(forumId);
try { const oi = await forumModel.getOpenInvite(forumId).catch(() => null); if (oi && forumObj) forumObj.openInviteCode = oi.code; } catch (_) {}
const forumMsgs = await forumModel.getMessagesByForumId(forumId);
await warmAuthorNames(forumObj, forumMsgs);
ctx.body = await singleForumView(forumObj, forumMsgs, ctx.query.filter, ctx.query.topic || '');
})
.get('/welcome', async (ctx) => {
const lang = ctx.cookies.get('language') || getConfig().language || 'en';
const available = {
language: true,
profile: true,
federation: checkMod(ctx, 'invitesMod'),
larp: checkMod(ctx, 'larpMod'),
backup: checkMod(ctx, 'legacyMod'),
greeting: checkMod(ctx, 'feedMod')
};
try {
const status = await onboardingModel.status(available);
ctx.body = await welcomeView(status, lang, status.profile);
} catch (error) { sendErrorPage(ctx, error.message || String(error), { status: 500 }); }
})
.post('/welcome/profile', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const body = ctx.request.body || {};
const imageFile = ctx.request.files?.image;
const mime = imageFile?.mimetype || imageFile?.type || '';
const image = mime.startsWith('image/') && imageFile?.filepath
? await promisesFs.readFile(imageFile.filepath).catch(() => undefined)
: undefined;
try {
await post.publishProfileEdit({
name: stripDangerousTags(String(body.name || '')).trim(),
description: stripDangerousTags(String(body.description || '')).trim(),
image
});
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return; }
ctx.redirect('/welcome');
})
.post('/welcome/greeting', koaBody(), async (ctx) => {
const text = stripDangerousTags(String((ctx.request.body || {}).text || ''));
try {
const mentions = await extractMentions(text);
await feedModel.createFeed(text, mentions);
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return; }
ctx.redirect('/welcome');
})
.post('/welcome/dismiss', koaBody(), async (ctx) => {
try { onboardingModel.dismiss(); } catch (_) {}
safeRefererRedirect(ctx, '/');
})
.get('/legacy', async (ctx) => {
if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules');
try { ctx.body = await legacyView(); } catch (error) { sendErrorPage(ctx, error.message); }
})
.get('/dev', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; }
try {
const group = String(ctx.query.group || '');
const tree = group ? devModel.listGroup(group) : devModel.listTree(ctx.query.path || '');
ctx.body = await devTreeView(tree, devModel.projectStats());
} catch (_) { ctx.redirect('/dev'); }
})
.get('/dev/file', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; }
try {
ctx.body = await devFileView(devModel.readFile(ctx.query.path || '', { from: ctx.query.from }));
} catch (_) { ctx.redirect('/dev'); }
})
.get('/dev/raw', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; }
try {
const file = devModel.rawFile(ctx.query.path || '');
ctx.set('Content-Type', 'text/plain; charset=utf-8');
ctx.body = file.content;
} catch (_) { ctx.redirect('/dev'); }
})
.get('/dev/search', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; }
const ext = String(ctx.query.ext || '');
try {
ctx.body = await devSearchView(devModel.searchCode(ctx.query.q || '', { ext }), ext);
} catch (_) { ctx.redirect('/dev'); }
})
.get('/dev/map', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!checkMod(ctx, 'devMod')) { ctx.redirect('/modules'); return; }
try {
ctx.body = await devMapView(devModel.moduleMap());
} catch (_) { ctx.redirect('/dev'); }
})
.get('/bookmarks', async (ctx) => {
if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules');
const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', viewerId = getViewerId();
const favs = await mediaFavorites.getFavoriteSet("bookmarks");
let bookmarks = (await bookmarksModel.listAll({ viewerId, filter: filter === "favorites" ? "all" : filter, q, sort })).map(b => ({ ...b, isFavorite: favs.has(String(b.rootId || b.id)) }));
if (filter === "favorites") bookmarks = bookmarks.filter(b => b.isFavorite);
bookmarks = await applyListFilters(bookmarks, ctx);
await enrichWithComments(bookmarks, 'rootId');
const spreadMap = await spreads.forMessages((bookmarks || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(bookmarks);
ctx.body = await bookmarkView(bookmarks, filter, null, { q, sort, spreadMap });
})
.get("/bookmarks/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules');
const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await mediaFavorites.getFavoriteSet("bookmarks");
ctx.body = await bookmarkView([{ ...bookmark, isFavorite: favs.has(String(bookmark.rootId || bookmark.id)) }], "edit", bookmark.id, { returnTo: ctx.query.returnTo || "" });
})
.get('/bookmarks/:bookmarkId', async (ctx) => {
if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules');
const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', favs = await mediaFavorites.getFavoriteSet("bookmarks");
const bookmark = await bookmarksModel.getBookmarkById(ctx.params.bookmarkId), root = bookmark.rootId || bookmark.id, comments = await getVoteComments(root);
await enrichItemLifetime(bookmark);
ctx.body = await singleBookmarkView({ ...bookmark, commentCount: comments.length, isFavorite: favs.has(String(root)) }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/bookmarks?filter=${encodeURIComponent(filter)}`, ['/bookmarks']), spreads: await spreads.forMessage(bookmark.id) });
})
.get('/tasks', async ctx => {
const filter = qf(ctx);
let tasks = await enrichWithComments(await tasksModel.listAll());
tasks = await applyListFilters(tasks, ctx);
try { tasks = await lifetime.enrichAndFilter(tasks); } catch (_) {}
await enrichMsgSize(tasks);
const spreadMap = await spreads.forMessages((tasks || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(tasks);
ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo, { spreadMap, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' });
})
.get('/tasks/edit/:id', async ctx => {
const id = ctx.params.id;
const task = await tasksModel.getTaskById(id);
ctx.body = await taskView(task, 'edit', id, ctx.query.returnTo);
})
.get('/tasks/:taskId', async ctx => {
const { taskId } = ctx.params, filter = qf(ctx), task = await tasksModel.getTaskById(taskId);
const comments = await getVoteComments(taskId);
await enrichMsgSize([task]);
await enrichItemLifetime(task);
ctx.body = await singleTaskView(withCount(task, comments), filter, comments, { spreads: await spreads.forMessage(task.id).catch(() => null) });
})
.get('/events', async (ctx) => {
if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
const filter = qf(ctx);
let events = await enrichWithComments(await eventsModel.listAll(null, filter));
events = await applyListFilters(events, ctx);
try { events = await lifetime.enrichAndFilter(events); } catch (_) {}
await enrichMsgSize(events);
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
const spreadMap = await spreads.forMessages((events || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(events);
ctx.body = await eventView(events, filter, null, ctx.query.returnTo, { viewerPrefs, spreadMap });
})
.get('/events/edit/:id', async (ctx) => {
if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
const eventId = ctx.params.id;
const event = await eventsModel.getEventById(eventId);
ctx.body = await eventView([event], 'edit', eventId, ctx.query.returnTo);
})
.get('/events/:eventId', async ctx => {
await eventsModel.ingestKeys().catch(() => {});
const { eventId } = ctx.params, filter = qf(ctx), event = await eventsModel.getEventById(eventId);
const [comments, mapData, linkedCalendarId] = await Promise.all([
getVoteComments(eventId),
resolveMapUrl(event.mapUrl),
calendarsModel.findCalendarByLinkText(`/events/${eventId}`).catch(() => null)
]);
await enrichMsgSize([event]);
await enrichItemLifetime(event, { author: event.organizer });
try { const oi = await eventsModel.getOpenInvite(eventId).catch(() => null); if (oi) event.openInviteCode = oi.code; } catch (_) {}
const evAuthorPrefs2 = await about.visibilityPrefs(event.organizer).catch(() => null);
await warmAuthorNames(event, comments);
ctx.body = await singleEventView(withCount(event, comments), filter, comments, { mapData, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: evAuthorPrefs2, linkedCalendarId, spreads: await spreads.forMessage(event.id).catch(() => null) });
})
.get('/c/events/:eventId', async (ctx) => {
let event;
try { event = await eventsModel.getEventById(ctx.params.eventId); } catch (_) {}
if (!event || String(event.status || '').toUpperCase() === 'CLOSED' || event.isPublic === 'private') {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const evAuthorPrefs = await about.visibilityPrefs(event.organizer).catch(() => null);
if (!evAuthorPrefs || evAuthorPrefs.clearnetEvents !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
ctx.type = 'text/html';
ctx.body = await clearnetEventView(event);
})
.get('/votes', async ctx => {
const filter = qf(ctx);
let voteList = await enrichWithComments(await votesModel.listAll(filter));
voteList = await applyListFilters(voteList, ctx);
try { voteList = await lifetime.enrichAndFilter(voteList, { getAuthor: (x) => x.createdBy }); } catch (_) {}
await enrichMsgSize(voteList);
const spreadMap = await spreads.forMessages((voteList || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(voteList);
ctx.body = await voteView(voteList, filter, null, [], filter, { spreadMap });
})
.get('/votes/edit/:id', async ctx => {
const id = ctx.params.id;
const activeFilter = (ctx.query.filter || 'mine');
const voteData = await votesModel.getVoteById(id);
ctx.body = await voteView([voteData], 'edit', id, [], activeFilter);
})
.get('/votes/:voteId', async ctx => {
const { voteId } = ctx.params, filter = qf(ctx), voteData = await votesModel.getVoteById(voteId);
const comments = await getVoteComments(voteId);
await enrichMsgSize([voteData]);
await enrichItemLifetime(voteData, { author: voteData.createdBy });
ctx.body = await voteView([withCount(voteData, comments)], 'detail', voteId, comments, filter, { spreads: await spreads.forMessage(voteId).catch(() => null) });
})
.get("/market", async (ctx) => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const filter = qf(ctx), q = ctx.query.q || "", minPrice = ctx.query.minPrice ?? "", maxPrice = ctx.query.maxPrice ?? "", sort = ctx.query.sort || "recent";
let marketItems = await marketModel.listAllItems("all");
await marketModel.checkAuctionItemsStatus(marketItems);
marketItems = await marketModel.listAllItems("all");
await enrichWithComments(marketItems);
marketItems = await applyListFilters(marketItems, ctx);
if (String(filter || '').toUpperCase() !== 'MINE') {
try { marketItems = await lifetime.enrichAndFilter(marketItems, { getCreatedAt: (x) => x.updatedAt || x.createdAt }); } catch (_) {}
}
const spreadMap = await spreads.forMessages((marketItems || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(marketItems);
ctx.body = await marketView(marketItems, filter, null, { q, minPrice, maxPrice, sort, spreadMap, industry: ctx.query.industry || "", title: ctx.query.title || "", description: ctx.query.description || "", price: ctx.query.price || "", tags: ctx.query.tags || "", stock: ctx.query.stock || "" });
})
.get("/market/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const id = ctx.params.id
let marketItem = await marketModel.getItemById(id)
if (!marketItem) ctx.throw(404, "Item not found")
await marketModel.checkAuctionItemsStatus([marketItem])
marketItem = await marketModel.getItemById(id)
if (!marketItem) ctx.throw(404, "Item not found")
ctx.body = await marketView([marketItem], "edit", marketItem, { q: "", minPrice: "", maxPrice: "", sort: "recent" })
})
.get("/market/:itemId", async (ctx) => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const { itemId } = ctx.params, filter = qf(ctx), q = ctx.query.q || "", minPrice = ctx.query.minPrice ?? "", maxPrice = ctx.query.maxPrice ?? "", sort = ctx.query.sort || "recent";
let item = await marketModel.getItemById(itemId)
if (!item) ctx.throw(404, "Item not found")
await marketModel.checkAuctionItemsStatus([item])
item = await marketModel.getItemById(itemId)
if (!item) ctx.throw(404, "Item not found")
const zoom = parseInt(ctx.query.zoom) || 2;
const [comments, mapData] = await Promise.all([getVoteComments(itemId), resolveMapUrl(item.mapUrl)])
const returnTo = (() => {
const params = []
if (filter) params.push(`filter=${encodeURIComponent(filter)}`)
if (q) params.push(`q=${encodeURIComponent(q)}`)
if (minPrice !== "" && minPrice != null) params.push(`minPrice=${encodeURIComponent(String(minPrice))}`)
if (maxPrice !== "" && maxPrice != null) params.push(`maxPrice=${encodeURIComponent(String(maxPrice))}`)
if (sort) params.push(`sort=${encodeURIComponent(sort)}`)
return `/market${params.length ? `?${params.join("&")}` : ""}`
})()
await enrichItemLifetime(item, { author: item.seller, createdAt: item.updatedAt || item.createdAt })
ctx.body = await singleMarketView(withCount(item, comments), filter, comments, { q, minPrice, maxPrice, sort, returnTo, mapData, zoom, spreads: await spreads.forMessage(item.id).catch(() => null) })
})
.get('/jobs', async (ctx) => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
let filter = String(ctx.query.filter || 'ALL').toUpperCase()
if (filter === 'FAVS' || filter === 'NEEDS') filter = 'ALL'
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
const query = {
search: ctx.query.search || '',
minSalary: ctx.query.minSalary ?? '',
maxSalary: ctx.query.maxSalary ?? '',
sort: ctx.query.sort || 'recent',
industry: ctx.query.industry || '',
title: ctx.query.title || '',
description: ctx.query.description || '',
tasks: ctx.query.tasks || '',
salary: ctx.query.salary || '',
viewerPrefs
}
if (filter === 'CREATE') {
ctx.body = await jobsView([], 'CREATE', query)
return
}
if (filter === 'CV') {
query.location = ctx.query.location || ''
query.language = ctx.query.language || ''
query.skills = ctx.query.skills || ''
const inhabitants = await inhabitantsModel.listInhabitants({
filter: 'CVs',
...query
})
ctx.body = await jobsView(inhabitants, filter, query)
return
}
const viewerId = getViewerId()
let jobs = await jobsModel.listJobs(filter, viewerId, query)
await enrichWithComments(jobs)
jobs = await applyListFilters(jobs, ctx)
if (filter !== 'MINE') {
try { jobs = await lifetime.enrichAndFilter(jobs); } catch (_) {}
}
await enrichMsgSize(jobs)
const spreadMap = await spreads.forMessages((jobs || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(jobs);
ctx.body = await jobsView(jobs, filter, { ...(query || {}), spreadMap })
})
.get('/jobs/edit/:id', async (ctx) => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
const id = ctx.params.id
const viewerId = getViewerId()
const job = await jobsModel.getJobById(id, viewerId)
ctx.body = await jobsView([job], 'EDIT', {})
})
.get('/jobs/:jobId', async (ctx) => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
let jobId = ctx.params.jobId
if (jobId && jobId.startsWith('%25')) {
try { jobId = decodeURIComponent(jobId); } catch (_) {}
}
let filter = String(ctx.query.filter || 'ALL').toUpperCase()
if (filter === 'FAVS' || filter === 'NEEDS') filter = 'ALL'
const viewerId = getViewerId()
const params = {
search: ctx.query.search || '',
minSalary: ctx.query.minSalary ?? '',
maxSalary: ctx.query.maxSalary ?? '',
sort: ctx.query.sort || 'recent',
returnTo: safeReturnTo(ctx, `/jobs?filter=${encodeURIComponent(filter)}`, ['/jobs'])
}
let job;
try {
job = await jobsModel.getJobById(jobId, viewerId)
} catch (e) {
sendErrorPage(ctx, `Job not found or invalid id: ${jobId}`, { status: 404 });
return;
}
if (!job) {
sendErrorPage(ctx, `Job not found: ${jobId}`, { status: 404 });
return;
}
const [comments, mapData] = await Promise.all([getVoteComments(jobId), resolveMapUrl(job.mapUrl)])
await enrichMsgSize([job])
let candidates = [];
if (job && String(job.author) === String(viewerId)) {
try { candidates = await inhabitantsModel.getCandidatesForJob(job, viewerId); } catch (_) { candidates = []; }
}
const jobAuthorPrefs2 = await about.visibilityPrefs(job.author).catch(() => null);
await enrichItemLifetime(job)
ctx.body = await singleJobsView(withCount(job, comments), filter, comments, { ...params, mapData, candidates, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: jobAuthorPrefs2, spreads: await spreads.forMessage(job.id).catch(() => null) })
})
.get('/c/jobs/:jobId', async (ctx) => {
let job;
try { job = await jobsModel.getJobById(ctx.params.jobId); } catch (_) {}
if (!job || String(job.status || '').toUpperCase() === 'CLOSED' || String(job.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN') {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const jobAuthorPrefs = await about.visibilityPrefs(job.author).catch(() => null);
if (!jobAuthorPrefs || jobAuthorPrefs.clearnetJobs !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
ctx.type = 'text/html';
ctx.body = await clearnetJobView(job);
})
.get("/shops", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
const hasPurchases = (await shopsModel.listMyPurchases().catch(() => [])).length > 0;
if (filter === 'products' || filter === 'prices') {
const products = await shopsModel.listAllProducts({ filter: 'top', sort, viewerId: getViewerId() });
const enriched = await Promise.all(products.map(async (prod) => {
try {
const shop = await shopsModel.getShopById(prod.shopId);
return { ...prod, shopTitle: shop ? shop.title : '' };
} catch (_) { return prod; }
}));
ctx.body = await shopsView(enriched, filter, null, { q, sort, viewerPrefs, hasPurchases });
return;
}
const items = await shopsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
const fav = await mediaFavorites.getFavoriteSet('shops');
let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
enriched = await applyListFilters(enriched, ctx);
let withFeatured = await Promise.all(enriched.map(async (shop) => {
shop.featuredProducts = await shopsModel.listFeaturedProducts(shop.rootId || shop.key);
return shop;
}));
try { withFeatured = await lifetime.enrichAndFilter(withFeatured, { getKey: (x) => x.rootId || x.key }); } catch (_) {}
const spreadMap = await spreads.forMessages((withFeatured || []).map(x => x && (x.key || x.id)));
await warmAuthorNames(withFeatured);
ctx.body = await shopsView(withFeatured, filter, null, { q, sort, viewerPrefs, spreadMap, hasPurchases });
})
.get("/shops/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const shop = await shopsModel.getShopById(ctx.params.id);
if (!shop) { ctx.redirect('/shops'); return; }
const fav = await mediaFavorites.getFavoriteSet('shops');
ctx.body = await shopsView([{ ...shop, isFavorite: fav.has(String(shop.rootId || shop.key)) }], 'edit', shop, { returnTo: ctx.query.returnTo || '' });
})
.get("/shops/product/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const product = await shopsModel.getProductById(ctx.params.id);
if (!product) { ctx.redirect('/shops'); return; }
ctx.body = await editProductView(product, ctx.query.shopId || product.shopId, { returnTo: ctx.query.returnTo || '' });
})
.get("/shops/product/:productId", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const product = await shopsModel.getProductById(ctx.params.productId);
if (!product) { ctx.redirect('/shops'); return; }
const shop = await shopsModel.getShopById(product.shopId);
const comments = await getVoteComments(product.key);
const myPurchases = await shopsModel.listMyPurchases().catch(() => []);
const productRootId = product.rootId || product.key;
const canRate = myPurchases.some(o => o.productId === productRootId && String(o.status || '').toUpperCase() === 'RECEIVED');
ctx.body = await singleProductView(withCount(product, comments), shop, comments, { shopId: product.shopId, canRate, returnTo: safeReturnTo(ctx, `/shops/${encodeURIComponent(product.shopId)}`, ['/shops']) });
})
.get("/c/audios/:id", async (ctx) => {
let item; try { item = await audiosModel.getAudioById(ctx.params.id); } catch (_) {}
const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null;
if (!item || !p || p.clearnetAudios !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'audio', item });
})
.get("/c/videos/:id", async (ctx) => {
let item; try { item = await videosModel.getVideoById(ctx.params.id); } catch (_) {}
const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null;
if (!item || !p || p.clearnetVideos !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'video', item });
})
.get("/c/images/:id", async (ctx) => {
let item; try { item = await imagesModel.getImageById(ctx.params.id); } catch (_) {}
const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null;
if (!item || !p || p.clearnetImages !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'image', item });
})
.get("/c/documents/:id", async (ctx) => {
let item; try { item = await documentsModel.getDocumentById(ctx.params.id); } catch (_) {}
const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null;
if (!item || !p || p.clearnetDocuments !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'document', item });
})
.get("/c/torrents/:id", async (ctx) => {
let item; try { item = await torrentsModel.getTorrentById(ctx.params.id); } catch (_) {}
const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null;
if (!item || !p || p.clearnetTorrents !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'torrent', item });
})
.get("/c/blog/:msgKey", async (ctx) => {
const msgKey = String(ctx.params.msgKey || '');
let msg;
try {
const ssbX = await cooler.open();
msg = await new Promise((res, rej) => ssbX.get(msgKey, (err, m) => err || !m ? rej(err || new Error('not found')) : res(m)));
} catch (_) {}
const content = msg && msg.content;
if (!content || content.type !== 'post' || content.root) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const authorId = msg.author;
const postAuthorPrefs = await about.visibilityPrefs(authorId).catch(() => null);
if (!postAuthorPrefs || postAuthorPrefs.clearnetPosts !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const authorName = authorId ? await about.name(authorId).catch(() => '') : '';
ctx.type = 'text/html';
ctx.body = await clearnetBlogView({
msgKey,
text: content.text || '',
author: authorId,
authorName,
contentWarning: content.contentWarning || '',
sentAt: msg.timestamp || content.sentAt
});
})
.get("/c/inhabitant/:feedId", async (ctx) => {
const feedId = decodeURIComponent(ctx.params.feedId || '');
if (!ssbRef.isFeedId(feedId)) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const prefs = await about.visibilityPrefs(feedId).catch(() => null);
if (!prefs || prefs.clearnet !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const [name, description, imageBlobId] = await Promise.all([
about.name(feedId).catch(() => ''),
about.description(feedId).catch(() => ''),
about.image(feedId).catch(() => null)
]);
let safeImage = imageBlobId;
if (imageBlobId) {
const { blobIdOf } = require('../views/clearnet_view');
const bid = blobIdOf(imageBlobId);
if (bid) {
try {
const ssbX = await cooler.open();
const has = await new Promise(resolve => ssbX.blobs.has(bid, (err, h) => resolve(!err && !!h)));
if (!has) safeImage = null;
} catch (_) { safeImage = null; }
} else {
safeImage = null;
}
}
const MAX_PER_SECTION = 5;
const items = { shops: [], jobs: [], events: [], projects: [], posts: [], audios: [], videos: [], images: [], documents: [], torrents: [] };
const mediaItemMapper = (m, { withImage = false } = {}) => ({
id: m.key,
title: m.title || 'Untitled',
image: withImage ? (m.url || null) : null,
snippet: m.description || '',
meta: m.createdAt ? new Date(m.createdAt).toISOString().slice(0, 10) : ''
});
if (prefs.clearnetShops) {
try {
const shops = await shopsModel.listAll({ filter: 'all' }).catch(() => []);
items.shops = (shops || []).filter(s => s.author === feedId && String(s.visibility || '').toUpperCase() !== 'CLOSED').slice(0, MAX_PER_SECTION).map(s => ({
id: s.key,
title: s.title || 'Untitled',
image: s.image || null,
snippet: s.shortDescription || s.description || '',
meta: s.location || ''
}));
} catch (_) {}
}
if (prefs.clearnetJobs) {
try {
const jobs = await jobsModel.listJobs('ALL', feedId).catch(() => []);
items.jobs = (jobs || []).filter(j => j.author === feedId && String(j.status || '').toUpperCase() !== 'CLOSED' && String(j.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN').slice(0, MAX_PER_SECTION).map(j => ({
id: j.id,
title: j.title || 'Untitled',
image: j.image || null,
snippet: j.description || '',
meta: j.location ? String(j.location).toUpperCase() : ''
}));
} catch (_) {}
}
if (prefs.clearnetEvents) {
try {
const events = await eventsModel.listAll(feedId, 'all').catch(() => []);
items.events = (events || []).filter(e => e.organizer === feedId && String(e.status || '').toUpperCase() !== 'CLOSED' && e.isPublic !== 'private').slice(0, MAX_PER_SECTION).map(e => ({
id: e.id,
title: e.title || 'Untitled',
image: null,
snippet: e.description || '',
meta: e.date ? new Date(e.date).toISOString().slice(0, 10) : (e.location || '')
}));
} catch (_) {}
}
if (prefs.clearnetProjects) {
try {
const projects = await projectsModel.listProjects('ALL').catch(() => []);
items.projects = (projects || []).filter(p => p.author === feedId && String(p.status || '').toUpperCase() !== 'CANCELLED').slice(0, MAX_PER_SECTION).map(p => ({
id: p.id || p.key,
title: p.title || 'Untitled',
image: p.image || null,
snippet: p.description || '',
meta: p.status ? String(p.status).toUpperCase() : ''
}));
} catch (_) {}
}
if (prefs.clearnetPosts) {
try {
const ssbX = await cooler.open();
items.posts = await new Promise((resolve) => {
try {
pull(
ssbX.createUserStream({ id: feedId, reverse: true, limit: 200 }),
pull.filter(m => m && m.value && m.value.content && m.value.content.type === 'post' && !m.value.content.root),
pull.collect((err, arr) => {
if (err || !Array.isArray(arr)) return resolve([]);
resolve(arr.slice(0, MAX_PER_SECTION).map(m => {
const c = m.value.content;
const cleanText = String(c.text || '').replace(/<[^>]+>/g, '').replace(/!\[[^\]]*\]\([^)]*\)/g, '');
const firstLine = cleanText.split('\n').find(l => l.trim()) || '';
const dateIso = m.value.timestamp ? new Date(m.value.timestamp).toISOString().slice(0, 10) : '';
return {
id: m.key,
title: c.contentWarning || firstLine.slice(0, 80) || 'Blog',
image: null,
snippet: c.contentWarning ? firstLine.slice(0, 200) : cleanText.slice(0, 200),
meta: dateIso
};
}));
})
);
} catch (_) { resolve([]); }
});
} catch (_) {}
}
if (prefs.clearnetAudios) {
try {
const audios = await audiosModel.listAll('all').catch(() => []);
items.audios = (audios || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m));
} catch (_) {}
}
if (prefs.clearnetVideos) {
try {
const videos = await videosModel.listAll('all').catch(() => []);
items.videos = (videos || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m));
} catch (_) {}
}
if (prefs.clearnetImages) {
try {
const images = await imagesModel.listAll('all').catch(() => []);
items.images = (images || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m, { withImage: true }));
} catch (_) {}
}
if (prefs.clearnetDocuments) {
try {
const documents = await documentsModel.listAll('all').catch(() => []);
items.documents = (documents || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m));
} catch (_) {}
}
if (prefs.clearnetTorrents) {
try {
const torrents = await torrentsModel.listAll('all').catch(() => []);
items.torrents = (torrents || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m));
} catch (_) {}
}
try {
const ssbBlob = await cooler.open();
const { blobIdOf } = require('../views/clearnet_view');
const blobCache = new Map();
const checkBlob = (bid) => new Promise(resolve => {
if (!bid) return resolve(false);
if (blobCache.has(bid)) return resolve(blobCache.get(bid));
try {
ssbBlob.blobs.has(bid, (err, has) => {
const ok = !err && !!has;
blobCache.set(bid, ok);
resolve(ok);
});
} catch (_) { resolve(false); }
});
for (const k of Object.keys(items)) {
for (const it of items[k]) {
const bid = blobIdOf(it.image);
if (bid) {
const ok = await checkBlob(bid);
if (!ok) it.image = null;
}
}
}
} catch (_) {}
const filterType = String(ctx.query.type || '').toLowerCase();
ctx.type = 'text/html';
ctx.body = await clearnetInhabitantView({ feedId, name, description, image: safeImage, prefs, items, filterType });
})
.get("/c/shops/:shopId", async (ctx) => {
const shop = await shopsModel.getShopById(ctx.params.shopId).catch(() => null);
if (!shop || String(shop.visibility || '').toUpperCase() === 'CLOSED') {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const shopAuthorPrefs = await about.visibilityPrefs(shop.author).catch(() => null);
if (!shopAuthorPrefs || shopAuthorPrefs.clearnetShops !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const products = await shopsModel.listProducts(shop.rootId || shop.key).catch(() => []);
ctx.type = 'text/html';
ctx.body = await clearnetShopView(shop, products || []);
})
.get("/shops/purchases", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const purchases = await shopsModel.listMyPurchases().catch(() => []);
ctx.body = await myPurchasesView(purchases);
})
.get("/shops/:shopId", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
const shop = await shopsModel.getShopById(ctx.params.shopId);
if (!shop) { ctx.redirect('/shops'); return; }
if (shop.encrypted && shop.undecryptable && shop.author !== getViewerId()) { ctx.redirect('/invites#invites-shops'); return; }
const fav = await mediaFavorites.getFavoriteSet('shops');
const [products, comments, mapData] = await Promise.all([shopsModel.listProducts(shop.rootId || shop.key), getVoteComments(shop.key), resolveMapUrl(shop.mapUrl)]);
const baseUrl = resolveExternalBaseUrl(ctx);
const authorPrefs = await about.visibilityPrefs(shop.author).catch(() => null);
await enrichItemLifetime(shop, { key: shop.rootId || shop.key });
const pendingOrders = shop.author === getViewerId() ? await shopsModel.countPendingOrders(shop.rootId || shop.key).catch(() => 0) : 0;
let shopOpenInviteCode = null, shopOpenInviteQr = null;
try {
const oi = await shopsModel.getOpenInvite(shop.key).catch(() => null);
if (oi) {
shopOpenInviteCode = oi.code;
shopOpenInviteQr = `/qr-invite/shop/${encodeURIComponent(shop.key)}`;
}
} catch (_) {}
await warmAuthorNames(shop, products, comments);
ctx.body = await singleShopView({ ...shop, isFavorite: fav.has(String(shop.rootId || shop.key)), commentCount: comments.length, pendingOrders, openInviteCode: shopOpenInviteCode, openInviteQr: shopOpenInviteQr }, filter, products, comments, { q, sort, returnTo: safeReturnTo(ctx, `/shops?filter=${encodeURIComponent(filter)}`, ['/shops']), mapData, baseUrl, authorPrefs, spreads: await spreads.forMessage(shop.key).catch(() => null) });
})
.get("/shops/:shopId/orders", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const shop = await shopsModel.getShopById(ctx.params.shopId);
if (!shop) { sendErrorPage(ctx, "Shop not found", { status: 404 }); return; }
const rootId = shop.rootId || shop.key;
const orders = await shopsModel.listShopOrders(rootId).catch(e => { throw e; });
ctx.body = await shopOrdersView(shop, orders);
})
.post("/shops/orders/:orderId/status", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const shopId = String(b.shopId || '');
try {
await shopsModel.setOrderStatus(ctx.params.orderId, b.status);
} catch (_) {}
const dest = (typeof b.returnTo === 'string' && b.returnTo.startsWith('/shops')) ? b.returnTo : `/shops/${encodeURIComponent(shopId)}/orders`;
ctx.redirect(dest);
})
.get("/chats", async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const { filter = 'all', q = '', tribeId = '' } = ctx.query;
const viewerId = getViewerId();
if (filter === 'create') {
ctx.body = await chatsView([], 'create', null, { q, ...(tribeId ? { tribeId } : {}) });
return;
}
const modelFilter = filter === "favorites" ? "all" : filter;
const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId });
const fav = await mediaFavorites.getFavoriteSet('chats');
const myTribeIds = await getUserTribeIds(viewerId);
const enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
let finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
finalList = await applyListFilters(finalList, ctx);
try { finalList = await lifetime.enrichAndFilter(finalList, { getKey: (x) => x.rootId || x.key }); } catch (_) {}
const spreadMap = await spreads.forMessages((finalList || []).map(x => x && (x.key || x.id)));
await warmAuthorNames(finalList);
ctx.body = await chatsView(finalList, filter, null, { q, spreadMap });
})
.get("/chats/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const chat = await chatsModel.getChatById(ctx.params.id);
if (!chat) { ctx.redirect('/chats'); return; }
ctx.body = await chatsView([], 'edit', chat, { returnTo: ctx.query.returnTo || '' });
})
.get("/chats/:chatId", async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
await chatsModel.ingestKeys().catch(() => {});
const { filter = 'all', q = '', replyTo = '' } = ctx.query;
const uid = getViewerId();
let chat = await chatsModel.getChatById(ctx.params.chatId);
if (!chat) { ctx.redirect('/chats'); return; }
let parentTribe = null;
if (chat.tribeId) {
try {
parentTribe = await tribesModel.getTribeById(chat.tribeId);
if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
await tribesModel.processIncomingKeys().catch(() => {});
chat = await chatsModel.getChatById(ctx.params.chatId);
} catch { ctx.redirect('/tribes'); return; }
} else {
const members = Array.isArray(chat.members) ? chat.members : [];
const isOpen = String(chat.status || '').toUpperCase() === 'OPEN';
if (!isOpen && chat.author !== uid && !members.includes(uid)) { ctx.redirect('/chats?filter=all'); return; }
}
const fav = await mediaFavorites.getFavoriteSet('chats');
const messages = await chatsModel.listMessages(chat.rootId || chat.key);
const isTribeMember = !!parentTribe;
const chatListForSwitcher = process.env.OASIS_MOBILE === '1' ? await chatsModel.listAll({ viewerId: uid }).catch(() => []) : [];
ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']), replyTo: typeof replyTo === 'string' ? replyTo : '', chatList: chatListForSwitcher });
})
.get("/pads", async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
const filter = String(ctx.query.filter || "all").toLowerCase();
const uid = getViewerId();
if (filter === "edit") {
const id = ctx.query.id;
if (!id) { ctx.redirect('/pads'); return; }
const pad = await padsModel.getPadById(id);
if (!pad || pad.author !== uid) { ctx.redirect('/pads'); return; }
ctx.body = await padsView([], "edit", pad, {});
return;
}
const q = String(ctx.query.q || "").trim();
const tribeId = ctx.query.tribeId || "";
const pads = await padsModel.listAll({ filter, viewerId: uid });
const fav = await mediaFavorites.getFavoriteSet('pads');
let enriched = pads.filter(p => !p.tribeId).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
enriched = await applyListFilters(enriched, ctx);
try { enriched = await lifetime.enrichAndFilter(enriched, { getKey: (x) => x.rootId || x.key }); } catch (_) {}
const spreadMap = await spreads.forMessages((enriched || []).map(x => x && (x.rootId || x.key || x.id)));
await warmAuthorNames(enriched);
ctx.body = await padsView(enriched, filter, null, { q, ...(tribeId ? { tribeId } : {}), spreadMap });
})
.get("/pads/:padId", async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
await padsModel.ingestKeys().catch(() => {});
const uid = getViewerId();
let pad = await padsModel.getPadById(ctx.params.padId);
if (!pad) { ctx.redirect('/pads'); return; }
let parentTribe = null;
if (pad.tribeId) {
try {
parentTribe = await tribesModel.getTribeById(pad.tribeId);
if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
await tribesModel.processIncomingKeys().catch(() => {});
pad = await padsModel.getPadById(ctx.params.padId);
} catch { ctx.redirect('/tribes'); return; }
} else {
const members = Array.isArray(pad.members) ? pad.members : [];
const isOpen = String(pad.status || '').toUpperCase() === 'OPEN';
if (!isOpen && pad.author !== uid && !members.includes(uid)) { ctx.redirect('/pads?filter=all'); return; }
}
const fav = await mediaFavorites.getFavoriteSet('pads');
const entries = await padsModel.getEntries(pad.rootId);
const versionKey = ctx.query.version || null;
const selectedVersion = versionKey
? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null)
: null;
const baseUrl = `${ctx.protocol}://${ctx.host}`;
const isTribeMember = !!parentTribe;
ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)), isTribeMember }, entries, { baseUrl, selectedVersion });
})
.get("/calendars", async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const filter = String(ctx.query.filter || "all").toLowerCase();
const uid = getViewerId();
if (filter === "edit") {
const id = ctx.query.id;
if (!id) { ctx.redirect('/calendars'); return; }
const cal = await calendarsModel.getCalendarById(id);
if (!cal || cal.author !== uid) { ctx.redirect('/calendars'); return; }
ctx.body = await calendarsView([], "edit", cal, {});
return;
}
const q = String(ctx.query.q || "").trim();
const tribeId = ctx.query.tribeId || "";
const modelFilter = filter === "favorites" ? "all" : filter;
const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid });
const fav = await mediaFavorites.getFavoriteSet('calendars');
const myTribeIds = await getUserTribeIds(uid);
const enriched = calendars.filter(c => !c.tribeId).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
let finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
finalList = await applyListFilters(finalList, ctx);
try { finalList = await lifetime.enrichAndFilter(finalList, { getKey: (x) => x.rootId || x.key }); } catch (_) {}
const spreadMap = await spreads.forMessages((finalList || []).map(x => x && (x.rootId || x.key || x.id)));
await warmAuthorNames(finalList);
ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}), spreadMap });
})
.get("/calendars/:calId", async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
await calendarsModel.ingestKeys().catch(() => {});
const uid = getViewerId();
const cal = await calendarsModel.getCalendarById(ctx.params.calId);
if (!cal) { ctx.redirect('/calendars'); return; }
let parentTribe = null;
if (cal.tribeId) {
try {
parentTribe = await tribesModel.getTribeById(cal.tribeId);
if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
} catch { ctx.redirect('/tribes'); return; }
} else {
const participants = Array.isArray(cal.participants) ? cal.participants : [];
const isOpen = String(cal.status || '').toUpperCase() === 'OPEN';
if (!isOpen && cal.author !== uid && !participants.includes(uid)) { ctx.redirect('/calendars?filter=all'); return; }
}
if (String(cal.status || '').toUpperCase() === 'CLOSED' && cal.author !== uid) {
ctx.body = tribeAccessDeniedView(parentTribe); return;
}
const dates = await calendarsModel.getDatesForCalendar(cal.rootId);
const notesByDate = {};
for (const d of dates) {
notesByDate[d.key] = await calendarsModel.getNotesForDate(cal.rootId, d.key);
}
const fav = await mediaFavorites.getFavoriteSet('calendars');
const month = String(ctx.query.month || "").trim() || null;
const day = String(ctx.query.day || "").trim() || null;
await enrichItemLifetime(cal, { key: cal.rootId });
ctx.body = await singleCalendarView({ ...cal, isFavorite: fav.has(String(cal.rootId)) }, dates, notesByDate, { month, day, spreads: await spreads.forMessage(cal.rootId).catch(() => null) });
})
.get("/projects", async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const filter = String(ctx.query.filter || "ALL").toUpperCase()
const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null);
if (filter === "CREATE") {
const prefill = {
title: String(ctx.query.title || ""),
description: String(ctx.query.description || ""),
goal: String(ctx.query.goal || "")
}
ctx.body = await projectsView([], "CREATE", null, { viewerPrefs, prefill })
return
}
const modelFilter = filter === "BACKERS" ? "ALL" : filter
let projects = await projectsModel.listProjects(modelFilter)
await enrichWithComments(projects)
projects = await applyListFilters(projects, ctx)
try { projects = await lifetime.enrichAndFilter(projects); } catch (_) {}
await enrichMsgSize(projects)
const spreadMap = await spreads.forMessages((projects || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(projects);
ctx.body = await projectsView(projects, filter, null, { viewerPrefs, spreadMap })
})
.get("/projects/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = ctx.params.id
const pr = await projectsModel.getProjectById(id)
ctx.body = await projectsView([pr], "EDIT")
})
.get("/projects/:projectId", async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const projectId = ctx.params.projectId
const filter = String(ctx.query.filter || "ALL").toUpperCase()
const project = await projectsModel.getProjectById(projectId)
const zoom = parseInt(ctx.query.zoom) || 2;
const [comments, mapData] = await Promise.all([getVoteComments(projectId), resolveMapUrl(project.mapUrl)])
await enrichMsgSize([project])
await enrichItemLifetime(project, { key: project.id || project.key })
ctx.body = await singleProjectView(withCount(project, comments), filter, comments, { mapData, zoom, baseUrl: resolveExternalBaseUrl(ctx), spreads: await spreads.forMessage(project.id).catch(() => null) })
})
.get("/c/projects/:projectId", async (ctx) => {
let project;
try { project = await projectsModel.getProjectById(ctx.params.projectId); } catch (_) {}
if (!project || String(project.status || '').toUpperCase() === 'CANCELLED') {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
const projAuthorPrefs = await about.visibilityPrefs(project.author).catch(() => null);
if (!projAuthorPrefs || projAuthorPrefs.clearnetProjects !== true) {
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
ctx.type = 'text/html';
ctx.body = await clearnetProjectView(project);
})
.get("/industry", async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
const filter = String(ctx.query.filter || "ALL").toUpperCase()
if (filter === "CREATE" || filter === "RULES") { ctx.body = await industryView([], filter); return }
if (filter === "BLUEPRINTS" || filter === "BUILDS") {
const q = String(ctx.query.search || "").trim().toLowerCase()
let items = filter === "BLUEPRINTS"
? await industryModel.listAllBlueprints().catch(() => [])
: await industryModel.listAllBuilds().catch(() => [])
if (q) items = items.filter(x => [x.name, x.title, x.description, x.notes, x.facilityName].some(v => String(v || "").toLowerCase().includes(q)))
const spreadMap = await spreads.forMessages((items || []).map(x => x && (x.id || x.key))).catch(() => new Map())
ctx.body = await industryView(items, filter, { spreadMap })
return
}
const search = String(ctx.query.search || "").trim()
const sector = String(ctx.query.sector || "").trim().toLowerCase()
let facilities = await industryModel.listFacilities(filter)
if (sector) facilities = facilities.filter(x => x.sector === sector)
if (search) {
const q = search.toLowerCase()
facilities = facilities.filter(x => [x.name, x.description, x.sector].some(v => String(v || "").toLowerCase().includes(q)))
}
try { facilities = await lifetime.enrichAndFilter(facilities); } catch (_) {}
await enrichMsgSize(facilities)
const spreadMap = await spreads.forMessages((facilities || []).map(x => x && (x.id || x.key)));
await warmAuthorNames(facilities);
ctx.body = await industryView(facilities, filter, { spreadMap, search, sector })
})
.get("/industry/edit/:id", async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
const fc = await industryModel.getFacilityById(ctx.params.id)
ctx.body = await industryView([fc], "EDIT")
})
.get("/industry/build/:id", async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const build = await industryModel.getBuild(ctx.params.id)
ctx.body = await singleBuildView(build, { kind: ctx.query.kind, spreads: await spreads.forMessage(build.id).catch(() => null) })
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 404 }) }
})
.get("/industry/builds/:id/edit", async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const build = await industryModel.getBuild(ctx.params.id)
const fc = await industryModel.getFacilityById(build.facilityId)
ctx.body = await buildEditView(build, fc)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 404 }) }
})
.get("/industry/blueprint/:id", async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const bp = await industryModel.getBlueprint(ctx.params.id)
ctx.body = await singleBlueprintView(bp, { spreads: await spreads.forMessage(bp.id).catch(() => null) })
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 404 }) }
})
.get("/industry/blueprints/:id/edit", async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const bp = await industryModel.getBlueprint(ctx.params.id)
const fc = await industryModel.getFacilityById(bp.facility)
ctx.body = await blueprintEditView(bp, fc)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 404 }) }
})
.get("/industry/:id", async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
const filter = String(ctx.query.filter || "ALL").toUpperCase()
const facility = await industryModel.getFacilityById(ctx.params.id)
const zoom = parseInt(ctx.query.zoom) || 2;
const [mapData, blueprints, builds, allJobs] = await Promise.all([
resolveMapUrl(facility.mapUrl),
industryModel.listBlueprints(ctx.params.id).catch(() => []),
industryModel.listBuilds(ctx.params.id, 'ALL').catch(() => []),
checkMod(ctx, 'jobsMod') ? jobsModel.listJobs('ALL', getViewerId()).catch(() => []) : Promise.resolve([])
])
const facilityRef = facility.root || facility.id
const facilityJobs = (allJobs || []).filter(j => j && j.industry === facilityRef)
await enrichMsgSize([facility])
await enrichItemLifetime(facility, { key: facility.id || facility.key })
const childSpreadMap = await spreads.forMessages([...(blueprints || []), ...(builds || [])].map(x => x && (x.id || x.key))).catch(() => new Map())
ctx.body = await singleFacilityView(facility, filter, { mapData, zoom, blueprints, builds, facilityJobs, childSpreadMap, spreads: await spreads.forMessage(facility.id).catch(() => null) })
})
.post("/industry/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null
const res = await industryModel.createFacility({ ...(ctx.request.body || {}), image })
ctx.redirect(`/industry/${encodeURIComponent(res.key)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : undefined
await industryModel.updateFacility(ctx.params.id, { ...(ctx.request.body || {}), ...(image !== undefined ? { image } : {}) })
ctx.redirect(`/industry/${encodeURIComponent(ctx.params.id)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try { await industryModel.deleteFacility(ctx.params.id); ctx.redirect('/industry?filter=MINE') }
catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/join/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try { await industryModel.joinFacility(ctx.params.id); safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) }
catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/apply/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const before = await industryModel.getFacilityById(ctx.params.id).catch(() => null)
await industryModel.joinFacility(ctx.params.id)
if (before && before.membershipPolicy === 'vote') {
const me = getViewerId()
for (const m of (before.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_APPLICATION", `A new habitant requested to join "${before.name}" -> /industry/${ctx.params.id}`); } catch (_) {} } }
}
safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/leave/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try { await industryModel.leaveFacility(ctx.params.id); safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) }
catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/invite/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
const invitee = ctx.request.body.invitee
try {
await industryModel.inviteToFacility(ctx.params.id, invitee)
try { const fc = await industryModel.getFacilityById(ctx.params.id); await pmModel.sendMessage([invitee], "INDUSTRY_INVITED", `You have been invited to join "${fc.name}" -> /industry/${ctx.params.id}`); } catch (_) {}
safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/govern/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
const subject = ctx.request.body.subject, ref = ctx.request.body.ref
try {
const before = await industryModel.getFacilityById(ctx.params.id).catch(() => null)
await industryModel.voteGovernance(ctx.params.id, subject, ref, ctx.request.body.choice)
const after = await industryModel.getFacilityById(ctx.params.id).catch(() => null)
const me = getViewerId()
if (subject === 'admit' && ref && before && after && !before.members.includes(ref) && after.members.includes(ref)) {
try { await pmModel.sendMessage([ref], "INDUSTRY_ADMITTED", `You have been admitted to "${after.name}" -> /industry/${ctx.params.id}`); } catch (_) {}
}
if (subject === 'dissolve' && before && after && before.status !== 'DISSOLVED' && after.status === 'DISSOLVED') {
for (const m of (after.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_DISSOLVED", `The facility "${after.name}" has been dissolved by collective vote -> /industry/${ctx.params.id}`); } catch (_) {} } }
}
safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post('/industry/opinions/:id/:category', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try { await industryModel.createOpinion(ctx.params.id, ctx.params.category); }
catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return; }
safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`)
})
.post("/industry/:fid/blueprints", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null
await industryModel.createBlueprint(ctx.params.fid, { ...(ctx.request.body || {}), image })
safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.fid)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/blueprints/:id/update", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : undefined
await industryModel.updateBlueprint(ctx.params.id, { ...(ctx.request.body || {}), ...(image !== undefined ? { image } : {}) })
const bp = await industryModel.getBlueprint(ctx.params.id).catch(() => null)
ctx.redirect(bp ? `/industry/${encodeURIComponent(bp.facility)}` : '/industry')
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/blueprints/:id/delete", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
const bp = await industryModel.getBlueprint(ctx.params.id).catch(() => null)
try { await industryModel.deleteBlueprint(ctx.params.id) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return }
ctx.redirect(bp && bp.facilityId ? `/industry/${encodeURIComponent(bp.facilityId)}` : '/industry?filter=BLUEPRINTS')
})
.post("/industry/:fid/builds", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null
const res = await industryModel.createBuild(ctx.params.fid, { ...(ctx.request.body || {}), image })
ctx.redirect(`/industry/build/${encodeURIComponent(res.key)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/builds/:id/update", koaBody(), async (ctx) => {
if (!checkMod(ctx, "industryMod")) { ctx.redirect("/modules"); return; }
try {
await industryModel.updateBuild(ctx.params.id, ctx.request.body || {})
safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/builds/:id/delete", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try { await industryModel.deleteBuild(ctx.params.id); ctx.redirect('/industry?filter=BUILDS') }
catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/builds/:id/vote", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const before = await industryModel.getBuild(ctx.params.id).catch(() => null)
await industryModel.voteBuild(ctx.params.id, ctx.request.body.choice)
const after = await industryModel.getBuild(ctx.params.id).catch(() => null)
if (before && after && before.status !== 'APPROVED' && after.status === 'APPROVED' && after.proposer !== getViewerId()) {
try { await pmModel.sendMessage([after.proposer], "INDUSTRY_BUILD_APPROVED", `Your build "${after.title}" has been approved -> /industry/build/${ctx.params.id}`); } catch (_) {}
}
safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/builds/:id/status", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try { await industryModel.setBuildStatus(ctx.params.id, ctx.request.body.status); safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) }
catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/builds/:id/contribute", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {}
try {
await industryModel.contribute(ctx.params.id, b)
safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.post("/industry/builds/:id/distribute", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'industryMod')) { ctx.redirect('/modules'); return; }
try {
const viewer = getViewerId()
const res = await industryModel.distributeBuild(ctx.params.id, { outputValue: ctx.request.body.outputValue })
for (const alloc of res.plan.allocations) {
if (!alloc || !alloc.to || alloc.to === viewer || !(alloc.amount > 0)) continue
try { await pmModel.sendMessage([alloc.to], "INDUSTRY_DISTRIBUTED", `Build "${res.build.title}" allocated you ${alloc.amount.toFixed(2)} ECO -> /industry/build/${ctx.params.id}`) } catch (_) {}
}
safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`)
} catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) }
})
.get("/banking", async (ctx) => {
if (!checkMod(ctx, 'bankingMod')) { ctx.redirect('/modules'); return; }
const userId = getViewerId();
const query = ctx.query;
const filter = (query.filter || 'overview').toLowerCase();
const q = (query.q || '').trim();
const msg = (query.msg || '').trim();
await bankingModel.ensureSelfAddressPublished();
const data = await bankingModel.listBanking(filter, userId);
data.isPub = bankingModel.isPubNode();
data.alreadyClaimed = data.summary?.alreadyClaimed || false;
if (filter === 'overview') {
const pending = (data.allocations || []).find(a => a.to === userId && (a.status === "UNCLAIMED" || a.status === "UNCONFIRMED"));
data.pendingUBI = pending || null;
}
if (filter === 'addresses' && q) {
data.addresses = (data.addresses || []).filter(x =>
String(x.id).toLowerCase().includes(q.toLowerCase()) ||
String(x.address).toLowerCase().includes(q.toLowerCase())
);
data.search = q;
}
data.flash = msg || '';
const { ecoValue, inflationFactor, inflationMonthly, ecoTimeMs, currentSupply, isSynced } = await bankingModel.calculateEcoinValue();
data.exchange = {
ecoValue,
inflationFactor,
inflationMonthly,
ecoTimeMs,
currentSupply,
totalSupply: 25500000,
isSynced
};
if (filter === 'taxes') {
const inspectBlock = async (blockId) => {
if (!blockId) return null;
try {
const blk = await blockchainModel.getBlockById(blockId, userId);
if (blk && blk.id) {
const sizeBytes = Number(blk.size || 0);
const grams = (sizeBytes / (1024 * 1024)) * 0.095;
const ecoinTax = grams * (bankingModel.ECOIN_PER_GRAM_CO2 || 0.1);
return { block: blockId, found: true, size: sizeBytes, author: blk.author, blockType: blk.type, ecoinTax };
}
} catch (_) {}
try {
const ssbClient = await cooler.open();
const raw = await new Promise((resolve, reject) => ssbClient.get(blockId, (err, v) => err ? reject(err) : resolve(v)));
if (raw) {
const sizeBytes = Buffer.byteLength(JSON.stringify(raw), 'utf8');
let bType = (raw.content && typeof raw.content === 'object' && raw.content.type) || null;
if (!bType && typeof raw.content === 'string' && raw.content.endsWith('.box')) {
try {
const dec = ssbClient.private.unbox({ key: blockId, value: raw, timestamp: raw.timestamp || 0 });
if (dec && dec.value && dec.value.content && dec.value.content.type) bType = dec.value.content.type;
} catch (_) {}
if (!bType) bType = 'encrypted';
}
const grams = (sizeBytes / (1024 * 1024)) * 0.095;
const ecoinTax = grams * (bankingModel.ECOIN_PER_GRAM_CO2 || 0.1);
return { block: blockId, found: true, size: sizeBytes, author: raw.author, blockType: bType || 'unknown', ecoinTax };
}
} catch (_) {}
return { block: blockId, found: false };
};
let firstKey = null;
try {
const ssbClient = await cooler.open();
firstKey = await new Promise((resolve) => {
let first = null;
pull(
ssbClient.createUserStream({ id: userId, limit: 1 }),
pull.drain(
(m) => { if (!first && m && m.key) first = m.key; },
() => resolve(first)
)
);
});
} catch (_) {}
let firstBlockSize = 0;
if (firstKey) {
const firstLookup = await inspectBlock(firstKey);
if (firstLookup && firstLookup.found) firstBlockSize = Number(firstLookup.size || 0);
}
data.firstBlock = firstKey || null;
data.firstBlockSize = firstBlockSize;
const blockId = String(query.block || '').trim();
let lookup = null;
if (blockId) lookup = await inspectBlock(blockId);
data.lookup = lookup;
const VALID_TAX_TYPES = ['eco', 'arch'];
const MANDATORY_TAX_TYPES = ['eco'];
const rawTypes = query.types;
let parsedTypes;
if (rawTypes === undefined) parsedTypes = null;
else if (Array.isArray(rawTypes)) parsedTypes = rawTypes;
else parsedTypes = String(rawTypes).split(',');
const selected = (parsedTypes === null
? VALID_TAX_TYPES.slice()
: parsedTypes
.map(s => String(s || '').trim().toLowerCase())
.filter(s => VALID_TAX_TYPES.includes(s))
);
const set = new Set(selected);
for (const t of MANDATORY_TAX_TYPES) set.add(t);
data.selectedTaxTypes = Array.from(set);
}
ctx.body = renderBankingView(data, filter, userId, data.isPub);
})
.get("/banking/allocation/:id", async (ctx) => {
const userId = getViewerId();
const allocation = await bankingModel.getAllocationById(ctx.params.id);
ctx.body = renderSingleAllocationView(allocation, userId);
})
.get("/banking/epoch/:id", async (ctx) => {
const epoch = await bankingModel.getEpochById(ctx.params.id);
const allocations = await bankingModel.listEpochAllocations(ctx.params.id);
ctx.body = renderEpochView(epoch, allocations);
})
.get("/favorites", async (ctx) => {
const filter = qf(ctx), data = await favoritesModel.listAll({ filter });
ctx.body = await favoritesView(data.items, filter, data.counts);
})
.get("/logs", async (ctx) => {
if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
const view = String(ctx.query.view || 'list');
const aiModOn = logsModel.isAImodOn();
if (view === 'create') {
const mode = ctx.query.mode === 'ai' ? 'ai' : 'manual';
ctx.body = logsView([], 'today', mode, { view: 'create', aiModOn });
return;
}
if (view === 'edit') {
const id = String(ctx.query.id || '');
const entry = id ? await logsModel.getLogById(id) : null;
if (!entry) { ctx.redirect('/logs'); return; }
ctx.body = logsView([], 'today', entry.mode, { view: 'edit', aiModOn, entry });
return;
}
const filter = qf(ctx, 'today');
const q = String(ctx.query.q || '').trim().toLowerCase();
const typeQ = String(ctx.query.type || '').trim().toLowerCase();
const dateQ = String(ctx.query.date || '').trim();
let items = await logsModel.listLogs(filter);
if (q) items = items.filter(i => String(i.text || '').toLowerCase().includes(q) || String(i.label || '').toLowerCase().includes(q));
if (typeQ === 'ai' || typeQ === 'manual') items = items.filter(i => (i.mode === 'ai' ? 'ai' : 'manual') === typeQ);
if (/^\d{4}-\d{2}-\d{2}$/.test(dateQ)) {
const start = new Date(dateQ + 'T00:00:00').getTime();
const end = start + 24 * 60 * 60 * 1000;
items = items.filter(i => i.ts >= start && i.ts < end);
}
ctx.body = logsView(items, filter, null, { view: 'list', aiModOn, search: { q: ctx.query.q || '', type: typeQ, date: dateQ } });
})
.get("/logs/view/:id", async (ctx) => {
if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
const entry = await logsModel.getLogById(ctx.params.id);
if (!entry) { ctx.redirect('/logs'); return; }
const aiModOn = logsModel.isAImodOn();
ctx.body = logsView([], 'today', entry.mode, { view: 'detail', aiModOn, entry });
})
.post("/logs/create", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const mode = b.mode === 'ai' ? 'ai' : 'manual';
try {
if (mode === 'ai') { startAI(); await logsModel.createAI(); }
else await logsModel.createManual(b.label || '', b.text || '');
} catch (_) {}
ctx.redirect('/logs');
})
.post("/logs/edit/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
try { await logsModel.updateLog(ctx.params.id, { label: b.label || '', text: b.text || '' }); } catch (_) {}
ctx.redirect('/logs');
})
.post("/logs/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
try { await logsModel.deleteLog(ctx.params.id); } catch (_) {}
ctx.redirect('/logs');
})
.get("/logs/export", async (ctx) => {
if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
const items = await logsModel.listLogs('always');
const pdf = await buildLogsPdf(items, getViewerId());
ctx.set('Content-Type', 'application/pdf');
ctx.set('Content-Disposition', `attachment; filename="oasis-logs-${Date.now()}.pdf"`);
ctx.body = pdf;
})
.get("/logs/export/:id", async (ctx) => {
if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
const entry = await logsModel.getLogById(ctx.params.id);
if (!entry) { ctx.redirect('/logs'); return; }
const pdf = await buildLogsPdf([entry], getViewerId());
ctx.set('Content-Type', 'application/pdf');
ctx.set('Content-Disposition', `attachment; filename="oasis-log-${Date.now()}.pdf"`);
ctx.body = pdf;
})
.get('/cipher', async (ctx) => {
if (!checkMod(ctx, 'cipherMod')) { ctx.redirect('/modules'); return; }
try {
ctx.body = await cipherView();
} catch (error) {
sendErrorPage(ctx, error.message);
}
})
.get("/thread/:message", async (ctx) => {
const { message } = ctx.params;
const thread = async (message) => {
const messages = await post.fromThread(message);
const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
return threadView({ messages, spreadMap });
};
ctx.body = await thread(message);
})
.get("/subtopic/:message", async (ctx) => {
const { message } = ctx.params;
const rootMessage = await post.get(message);
const myFeedId = await meta.myFeedId();
debug("%O", rootMessage);
const messages = [rootMessage];
const spreadMap = await spreads.forMessages(messages.map(m => m && m.key)).catch(() => new Map());
ctx.body = await subtopicView({ messages, myFeedId, spreadMap });
})
.get("/publish", async (ctx) => {
ctx.body = await publishView();
})
.get("/comment/:message", async (ctx) => {
const { messages, myFeedId, parentMessage } =
await resolveCommentComponents(ctx);
const allKeys = [parentMessage && parentMessage.key, ...(messages || []).map(m => m && m.key)].filter(Boolean);
const spreadMap = await spreads.forMessages(allKeys).catch(() => new Map());
ctx.body = await commentView({ messages, myFeedId, parentMessage, spreadMap });
})
.get("/wallet", async (ctx) => {
const { url, user, pass } = getConfig().wallet;
if (!checkMod(ctx, 'walletMod')) { ctx.redirect('/modules'); return; }
try {
const balance = await walletModel.getBalance(url, user, pass);
const address = await walletModel.getAddress(url, user, pass);
const userId = getViewerId();
if (address && typeof address === "string") {
const map = readAddrMap();
const was = map[userId];
if (was !== address) {
map[userId] = address;
writeAddrMap(map);
try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
}
}
ctx.body = await walletView(balance, address);
} catch (error) {
ctx.body = await walletErrorView(error);
}
})
.get("/wallet/history", async (ctx) => {
const { url, user, pass } = getConfig().wallet;
try {
const balance = await walletModel.getBalance(url, user, pass);
const transactions = await walletModel.listTransactions(url, user, pass);
const address = await walletModel.getAddress(url, user, pass);
const userId = getViewerId();
if (address && typeof address === "string") {
const map = readAddrMap();
const was = map[userId];
if (was !== address) {
map[userId] = address;
writeAddrMap(map);
try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
}
}
ctx.body = await walletHistoryView(balance, transactions, address);
} catch (error) {
ctx.body = await walletErrorView(error);
}
})
.get("/wallet/receive", async (ctx) => {
const { url, user, pass } = getConfig().wallet;
try {
const balance = await walletModel.getBalance(url, user, pass);
const address = await walletModel.getAddress(url, user, pass);
const userId = getViewerId();
if (address && typeof address === "string") {
const map = readAddrMap();
const was = map[userId];
if (was !== address) {
map[userId] = address;
writeAddrMap(map);
try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
}
}
ctx.body = await walletReceiveView(balance, address);
} catch (error) {
ctx.body = await walletErrorView(error);
}
})
.get("/wallet/send", async (ctx) => {
const { url, user, pass, fee } = getConfig().wallet;
try {
const balance = await walletModel.getBalance(url, user, pass);
const address = await walletModel.getAddress(url, user, pass);
const userId = getViewerId();
if (address && typeof address === "string") {
const map = readAddrMap();
const was = map[userId];
if (was !== address) {
map[userId] = address;
writeAddrMap(map);
try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
}
}
ctx.body = await walletSendFormView(balance, null, null, fee, null, address);
} catch (error) {
ctx.body = await walletErrorView(error);
}
})
.get('/transfers', async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
let filter = ctx.query.filter || 'all'; if (filter === 'favs') filter = 'all';
let list = await transfersModel.listAll(filter, getViewerId());
try { list = await lifetime.enrichAndFilter(list, { getAuthor: (x) => x.from }); } catch (_) {}
await enrichMsgSize(list);
const spreadMap = await spreads.forMessages((list || []).map(x => x && (x.id || x.key)));
const prefill = filter === 'create' ? { to: ctx.query.to || '', amount: ctx.query.amount || '', concept: ctx.query.concept || '', category: ctx.query.category || '' } : undefined;
await warmAuthorNames(list);
ctx.body = await transferView(list, filter, null, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent', category: ctx.query.category || '', spreadMap, prefill });
})
.get('/transfers/edit/:id', async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
const tr = await transfersModel.getTransferById(ctx.params.id, getViewerId());
ctx.body = await transferView([tr], 'edit', ctx.params.id, {});
})
.get('/transfers/contract/:transferId', async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
const transfer = await transfersModel.getTransferById(ctx.params.transferId, getViewerId());
if (!transfer) { ctx.redirect('/transfers'); return; }
let block = null;
try {
const ssbX = await cooler.open();
const msg = await new Promise((resolve) => {
ssbX.get(transfer.id, (err, m) => resolve(err ? null : m));
});
if (msg && msg.content) {
block = {
id: transfer.id,
author: msg.author,
ts: msg.timestamp || (msg.value && msg.value.timestamp) || transfer.createdAt,
type: msg.content.type,
size: Buffer.byteLength(JSON.stringify(msg), 'utf8')
};
}
} catch (_) {}
const pdf = buildSmartContractPdf({ transfer, block, viewerId: getViewerId() });
ctx.set('Content-Type', 'application/pdf');
ctx.set('Content-Disposition', `attachment; filename="oasis-smart-contract-${transfer.id.replace(/[^A-Za-z0-9]/g, '_').slice(0, 16)}.pdf"`);
ctx.body = pdf;
})
.get('/transfers/:transferId', async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
let filter = ctx.query.filter || 'all'; if (filter === 'favs') filter = 'all';
const transfer = await transfersModel.getTransferById(ctx.params.transferId, getViewerId());
let block = null;
if (transfer && transfer.id) {
try {
const ssbX = await cooler.open();
const msg = await new Promise((resolve) => {
ssbX.get(transfer.id, (err, m) => resolve(err ? null : m));
});
if (msg && msg.content) {
const size = Buffer.byteLength(JSON.stringify(msg), 'utf8');
block = {
id: transfer.id,
author: msg.author,
ts: msg.timestamp || (msg.value && msg.value.timestamp) || transfer.createdAt,
type: msg.content.type,
size
};
if (transfer) transfer.msgSize = size;
}
} catch (_) { block = null; }
}
await enrichItemLifetime(transfer, { author: transfer.from });
ctx.body = await singleTransferView(transfer, filter, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent', returnTo: safeReturnTo(ctx, `/transfers?filter=${encodeURIComponent(filter)}`, ['/transfers']), block, spreads: await spreads.forMessage(transfer.id).catch(() => null) });
})
.post('/ai', koaBody(), async (ctx) => {
const { input } = ctx.request.body;
if (!input) {
sendErrorPage(ctx, 'No input provided', { status: 400 });
return;
}
startAI();
const i18nAll = require('../client/assets/translations/i18n');
const lang = ctx.cookies.get('language') || getConfig().language || 'en';
const translations = i18nAll[lang] || i18nAll['en'];
const { setLanguage } = require('../views/main_views');
setLanguage(lang);
const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
let chatHistory = [];
try {
const fileData = fs.readFileSync(historyPath, 'utf-8');
chatHistory = JSON.parse(fileData);
} catch {
chatHistory = [];
}
const config = getConfig();
const userPrompt = config.ai?.prompt?.trim() || 'Provide an informative and precise response.';
try {
let aiResponse = '';
let snippets = [];
const trained = await getBestTrainedAnswer(input);
if (trained && trained.answer) {
aiResponse = trained.answer;
snippets = Array.isArray(trained.ctx) ? trained.ctx : [];
} else {
const response = await axios.post('http://localhost:4001/ai', { input });
aiResponse = response.data.answer;
snippets = Array.isArray(response.data.snippets) ? response.data.snippets : [];
}
chatHistory.unshift({
prompt: userPrompt,
question: input,
answer: aiResponse,
timestamp: Date.now(),
trainStatus: 'pending',
snippets
});
} catch (e) {
chatHistory.unshift({
prompt: userPrompt,
question: input,
answer: translations.aiServerError || 'The AI could not answer. Please try again.',
timestamp: Date.now(),
trainStatus: 'rejected',
snippets: []
});
}
chatHistory = chatHistory.slice(0, 20);
fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
ctx.body = aiView(chatHistory, userPrompt);
})
.post('/ai/approve', koaBody(), async (ctx) => {
const ts = String(ctx.request.body.ts || '');
const custom = String(ctx.request.body.custom || '').trim();
const rawTags = String(ctx.request.body.tags || '').trim();
const tagsList = rawTags
? rawTags.split(/[,\n]/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean)
: [];
const ratingRaw = parseInt(ctx.request.body.rating, 10);
const rating = Number.isFinite(ratingRaw) ? Math.max(0, Math.min(5, ratingRaw)) : 0;
const cfg = getConfig();
const lang = ctx.cookies.get('language') || cfg.language || '';
const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
let chatHistory = [];
try {
const fileData = fs.readFileSync(historyPath, 'utf-8');
chatHistory = JSON.parse(fileData);
} catch {
chatHistory = [];
}
const item = chatHistory.find(e => String(e.timestamp) === ts);
if (item) {
try {
if (custom) item.answer = stripDangerousTags(custom);
item.type = 'aiExchange';
let snippets = fieldsForSnippet('aiExchange', item);
if (snippets.length === 0) {
const context = await buildContext();
snippets = [context];
} else {
snippets = snippets.map(snippet => clip(snippet, 200));
}
await publishExchange({
q: item.question,
a: item.answer,
ctx: snippets,
tokens: {},
lang,
tags: tagsList,
rating
});
item.trainStatus = 'approved';
if (tagsList.length) item.tags = tagsList;
if (rating > 0) item.rating = rating;
if (lang) item.lang = lang;
} catch {
item.trainStatus = 'failed';
}
fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
}
const config = getConfig();
const userPrompt = config.ai?.prompt?.trim() || '';
ctx.body = aiView(chatHistory, userPrompt);
})
.post('/ai/exchange/vote', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'aiMod')) return ctx.redirect('/modules');
const targetId = String((ctx.request.body && ctx.request.body.target) || '').trim();
const helpful = !((ctx.request.body && ctx.request.body.helpful) === 'no');
if (targetId) {
try { await publishExchangeVote({ targetId, helpful }); } catch (_) {}
}
const back = String((ctx.request.body && ctx.request.body.returnTo) || '/activity').trim();
ctx.redirect(back && back.startsWith('/') ? back : '/activity');
})
.post('/ai/reject', koaBody(), async (ctx) => {
const i18nAll = require('../client/assets/translations/i18n');
const lang = ctx.cookies.get('language') || getConfig().language || 'en';
const { setLanguage } = require('../views/main_views');
setLanguage(lang);
const ts = String(ctx.request.body.ts || '');
const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
let chatHistory = [];
try {
const fileData = fs.readFileSync(historyPath, 'utf-8');
chatHistory = JSON.parse(fileData);
} catch {
chatHistory = [];
}
const item = chatHistory.find(e => String(e.timestamp) === ts);
if (item) {
item.trainStatus = 'rejected';
fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
}
const config = getConfig();
const userPrompt = config.ai?.prompt?.trim() || '';
ctx.body = aiView(chatHistory, userPrompt);
})
.post('/ai/clear', async (ctx) => {
const i18nAll = require('../client/assets/translations/i18n');
const lang = ctx.cookies.get('language') || getConfig().language || 'en';
const { setLanguage } = require('../views/main_views');
setLanguage(lang);
const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
fs.writeFileSync(historyPath, '[]', 'utf-8');
const config = getConfig();
const userPrompt = config.ai?.prompt?.trim() || '';
ctx.body = aiView([], userPrompt);
})
.get('/ai/ask', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const raw = String(ctx.query?.q || ctx.query?.prompt || '').trim();
if (!raw) { ctx.redirect('/'); return; }
const routesIndex = require('../AI/routes_index');
const { aiNavResultsView } = require('../views/main_views');
const isModuleEnabled = (modName) => checkMod(ctx, modName);
let results = [];
if (checkMod(ctx, 'aiNavMod')) {
try {
const embedder = require('../AI/embedder');
if (embedder.isInstalled()) {
const vec = await embedder.embed(raw);
if (vec) {
results = await routesIndex.resolveTopK(vec, { isModuleEnabled, embed: embedder.embed }, 8);
}
}
} catch (_) {}
}
if (!Array.isArray(results) || results.length === 0) {
results = routesIndex.resolveKeywordTopK({ isModuleEnabled }, raw, 8);
}
ctx.body = await aiNavResultsView({ query: raw, results: results || [] });
})
.post('/ai/ask', koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!checkMod(ctx, 'aiNavMod')) {
sendErrorPage(ctx, require('../views/main_views').i18n.aiNavDisabled || 'AI navigation is disabled.', { status: 403 });
return;
}
const raw = String(ctx.request.body?.q || ctx.request.body?.prompt || '').trim();
if (!raw) { ctx.redirect('/'); return; }
const ssbRefLib = require('../server/node_modules/ssb-ref');
const hashtagMatch = raw.match(/^#([0-9A-Za-z_À-￿-]+)/);
if (hashtagMatch) {
ctx.redirect('/search?query=' + encodeURIComponent('#' + hashtagMatch[1]));
return;
}
const feedMatch = raw.match(/^@?([A-Za-z0-9+/=._-]+\.ed25519)\b/);
if (feedMatch) {
const id = (feedMatch[0].startsWith('@') ? feedMatch[0] : '@' + feedMatch[1]);
if (ssbRefLib.isFeed(id)) { ctx.redirect('/author/' + encodeURIComponent(id)); return; }
}
if (/^https?:\/\//i.test(raw)) {
try {
const u = new URL(raw);
if (u.host === ctx.host) { ctx.redirect(u.pathname + u.search + u.hash); return; }
} catch (_) {}
}
try {
const embedder = require('../AI/embedder');
const routesIndex = require('../AI/routes_index');
if (!embedder.isInstalled()) {
ctx.redirect('/search?query=' + encodeURIComponent(raw));
return;
}
const vec = await embedder.embed(raw);
if (!vec) {
ctx.redirect('/search?query=' + encodeURIComponent(raw));
return;
}
const isModuleEnabled = (modName) => checkMod(ctx, modName);
const best = await routesIndex.resolveBest(vec, { isModuleEnabled, embed: embedder.embed });
if (best && best.path) { ctx.redirect(best.path); return; }
} catch (_) {}
ctx.redirect('/search?query=' + encodeURIComponent(raw));
})
.post('/pixelia/paint', koaBody(), async (ctx) => {
const x = Number(ctx.request.body.x), y = Number(ctx.request.body.y), color = ctx.request.body.color;
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 1 || x > 50 || y < 1 || y > 200) {
const errorMessage = 'Coordinates are wrong!';
const pixelArt = await pixeliaModel.listPixels();
ctx.body = pixeliaView(pixelArt, errorMessage);
return;
}
await pixeliaModel.paintPixel(x, y, color);
ctx.redirect('/pixelia');
})
.post('/pm', koaBody(), async ctx => {
const { recipients, subject, text, crypter, precomputed, crypterKey } = ctx.request.body;
const recipientsArr = (recipients || '').split(',').map(s => s.trim()).filter(Boolean).filter(id => ssbRef.isFeedId(id));
if (recipientsArr.length === 0) { ctx.throw(400, 'No valid recipients'); return; }
const cfgNow = getConfig();
if (cfgNow.pmVisibility === 'mutuals') {
const viewer = getViewerId();
for (const rid of recipientsArr) {
if (rid === viewer) continue;
let rel = null;
try { rel = await friend.getRelationship(rid); } catch (e) { rel = null; }
if (!pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: viewer, recipientId: rid, relationship: rel })) {
ctx.throw(403, 'You can only send private messages to habitants with mutual support.');
}
}
}
const cleanSubject = stripDangerousTags(subject);
const cleanText = stripDangerousTags(text);
if (crypter) {
let key = (typeof crypterKey === 'string' && crypterKey.length >= 32) ? crypterKey : cipherModel.generateKey();
let encryptedText;
if (typeof precomputed === 'string' && precomputed) {
encryptedText = precomputed;
} else {
({ encryptedText } = cipherModel.encryptData(cleanText, key));
}
if (encryptedText.length > PM_CRYPTER_MAX) {
ctx.body = await pmView(recipients, subject, text, false, '', true);
return;
}
try {
await pmModel.sendMessage(recipientsArr, cleanSubject, encryptedText, true);
} catch (_) {
ctx.body = await pmView(recipients, subject, text, false, '', true);
return;
}
await refreshInboxCount();
ctx.body = await pmView('', '', '', false, key);
return;
}
try {
await pmModel.sendMessage(recipientsArr, cleanSubject, cleanText);
} catch (e) {
if (isSsbTooLargeError(e)) {
sendErrorPage(ctx, require('../views/main_views').i18n.publishTooLong || 'Your message is too long. Please shorten it.', { status: 400 });
return;
}
throw e;
}
await refreshInboxCount();
ctx.redirect('/inbox?filter=sent');
})
.post('/pm/preview', koaBody(), async ctx => {
const { recipients = '', subject = '', text = '', crypter } = ctx.request.body;
const validRecipients = (recipients || '').split(',').map(s => s.trim()).filter(Boolean).filter(id => ssbRef.isFeedId(id));
if (validRecipients.length === 0) {
ctx.body = await pmView(recipients, subject, text, false, '', false, null, true);
return;
}
if (crypter) {
const key = cipherModel.generateKey();
const { encryptedText } = cipherModel.encryptData(stripDangerousTags(text), key);
if (encryptedText.length > PM_CRYPTER_MAX) {
ctx.body = await pmView(recipients, subject, text, false, '', true);
return;
}
ctx.body = await pmView(recipients, subject, text, true, '', false, { key, cipher: encryptedText });
return;
}
ctx.body = await pmView(recipients, subject, text, true);
})
.post('/pm/file/preview', koaBodyFileshare, async ctx => {
const b = ctx.request.body || {};
const file = ctx.request.files && (ctx.request.files.file || ctx.request.files.blob);
const cleanup = () => { try { if (file && file.filepath) fs.unlinkSync(file.filepath); } catch (_) {} };
const recipient = String(b.recipient || '').trim();
if (!ssbRef.isFeedId(recipient)) { cleanup(); ctx.redirect('/pm?fileerror=recipient#fileshare'); return; }
const cfgNow = getConfig();
if (cfgNow.pmVisibility === 'mutuals' && recipient !== getViewerId()) {
let rel = null;
try { rel = await friend.getRelationship(recipient); } catch (_) { rel = null; }
if (!pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: getViewerId(), recipientId: recipient, relationship: rel })) {
cleanup(); ctx.redirect('/pm?fileerror=mutual#fileshare'); return;
}
}
const fmtB = (n) => { const x = Number(n) || 0; if (x < 1024) return x + ' B'; const u = ['KB','MB','GB','TB']; let v = x / 1024, i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return v.toFixed(v >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; };
if (!file || !file.filepath || !file.size) { cleanup(); ctx.redirect('/pm?fileerror=nofile#fileshare'); return; }
if (file.size > FILESHARE_MAX_SIZE) { cleanup(); ctx.redirect('/pm?fileerror=size#fileshare'); return; }
let pointer;
try {
pointer = await fileshareModel.createShareFromFile({
filepath: file.filepath,
filename: file.originalFilename || file.name || 'file',
mime: file.mimetype || null
});
} catch (_) { cleanup(); ctx.redirect('/pm?fileerror=failed#fileshare'); return; }
cleanup();
const useCrypter = !!b.crypter;
const sharedKey = useCrypter ? cipherModel.generateKey() : '';
ctx.body = await pmView(recipient, stripDangerousTags(b.subject || ''), '', false, '', false, null, false, '', {
recipient, subject: stripDangerousTags(b.subject || ''),
manifestBlobId: pointer.manifestBlobId, keyHex: pointer.key,
filename: pointer.filename, mime: pointer.mime, size: pointer.size,
sizeLabel: fmtB(pointer.size), crypter: useCrypter, sharedKey
});
})
.post('/pm/file', koaBodyFileshare, async ctx => {
const b = ctx.request.body || {};
const file = ctx.request.files && (ctx.request.files.file || ctx.request.files.blob);
const cleanup = () => { try { if (file && file.filepath) fs.unlinkSync(file.filepath); } catch (_) {} };
const recipient = String(b.recipient || '').trim();
if (!ssbRef.isFeedId(recipient)) { cleanup(); ctx.redirect('/pm?fileerror=recipient#fileshare'); return; }
const cfgNow = getConfig();
if (cfgNow.pmVisibility === 'mutuals' && recipient !== getViewerId()) {
let rel = null;
try { rel = await friend.getRelationship(recipient); } catch (_) { rel = null; }
if (!pmPolicy.isRecipientAllowed({ pmVisibility: cfgNow.pmVisibility, viewerId: getViewerId(), recipientId: recipient, relationship: rel })) {
cleanup(); ctx.redirect('/pm?fileerror=mutual#fileshare'); return;
}
}
if (!file && b.manifestBlobId) {
let pointer = {
type: 'fileShare', v: 1, key: String(b.keyHex || ''),
manifestBlobId: String(b.manifestBlobId), filename: String(b.filename || 'file'),
mime: String(b.mime || 'application/octet-stream'), size: Number(b.size) || 0
};
const useCrypter = !!b.crypter;
if (useCrypter) {
const sk = String(b.sharedKey || '');
if (sk.length >= 32) { const { encryptedText } = cipherModel.encryptData(pointer.key, sk); pointer = { ...pointer, key: encryptedText, crypter: true }; }
}
try { await pmModel.sendFileShare([recipient], stripDangerousTags(b.subject || ''), pointer, useCrypter); }
catch (_) { ctx.redirect('/pm?fileerror=send#fileshare'); return; }
await refreshInboxCount();
ctx.redirect('/inbox?filter=sent'); return;
}
if (!file || !file.filepath || !file.size) { cleanup(); ctx.redirect('/pm?fileerror=nofile#fileshare'); return; }
if (file.size > FILESHARE_MAX_SIZE) { cleanup(); ctx.redirect('/pm?fileerror=size#fileshare'); return; }
let pointer;
try {
pointer = await fileshareModel.createShareFromFile({
filepath: file.filepath,
filename: file.originalFilename || file.name || 'file',
mime: file.mimetype || null
});
} catch (_) { cleanup(); ctx.redirect('/pm?fileerror=failed#fileshare'); return; }
cleanup();
const useCrypter = !!b.crypter;
let sharedKey = '';
if (useCrypter) {
sharedKey = (typeof b.crypterKey === 'string' && b.crypterKey.length >= 32) ? b.crypterKey : cipherModel.generateKey();
const { encryptedText } = cipherModel.encryptData(pointer.key, sharedKey);
pointer = { ...pointer, key: encryptedText, crypter: true };
}
try {
await pmModel.sendFileShare([recipient], stripDangerousTags(b.subject || ''), pointer, useCrypter);
} catch (_) { ctx.redirect('/pm?fileerror=send#fileshare'); return; }
await refreshInboxCount();
if (useCrypter) { ctx.body = await pmView('', '', '', false, sharedKey); return; }
ctx.redirect('/inbox?filter=sent');
})
.get('/inbox/file/:id', async ctx => {
if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; }
const messages = await buildInboxMessages();
const msg = messages.find(m => m && m.key === ctx.params.id);
const fileShare = msg && msg.value && msg.value.content && msg.value.content.fileShare;
if (!fileShare) { ctx.redirect('/inbox'); return; }
let pointer = fileShare;
if (fileShare.crypter) {
const key = ctx.query && ctx.query.key;
if (typeof key !== 'string' || !key) { ctx.redirect('/inbox'); return; }
try {
pointer = { ...fileShare, key: cipherModel.decryptData(fileShare.key, key) };
} catch (_) { ctx.redirect('/inbox?filekey=bad'); return; }
}
if (!(await fileshareModel.ensureAvailable(pointer))) { ctx.redirect('/inbox?filestatus=unavailable'); return; }
const safeName = String(fileShare.filename || 'file').replace(/["\r\n\\]/g, '');
ctx.type = fileShare.mime || 'application/octet-stream';
ctx.set('Content-Disposition', `attachment; filename="${safeName}"`);
const stream = fileshareModel.readShareStream(pointer);
if (msg.value.author && msg.value.author !== getViewerId()) {
stream.on('end', () => { fileshareModel.removeLocalBlobs(pointer).catch(() => {}); });
}
ctx.body = stream;
})
.post('/inbox/file/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; }
const { key } = ctx.request.body || {};
const messages = await buildInboxMessages();
const msg = messages.find(m => m && m.key === ctx.params.id);
const fileShare = msg && msg.value && msg.value.content && msg.value.content.fileShare;
if (!fileShare) { ctx.redirect('/inbox'); return; }
let pointer = fileShare;
if (fileShare.crypter) {
if (typeof key !== 'string' || !key) { ctx.redirect('/inbox'); return; }
try {
pointer = { ...fileShare, key: cipherModel.decryptData(fileShare.key, key) };
} catch (_) { ctx.redirect('/inbox?filekey=bad'); return; }
}
if (!(await fileshareModel.ensureAvailable(pointer))) { ctx.redirect('/inbox?filestatus=unavailable'); return; }
const safeName = String(fileShare.filename || 'file').replace(/["\r\n\\]/g, '');
ctx.type = fileShare.mime || 'application/octet-stream';
ctx.set('Content-Disposition', `attachment; filename="${safeName}"`);
const stream = fileshareModel.readShareStream(pointer);
if (msg.value.author && msg.value.author !== getViewerId()) {
stream.on('end', () => { fileshareModel.removeLocalBlobs(pointer).catch(() => {}); });
}
ctx.body = stream;
})
.post('/inbox/delete/:id', koaBody(), async ctx => {
await pmModel.deleteMessageById(ctx.params.id);
await refreshInboxCount();
ctx.redirect('/inbox');
})
.post("/search", koaBody(), async (ctx) => {
const b = ctx.request.body, query = b.query || "";
let types = b.type || [];
if (typeof types === "string") types = [types];
if (!Array.isArray(types)) types = [];
if (!query) return ctx.body = await searchView({ messages: [], query, types });
const userId = getViewerId();
const allTribes = await tribesModel.listAll();
const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id));
const applySearchPrivacy = (msgs) => msgs.filter(msg => {
const c = msg.value?.content;
if (!c) return true;
if (Array.isArray(c.recps)) return false;
if (c.type === 'post' && (c.private === true || c.recps)) return false;
if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
if (c.status === 'PRIVATE') return false;
if (c.type === 'shop' && (c.visibility === 'CLOSED' || c.encryptedPayload) && c.author !== userId) return false;
return true;
});
const results = await searchModel.search({ query, types });
ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
const filtered = applySearchPrivacy(msgs).map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
if (filtered.length > 0) acc[type] = filtered;
return acc;
}, {}), query, types });
})
.post("/subtopic/preview/:message",
koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }),
async (ctx) => {
const { message } = ctx.params;
const rootMessage = await post.get(message);
const myFeedId = await meta.myFeedId();
const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning).trim());
const contentWarning =
rawContentWarning.length > 0 ? rawContentWarning : undefined;
const messages = [rootMessage];
const previewData = await preparePreview(ctx);
ctx.body = await previewSubtopicView({
messages,
myFeedId,
previewData,
contentWarning,
});
}
)
.post("/subtopic/:message", koaBody(), async (ctx) => {
const { message } = ctx.params;
const text = stripDangerousTags(String(ctx.request.body.text));
const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning).trim());
const contentWarning =
rawContentWarning.length > 0 ? rawContentWarning : undefined;
const publishSubtopic = async ({ message, text }) => {
const mentions = extractMentions(text);
const parent = await post.get(message);
return post.subtopic({
parent,
message: { text, mentions, contentWarning },
});
};
ctx.body = await publishSubtopic({ message, text });
ctx.redirect(`/thread/${encodeURIComponent(message)}`);
})
.post("/comment/preview/:message", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const { messages, contentWarning, myFeedId, parentMessage } = await resolveCommentComponents(ctx);
const previewData = await preparePreview(ctx);
ctx.body = await previewCommentView({
messages,
myFeedId,
contentWarning,
previewData,
parentMessage,
});
})
.post("/comment/:message", koaBody(), async (ctx) => {
let decodedMessage;
try {
decodedMessage = decodeURIComponent(ctx.params.message);
} catch {
decodedMessage = ctx.params.message;
}
const text = stripDangerousTags(String(ctx.request.body.text));
const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning));
const contentWarning =
rawContentWarning.length > 0 ? rawContentWarning : undefined;
let mentions = extractMentions(text);
if (!Array.isArray(mentions)) mentions = [];
const parent = await meta.get(decodedMessage);
ctx.body = await post.comment({
parent,
message: {
text,
mentions,
contentWarning
},
});
ctx.redirect(`/thread/${encodeURIComponent(parent.key)}`);
})
.post("/publish/preview", koaBody({multipart: true, formidable: { multiples: false, maxFileSize: maxSize }, urlencoded: true }), async (ctx) => {
const cw = stripDangerousTags(ctx.request.body.contentWarning?.toString().trim() || "");
ctx.body = await previewView({ previewData: await preparePreview(ctx), contentWarning: cw.length > 0 ? cw : undefined });
})
.post("/publish", koaBody({ multipart: true, urlencoded: true, formidable: { multiples: false, maxFileSize: maxSize } }), async (ctx) => {
const b = ctx.request.body, text = stripDangerousTags(b.text?.toString().trim() || ""), cw = stripDangerousTags(b.contentWarning?.toString().trim() || "");
let mentions = [];
try { mentions = JSON.parse(b.mentions || "[]"); } catch { mentions = await extractMentions(text); }
const approxContent = { type: 'post', text, ...(Array.isArray(mentions) && mentions.length ? { mentions } : {}), ...(cw.length > 0 ? { contentWarning: cw } : {}) };
const tooLongMsg = require('../views/main_views').i18n.publishTooLong || 'Your post is too long. Please shorten it.';
if (exceedsSsbLimit(approxContent)) {
sendErrorPage(ctx, tooLongMsg, { status: 400 });
return;
}
try {
await post.root({ text, mentions, contentWarning: cw.length > 0 ? cw : undefined });
} catch (e) {
if (isSsbTooLargeError(e)) { sendErrorPage(ctx, tooLongMsg, { status: 400 }); return; }
throw e;
}
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect("/public/latest");
})
.post("/publish/custom", koaBody(), async (ctx) => {
const text = String(ctx.request.body.text);
const obj = JSON.parse(text);
const ALLOWED_TYPES = ['post','about','contact','vote','pub','channel'];
if (!obj.type || !ALLOWED_TYPES.includes(obj.type)) { ctx.throw(400, 'Invalid message type'); return; }
const sanitizeObj = (o) => { for (const k of Object.keys(o)) { if (typeof o[k] === 'string') o[k] = stripDangerousTags(o[k]); else if (o[k] && typeof o[k] === 'object') sanitizeObj(o[k]); } };
sanitizeObj(obj);
ctx.body = await post.publishCustom(obj);
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect(`/public/latest`);
})
.post("/follow/:feed", koaBody(), async (ctx) => {
ctx.body = await friend.follow(ctx.params.feed);
safeRefererRedirect(ctx, '/inhabitants');
})
.post("/unfollow/:feed", koaBody(), async (ctx) => {
ctx.body = await friend.unfollow(ctx.params.feed);
safeRefererRedirect(ctx, '/inhabitants');
})
.post("/block/:feed", koaBody(), async (ctx) => {
ctx.body = await friend.block(ctx.params.feed);
safeRefererRedirect(ctx, '/inhabitants');
})
.post("/unblock/:feed", koaBody(), async (ctx) => {
ctx.body = await friend.unblock(ctx.params.feed);
safeRefererRedirect(ctx, '/inhabitants');
})
.post("/spread/:message", koaBody(), async (ctx) => {
const { message } = ctx.params;
const ref = ctx.request.header.referer;
let target = '/public/latest';
try {
if (ref) {
const u = new URL(ref);
if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) {
target = u.pathname + u.search + u.hash;
}
}
} catch (_) {}
if (!message || typeof message !== 'string' || !message.startsWith('%') || !/\.sha256$/.test(message)) {
sendErrorPage(ctx, `Spread failed: invalid message id`, { status: 400 });
return;
}
try {
const ssb = await cooler.open();
const myId = ssb.id;
const existing = await new Promise((resolve) => {
const out = [];
pull(
ssb.backlinks.read({ query: [{ $filter: { dest: message } }], reverse: true }),
pull.filter(ref => {
if (!ref || !ref.value || !ref.value.content) return false;
const c = ref.value.content;
if (ref.value.author !== myId) return false;
if (c.type === 'spread' && c.link === message) return true;
if (c.type === 'vote' && c.vote && c.vote.link === message && Number(c.vote.value) === 1) {
const br = Array.isArray(c.branch) ? c.branch : (typeof c.branch === 'string' ? [c.branch] : []);
return br.includes(message);
}
return false;
}),
pull.collect((err, refs) => resolve(!err && refs ? refs : []))
);
});
const tombstoneTargets = new Set();
await new Promise((resolve) => {
pull(
ssb.createUserStream({ id: myId, reverse: true }),
pull.filter(m => m && m.value && m.value.content && m.value.content.type === 'tombstone'),
pull.drain(m => { tombstoneTargets.add(m.value.content.target); }, () => resolve())
);
});
const activeExisting = existing.filter(r => !tombstoneTargets.has(r.key));
let tombstoneTargetId = activeExisting.length > 0 ? activeExisting[0].key : spreads.getCachedActiveOwnSpreadKey(message);
if (tombstoneTargetId) {
const tombstone = { type: 'tombstone', target: tombstoneTargetId, deletedAt: new Date().toISOString(), author: myId };
await new Promise((res, rej) => ssb.publish(tombstone, (e, m) => e ? rej(e) : res(m)));
spreads.noteOwnTombstone(tombstoneTargetId);
ctx.redirect(target);
return;
}
let recps = [];
try {
const raw = await new Promise((res) => {
let done = false;
const timer = setTimeout(() => { if (!done) { done = true; res(null); } }, 1500);
ssb.get({ id: message, private: true, meta: true }, (err, v) => {
if (done) return;
done = true;
clearTimeout(timer);
res(err ? null : v);
});
});
const value = raw && raw.value ? raw.value : raw;
const isPrivate = !!(raw && raw.meta && raw.meta.private === true);
if (isPrivate && value && value.content && Array.isArray(value.content.recps)) {
recps = value.content.recps.map(r => typeof r === 'string' ? r : (r && r.link)).filter(Boolean);
}
} catch (_) {}
const content = { type: 'spread', link: message, expression: '🔁' };
if (recps.length) content.recps = recps;
const publishedMsg = await new Promise((res, rej) => ssb.publish(content, (e, msg) => {
if (e) rej(e);
else res(msg);
}));
if (publishedMsg && publishedMsg.key) spreads.noteOwnSpread(message, publishedMsg.key);
} catch (e) {
sendErrorPage(ctx, `Spread failed: ${e.message || e}`, { status: 500 });
return;
}
ctx.redirect(target);
})
.post("/like/:message", koaBody(), async (ctx) => {
const { message } = ctx.params, voteValue = Number(ctx.request.body.voteValue);
const ref = ctx.request.header.referer;
let target = '/public/latest';
try {
if (ref) {
const u = new URL(ref);
if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) {
u.hash = `centered-footer-${encodeURIComponent(message)}`;
target = u.pathname + u.search + u.hash;
}
}
} catch (_) {}
const msgData = await post.get(message);
const isPrivate = msgData.value.meta.private === true;
const normalized = (isPrivate ? msgData.value.content.recps : []).map(r => typeof r === 'string' ? r : r?.link).filter(Boolean);
ctx.body = await vote.publish({ messageKey: message, value: voteValue, recps: normalized.length ? normalized : undefined });
ctx.redirect(target);
})
.post('/forum/create', koaBody(), async ctx => {
const { category, title, text, isPublic } = ctx.request.body;
const isPrivate = String(isPublic || 'public').toLowerCase() === 'private';
await forumModel.createForum(category, stripDangerousTags(title), stripDangerousTags(text), isPrivate);
ctx.redirect('/forum');
})
.post('/forum/:id/message', koaBody(), async ctx => {
const { message, parentId } = ctx.request.body;
const cleanedMsg = stripDangerousTags(message);
const subtopic = stripDangerousTags(String(ctx.request.body.subtopic || '').trim()).slice(0, 40);
const mentions = await extractMentions(cleanedMsg);
await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined, subtopic: subtopic || undefined }, parentId);
ctx.redirect(`/forum/${encodeURIComponent(ctx.params.id)}`);
})
.post('/forum/:forumId/vote', koaBody(), async ctx => {
await forumModel.voteContent(ctx.request.body.target, parseInt(ctx.request.body.value, 10));
ctx.redirect(ctx.get('referer') || `/forum/${encodeURIComponent(ctx.params.forumId)}`);
})
.post('/forum/delete/:id', koaBody(), async ctx => {
const forum = await forumModel.getForumById(ctx.params.id).catch(() => null);
if (!forum || forum.author !== getViewerId()) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; }
await forumModel.deleteForumById(ctx.params.id);
ctx.redirect('/forum');
})
.post('/forum/generate-invite/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; }
try {
const { code } = await forumModel.generateInvite(ctx.params.id);
ctx.body = renderForumInvitePage(code);
} catch (_) {
ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(ctx.params.id)}`, ['/forum']));
}
})
.post('/forum/open-invite/create/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; }
try { await forumModel.generateOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(ctx.params.id)}`, ['/forum']));
})
.post('/forum/open-invite/remove/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; }
try { await forumModel.removeOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(ctx.params.id)}`, ['/forum']));
})
.post('/forum/join-code', koaBody(), async ctx => {
if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; }
const code = String((ctx.request.body || {}).code || '').trim();
try {
const { forumId } = await forumModel.joinByInvite(code);
ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(forumId)}`, ['/forum']));
} catch (_) {
ctx.redirect(safeReturnTo(ctx, '/forum', ['/forum']));
}
})
.get('/legacy/export', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const pw = ctx.query && ctx.query.password;
if (!pw || pw.length < 32) return ctx.redirect('/legacy');
try {
const { filename, data } = await legacyModel.exportData({ password: pw });
ctx.set('Content-Type', 'application/octet-stream');
ctx.set('Content-Disposition', `attachment; filename="${filename}"`);
ctx.set('Content-Length', String(data.length));
ctx.body = data;
try { onboardingModel.markStep('backup'); } catch (_) {}
} catch (error) { sendErrorPage(ctx, error.message); }
})
.post('/legacy/import', koaBody({
multipart: true,
formidable: {
keepExtensions: true,
uploadDir: os.tmpdir(),
}
}), async (ctx) => {
const uploadedFile = ctx.request.files?.uploadedFile, pw = ctx.request.body.importPassword;
if (!uploadedFile) { ctx.body = { error: 'No file uploaded' }; return ctx.redirect('/legacy'); }
if (!pw || pw.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/legacy'); }
try {
await legacyModel.importData({ filePath: uploadedFile.filepath, password: pw });
ctx.body = { message: 'Data imported successfully!' };
ctx.redirect('/legacy');
} catch (error) { ctx.body = { error: error.message }; ctx.redirect('/legacy'); }
})
.post('/trending/:contentId/:category', async (ctx) => {
const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id;
if ((await trendingModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) {
ctx.flash = { message: 'You have already opined.' }; return ctx.redirect('/trending');
}
await trendingModel.createVote(contentId, category); ctx.redirect('/trending');
})
.post('/opinions/:contentId/:category', async (ctx) => {
const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id;
if ((await opinionsModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) {
ctx.flash = { message: 'You have already opined.' }; return ctx.redirect('/opinions');
}
await opinionsModel.createVote(contentId, category); ctx.redirect('/opinions');
})
.post('/agenda/discard/:itemId', async (ctx) => {
await agendaModel.discardItem(ctx.params.itemId); ctx.redirect('/agenda');
})
.post('/agenda/restore/:itemId', async (ctx) => {
await agendaModel.restoreItem(ctx.params.itemId); ctx.redirect('/agenda?filter=discarded');
})
.post("/feed/create", koaBody(), async (ctx) => {
const text = ctx.request.body?.text != null ? stripDangerousTags(String(ctx.request.body.text)) : "";
const mentions = await extractMentions(text);
await feedModel.createFeed(text, mentions);
ctx.redirect("/feed?filter=ALL&msg=feedPublished");
})
.post("/feed/opinions/:feedId/:category", async (ctx) => {
const { feedId, category } = ctx.params;
try {
await feedModel.addOpinion(feedId, category);
} catch { /* already voted or invalid — ignore */ }
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect(ctx.get("Referer") || "/feed");
})
.post("/feed/refeed/:id", koaBody(), async (ctx) => {
try {
await feedModel.createRefeed(ctx.params.id);
} catch (e) {
if (e.message !== "Already refeeded") throw e;
}
ctx.redirect(ctx.get("Referer") || "/feed");
})
.post("/feed/:feedId/comments", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const text = ctx.request.body?.text != null ? stripDangerousTags(String(ctx.request.body.text)) : "";
const imageMarkdown = ctx.request.files?.blob ? await handleBlobUpload(ctx, 'blob') : null;
const fullText = imageMarkdown ? (text ? text + '\n' : '') + imageMarkdown : text;
await feedModel.addComment(ctx.params.feedId, fullText);
ctx.redirect(`/feed/${encodeURIComponent(ctx.params.feedId)}`);
})
.post("/bookmarks/create", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body;
await bookmarksModel.createBookmark(stripDangerousTags(b.url), b.tags, stripDangerousTags(b.description), b.category, b.lastVisit);
ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=all', ['/bookmarks']));
})
.post("/bookmarks/update/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body;
await bookmarksModel.updateBookmarkById(ctx.params.id, { url: stripDangerousTags(b.url), tags: b.tags, description: stripDangerousTags(b.description), category: b.category, lastVisit: b.lastVisit });
ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=mine', ['/bookmarks']));
})
.post("/bookmarks/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'bookmarks'))
.post("/bookmarks/opinions/:bookmarkId/:category", koaBody(), async ctx => opinionAction(ctx, 'bookmarks', 'bookmarkId'))
.post("/bookmarks/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'bookmarks', 'add'))
.post("/bookmarks/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'bookmarks', 'remove'))
.post("/bookmarks/:bookmarkId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'bookmarks', 'bookmarkId'))
.post("/images/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
const blob = await handleBlobUpload(ctx, 'image'), b = ctx.request.body;
await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || ""));
ctx.redirect(safeReturnTo(ctx, '/images?filter=all', ['/images']));
})
.post("/images/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, blob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || ""));
ctx.redirect(safeReturnTo(ctx, '/images?filter=mine', ['/images']));
})
.post("/images/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'images'))
.post("/images/opinions/:imageId/:category", koaBody(), async ctx => opinionAction(ctx, 'images', 'imageId'))
.post("/images/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'images', 'add'))
.post("/images/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'images', 'remove'))
.post("/images/:imageId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'images', 'imageId'))
.post("/maps/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body;
if (b.tribeId) {
const t = await tribesModel.getTribeById(b.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
const imageId = extractBlobId(await handleBlobUpload(ctx, 'image')) || "";
const newMap = await mapsModel.createMap(b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), b.tribeId || null, stripDangerousTags(b.markerLabel), imageId);
const redir = b.tribeId ? `/tribe/${encodeURIComponent(b.tribeId)}?section=maps` : safeReturnTo(ctx, '/maps?filter=all', ['/maps']);
ctx.redirect(redir);
})
.post("/maps/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
const target = await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
const b = ctx.request.body;
const imageId = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) || "" : "";
await mapsModel.updateMapById(ctx.params.id, b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), imageId || undefined);
ctx.redirect(safeReturnTo(ctx, '/maps?filter=mine', ['/maps']));
})
.post("/maps/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
const target = await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
await mapsModel.deleteMapById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/maps?filter=mine', ['/maps']));
})
.post("/maps/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'add'))
.post("/maps/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'remove'))
.post("/maps/generate-invite/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
try {
const code = await mapsModel.generateInvite(ctx.params.id);
ctx.body = `<html><body><p>Map invite code: <code>${code}</code></p><p><a href="/maps/${encodeURIComponent(ctx.params.id)}">Back</a></p></body></html>`;
} catch (e) {
ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`);
}
})
.post("/maps/open-invite/create/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
try { await mapsModel.generateOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`);
})
.post("/maps/open-invite/remove/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
try { await mapsModel.removeOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`);
})
.post("/maps/join-code", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
const code = String((ctx.request.body || {}).code || "").trim();
try {
const mapId = await mapsModel.joinByInvite(code);
ctx.redirect(`/maps/${encodeURIComponent(mapId)}`);
} catch (_) {
ctx.redirect('/maps');
}
})
.post("/maps/join/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
try { await mapsModel.joinMap(ctx.params.id); } catch (_) {}
ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`);
})
.post("/maps/:mapId/marker", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId();
const mapItem = await mapsModel.getMapById(ctx.params.mapId, uid);
if (mapItem.tribeId) {
try {
const t = await tribesModel.getTribeById(mapItem.tribeId);
if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
} catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
}
const b = ctx.request.body;
const imageBlobId = extractBlobId(await handleBlobUpload(ctx, 'image')) || "";
await mapsModel.addMarker(ctx.params.mapId, b.mkLat, b.mkLng, stripDangerousTags(b.label), imageBlobId);
ctx.redirect(safeReturnTo(ctx, `/maps/${encodeURIComponent(ctx.params.mapId)}`, ['/maps']));
})
.post("/audios/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaCreateAction(ctx, 'audios'))
.post("/audios/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaUpdateAction(ctx, 'audios'))
.post("/audios/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'audios'))
.post("/audios/opinions/:audioId/:category", koaBody(), async ctx => opinionAction(ctx, 'audios', 'audioId'))
.post("/audios/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'audios', 'add'))
.post("/audios/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'audios', 'remove'))
.post("/audios/:audioId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'audios', 'audioId'))
.post("/torrents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
const { tags, title, description, tribeId } = ctx.request.body;
const cleanTribeId = tribeId ? String(tribeId).trim() : null;
if (cleanTribeId) {
const t = await tribesModel.getTribeById(cleanTribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
const blob = await handleBlobUpload(ctx, 'torrent');
const fileSize = ctx.request.files?.torrent?.size || 0;
await torrentsModel.createTorrent(blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), fileSize, cleanTribeId);
ctx.redirect(cleanTribeId ? `/tribe/${encodeURIComponent(cleanTribeId)}?section=torrents` : safeReturnTo(ctx, '/torrents?filter=all', ['/torrents']));
})
.post("/torrents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
const target = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
const { tags, title, description } = ctx.request.body;
const blob = ctx.request.files?.torrent ? await handleBlobUpload(ctx, 'torrent') : null;
await torrentsModel.updateTorrentById(ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description));
ctx.redirect(safeReturnTo(ctx, '/torrents?filter=mine', ['/torrents']));
})
.post("/torrents/delete/:id", koaBody(), async ctx => {
const target = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
return deleteAction(ctx, 'torrents');
})
.post("/torrents/opinions/:torrentId/:category", koaBody(), async ctx => {
const target = await torrentsModel.getTorrentById(ctx.params.torrentId, getViewerId()).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
return opinionAction(ctx, 'torrents', 'torrentId');
})
.post("/torrents/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'add'))
.post("/torrents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'remove'))
.post("/torrents/:torrentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'torrents', 'torrentId'))
.post("/videos/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaCreateAction(ctx, 'videos'))
.post("/videos/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaUpdateAction(ctx, 'videos'))
.post("/videos/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'videos'))
.post("/videos/opinions/:videoId/:category", koaBody(), async ctx => opinionAction(ctx, 'videos', 'videoId'))
.post("/videos/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'videos', 'add'))
.post("/videos/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'videos', 'remove'))
.post("/videos/:videoId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'videos', 'videoId'))
.post("/documents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const docBlob = await handleBlobUpload(ctx, "document"), b = ctx.request.body;
await documentsModel.createDocument(docBlob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description));
ctx.redirect(safeReturnTo(ctx, "/documents?filter=all", ["/documents"]));
})
.post("/documents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const b = ctx.request.body, blob = ctx.request.files?.document ? await handleBlobUpload(ctx, "document") : null;
await documentsModel.updateDocumentById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description));
ctx.redirect(safeReturnTo(ctx, "/documents?filter=mine", ["/documents"]));
})
.post("/documents/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'documents'))
.post("/documents/opinions/:documentId/:category", koaBody(), async ctx => opinionAction(ctx, 'documents', 'documentId'))
.post("/documents/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'documents', 'add'))
.post("/documents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'documents', 'remove'))
.post("/documents/:documentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'documents', 'documentId'))
.post('/cv/upload', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
const photoUrl = await handleBlobUpload(ctx, 'image')
await cvModel.createCV(ctx.request.body, photoUrl)
ctx.redirect('/cv')
})
.post('/cv/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
const photoUrl = await handleBlobUpload(ctx, 'image')
await cvModel.updateCV(ctx.params.id, ctx.request.body, photoUrl)
ctx.redirect('/cv')
})
.post('/cv/delete/:id', async ctx => {
await cvModel.deleteCVById(ctx.params.id)
ctx.redirect('/cv')
})
.post('/cv/visibility/:id', koaBody(), async ctx => {
const cv = await cvModel.getCVByUserId().catch(() => null);
if (!cv || cv.id !== ctx.params.id) { sendErrorPage(ctx, 'CV not found', { status: 404 }); return; }
const next = String(ctx.request.body?.visibility || '').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
await cvModel.updateCV(ctx.params.id, { ...cv, visibility: next }, cv.photo || null);
ctx.redirect('/cv');
})
.post('/cipher/encrypt', koaBody(), async (ctx) => {
const { text, password } = ctx.request.body;
if (password.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/cipher'); }
const { encryptedText, iv } = cipherModel.encryptData(text, password);
ctx.body = await cipherView(encryptedText, "", iv, password);
})
.post('/cipher/decrypt', koaBody(), async (ctx) => {
const { encryptedText, password } = ctx.request.body;
if (password.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/cipher'); }
ctx.body = await cipherView("", cipherModel.decryptData(encryptedText, password), "", password);
})
.post('/tribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
if (!['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; }
const image = await handleBlobUpload(ctx, 'image');
const tribeRes = await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isAnonymous === 'true', b.inviteMode, null, 'OPEN', stripDangerousTags(b.mapUrl));
try { if (tribeRes?.key) await parliamentModel.tribe.publishInitialTerm(tribeRes.key); } catch (e) { console.error('publishInitialTerm failed:', e); }
ctx.redirect('/tribes');
})
.post('/tribe/:id/subtribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const parentTribe = await tribesModel.getTribeById(ctx.params.id);
const viewerId = getViewerId();
const canCreate = parentTribe.inviteMode === 'open'
? parentTribe.members.includes(viewerId)
: parentTribe.author === viewerId;
if (!canCreate) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
const image = await handleBlobUpload(ctx, 'image');
const parentEffective = await tribesModel.getEffectiveStatus(ctx.params.id).catch(() => ({ isPrivate: false }));
const effectiveAnonymous = !!(parentEffective.isPrivate || parentTribe.isAnonymous);
await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, effectiveAnonymous, b.inviteMode || 'open', ctx.params.id, 'OPEN', stripDangerousTags(b.mapUrl));
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`);
})
.post('/tribes/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (tribe.author !== getViewerId()) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
if (b.inviteMode && !['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; }
const tags = b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
const isSub = !!tribe.parentTribeId;
const updateFields = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image'), location: stripDangerousTags(b.location), tags, inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' };
if (isSub) {
updateFields.isAnonymous = !!tribe.isAnonymous;
} else {
updateFields.isAnonymous = b.isAnonymous === 'true';
}
await tribesModel.updateTribeById(ctx.params.id, updateFields);
ctx.redirect('/tribes?filter=mine');
})
.post('/tribes/delete/:id', async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (tribe.author !== getViewerId()) { ctx.status = 403; ctx.redirect('/tribes'); return; }
await tribesModel.deleteTribeById(ctx.params.id)
ctx.redirect('/tribes?filter=mine')
})
.post('/tribes/generate-invite', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
ctx.body = await renderInvitePage(await tribesModel.generateInvite(ctx.request.body.tribeId));
})
.post('/tribes/join-code', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
await tribesModel.joinByInvite(ctx.request.body.inviteCode)
ctx.redirect('/tribes?filter=membership')
})
.post('/tribes/leave/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
await tribesModel.leaveTribe(ctx.params.id)
ctx.redirect('/tribes?filter=membership')
})
.post('/tribes/open-invite/create', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribeId = ctx.request.body.tribeId;
try { await tribesModel.generateOpenInvite(tribeId); } catch (_) {}
ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}`);
})
.post('/tribes/open-invite/remove', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribeId = ctx.request.body.tribeId;
try { await tribesModel.removeOpenInvite(tribeId); } catch (_) {}
ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}`);
})
.post('/tribes/open-invite/join', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribeId = ctx.request.body.tribeId;
try {
const oi = await tribesModel.getOpenInvite(tribeId);
if (oi && oi.code) await tribesModel.joinByInvite(oi.code);
} catch (_) {}
ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}`);
})
.get('/tribes/open-invite/join/:id', async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribeId = ctx.params.id;
try {
const oi = await tribesModel.getOpenInvite(tribeId);
if (oi && oi.code) await tribesModel.joinByInvite(oi.code);
} catch (_) {}
ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}`);
})
.post('/tribe/:id/message', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
const uid = getViewerId();
if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; }
if (tooLong(ctx, ctx.request.body.message, MAX_TEXT_LENGTH, 'Text')) return;
const message = stripDangerousTags((ctx.request.body.message || '').trim());
if (!message || message.length === 0 || message.length > 280) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed`); return; }
await tribesContentModel.create(tribe.id, 'feed', { description: await resolveMentionText(message) });
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed&sent=1`);
})
.post('/tribe/:id/refeed/:msgId', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
const uid = getViewerId();
if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; }
await tribesContentModel.toggleRefeed(ctx.params.msgId);
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed`);
})
.post('/tribe/:id/events/create', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
if (b.date && b.date < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events&action=create`); return; }
await tribesContentModel.create(tribe.id, 'event', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), date: b.date, location: stripDangerousTags(b.location), attendees: [getViewerId()] });
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events`);
})
.post('/tribe/:id/events/attend/:eventId', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
await tribesContentModel.toggleAttendee(ctx.params.eventId);
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events`);
})
.post('/tribe/:id/tasks/create', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
if (b.deadline && b.deadline < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks&action=create`); return; }
await tribesContentModel.create(tribe.id, 'task', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), priority: b.priority, deadline: b.deadline, assignees: [] });
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`);
})
.post('/tribe/:id/tasks/assign/:taskId', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
await tribesContentModel.toggleAssignee(ctx.params.taskId);
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`);
})
.post('/tribe/:id/tasks/status/:taskId', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const item = await tribesContentModel.getById(ctx.params.taskId);
if (!item || item.author !== getViewerId()) { ctx.status = 403; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`); return; }
await tribesContentModel.updateStatus(ctx.params.taskId, ctx.request.body.status);
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`);
})
.post('/tribe/:id/votations/create', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
if (b.deadline && b.deadline < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations&action=create`); return; }
const options = [b.option1, b.option2, b.option3, b.option4].filter(Boolean).map(o => stripDangerousTags(o));
await tribesContentModel.create(tribe.id, 'votation', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), deadline: b.deadline, options, votes: {} });
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`);
})
.post('/tribe/:id/votations/:voteId/vote', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
await tribesContentModel.castVote(ctx.params.voteId, parseInt(ctx.request.body.optionIndex, 10));
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`);
})
.post('/tribe/:id/votations/close/:voteId', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const votation = await tribesContentModel.getById(ctx.params.voteId);
if (!votation || votation.author !== getViewerId()) { ctx.status = 403; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`); return; }
await tribesContentModel.updateStatus(ctx.params.voteId, 'CLOSED');
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`);
})
.post('/tribe/:id/forum/create', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
await tribesContentModel.create(tribe.id, 'forum', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), category: b.category });
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum`);
})
.post('/tribe/:id/forum/:forumId/reply', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
await tribesContentModel.create(tribe.id, 'forum-reply', { description: await resolveMentionText(stripDangerousTags(b.description)), parentId: ctx.params.forumId });
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum&thread=${encodeURIComponent(ctx.params.forumId)}`);
})
.post('/tribe/:id/forum/:forumId/refeed', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
const uid = getViewerId();
if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; }
await tribesContentModel.toggleRefeed(ctx.params.forumId);
const thread = ctx.query.thread || '';
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum${thread ? '&thread=' + encodeURIComponent(thread) : ''}`);
})
.post('/tribe/:id/media/upload', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const b = ctx.request.body;
if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
const returnSection = b.returnSection || 'media';
const mediaType = b.mediaType || 'image';
let blobRef = null;
if (mediaType === 'bookmark') {
const url = stripDangerousTags(b.url || '');
await tribesContentModel.create(tribe.id, 'media', { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), mediaType: 'bookmark', url });
} else {
const blobMarkdownMedia = await handleBlobUpload(ctx, 'media');
blobRef = blobMarkdownMedia ? ((blobMarkdownMedia.match(/\((&[^)]+)\)/) || [])[1] || blobMarkdownMedia) : null;
await tribesContentModel.create(tribe.id, 'media', { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), mediaType, image: blobRef });
}
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=${returnSection}`);
})
.post('/tribe/:id/content/delete/:contentId', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribeRedirect = `/tribe/${encodeURIComponent(ctx.params.id)}`;
const item = await tribesContentModel.getById(ctx.params.contentId);
if (!item || item.author !== getViewerId() || item.tribeId !== ctx.params.id) { ctx.status = 403; ctx.redirect(tribeRedirect); return; }
await tribesContentModel.deleteById(ctx.params.contentId);
ctx.redirect(tribeRedirect);
})
.post('/tribe/:id/content/:contentId/opinion/:category', koaBody(), async ctx => {
if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
const tribe = await tribesModel.getTribeById(ctx.params.id);
if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
const item = await tribesContentModel.getById(ctx.params.contentId);
if (!item || item.tribeId !== ctx.params.id) { ctx.status = 404; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=opinions`); return; }
try {
await tribesContentModel.castOpinion(ctx.params.contentId, ctx.params.category);
} catch (_) {}
ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=opinions`);
})
.post('/panic/remove', koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const { exec } = require('child_process');
try {
await panicmodeModel.removeSSB();
sendErrorPage(ctx, 'Your blockchain has been successfully deleted!');
if (process.env.OASIS_MOBILE !== '1') exec('pkill -f "node SSB_server.js start"');
setTimeout(() => process.exit(0), 1000);
} catch (error) { sendErrorPage(ctx, 'Error deleting your blockchain: ' + error.message); }
})
.post('/export/create', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
try {
const outputPath = path.join(os.homedir(), 'ssb_exported.zip');
await exportmodeModel.exportSSB(outputPath);
ctx.set('Content-Type', 'application/zip');
ctx.set('Content-Disposition', `attachment; filename=ssb_exported.zip`);
ctx.body = fs.createReadStream(outputPath);
ctx.res.on('finish', () => fs.unlinkSync(outputPath));
} catch (error) { sendErrorPage(ctx, 'Error exporting your blockchain: ' + error.message); }
})
.post('/tasks/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
const b = ctx.request.body;
const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
let desc = stripDangerousTags(b.description);
if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
await tasksModel.createTask(stripDangerousTags(b.title), desc, b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic);
ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
})
.post('/tasks/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
const b = ctx.request.body, tags = Array.isArray(b.tags) ? b.tags.filter(Boolean) : (typeof b.tags === 'string' ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : []);
const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
let desc = stripDangerousTags(b.description);
if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
await tasksModel.updateTaskById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, startTime: b.startTime, endTime: b.endTime, priority: b.priority, location: stripDangerousTags(b.location), tags, isPublic: b.isPublic });
ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
})
.post('/tasks/assign/:id', koaBody(), async ctx => {
await tasksModel.toggleAssignee(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/tasks', ['/tasks']));
})
.post('/tasks/delete/:id', koaBody(), async ctx => {
await tasksModel.deleteTaskById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
})
.post('/tasks/status/:id', koaBody(), async ctx => {
await tasksModel.updateTaskStatus(ctx.params.id, ctx.request.body.status);
ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
})
.post('/tasks/:taskId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'tasks', 'taskId'))
.post('/reports/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image');
await reportsModel.createReport(stripDangerousTags(b.title), stripDangerousTags(b.description), b.category, image, b.tags, b.severity, {
stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate,
problemStatement: stripDangerousTags(b.problemStatement), userStory: stripDangerousTags(b.userStory), acceptanceCriteria: stripDangerousTags(b.acceptanceCriteria),
whatHappened: stripDangerousTags(b.whatHappened), reportedUser: b.reportedUser, evidenceLinks: stripDangerousTags(b.evidenceLinks),
contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate), requestedAction: stripDangerousTags(b.requestedAction)
});
ctx.redirect('/reports');
})
.post('/reports/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image');
await reportsModel.updateReportById(ctx.params.id, {
title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, image, tags: b.tags, severity: b.severity,
template: {
stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate,
problemStatement: stripDangerousTags(b.problemStatement), userStory: stripDangerousTags(b.userStory), acceptanceCriteria: stripDangerousTags(b.acceptanceCriteria),
whatHappened: stripDangerousTags(b.whatHappened), reportedUser: stripDangerousTags(b.reportedUser), evidenceLinks: stripDangerousTags(b.evidenceLinks),
contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate), requestedAction: stripDangerousTags(b.requestedAction)
}
});
ctx.redirect('/reports?filter=mine');
})
.post('/reports/delete/:id', async ctx => {
await reportsModel.deleteReportById(ctx.params.id);
ctx.redirect('/reports?filter=mine');
})
.post('/reports/confirm/:id', async ctx => {
await reportsModel.confirmReportById(ctx.params.id);
ctx.redirect('/reports');
})
.post('/reports/status/:id', koaBody(), async ctx => {
await reportsModel.updateReportById(ctx.params.id, { status: ctx.request.body.status });
ctx.redirect('/reports?filter=mine');
})
.post('/reports/:reportId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'reports', 'reportId'))
.post('/events/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const b = ctx.request.body;
const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
let desc = stripDangerousTags(b.description);
if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
const evResult = await eventsModel.createEvent(stripDangerousTags(b.title), desc, b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic, stripDangerousTags(b.mapUrl), b.clearnetPublic);
if ([].concat(b.addToCalendar).includes("1") && evResult && evResult.key) {
try {
await calendarsModel.createCalendar({
title: stripDangerousTags(b.title),
status: 'OPEN',
deadline: '',
tags: b.tags,
firstDate: b.date,
firstDateLabel: stripDangerousTags(b.title),
firstNote: `/events/${evResult.key}`,
intervalWeekly: 0,
intervalMonthly: 0,
intervalYearly: 0
});
} catch (_) {}
}
ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events']));
})
.post('/events/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const b = ctx.request.body, existing = await eventsModel.getEventById(ctx.params.id);
const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
let desc = stripDangerousTags(b.description);
if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
await eventsModel.updateEventById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, date: b.date, location: stripDangerousTags(b.location), price: b.price, url: b.url, attendees: b.attendees, tags: b.tags, isPublic: b.isPublic, createdAt: existing.createdAt, organizer: existing.organizer, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic });
ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events']));
})
.post('/events/attend/:id', koaBody(), async ctx => {
await eventsModel.toggleAttendee(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/events', ['/events']));
})
.post('/events/delete/:id', koaBody(), async ctx => {
await eventsModel.deleteEventById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events']));
})
.post('/events/generate-invite/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
try {
const { code } = await eventsModel.generateInvite(ctx.params.id);
ctx.body = renderEventInvitePage(code);
} catch (_) {
ctx.redirect(safeReturnTo(ctx, `/events/${encodeURIComponent(ctx.params.id)}`, ['/events']));
}
})
.post('/events/open-invite/create/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
try { await eventsModel.generateOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/events/${encodeURIComponent(ctx.params.id)}`, ['/events']));
})
.post('/events/open-invite/remove/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
try { await eventsModel.removeOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/events/${encodeURIComponent(ctx.params.id)}`, ['/events']));
})
.post('/events/join-code', koaBody(), async ctx => {
if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
const code = String((ctx.request.body || {}).code || '').trim();
try {
const { eventId } = await eventsModel.joinByInvite(code);
ctx.redirect(safeReturnTo(ctx, `/events/${encodeURIComponent(eventId)}`, ['/events']));
} catch (_) {
ctx.redirect(safeReturnTo(ctx, '/events', ['/events']));
}
})
.post('/events/:eventId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'events', 'eventId'))
.post('/votes/create', koaBody(), async ctx => {
const b = ctx.request.body, defaultOptions = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
const parsedOptions = b.options ? b.options.split(',').map(o => o.trim()).filter(Boolean) : defaultOptions;
await votesModel.createVote(stripDangerousTags(b.question), b.deadline, parsedOptions, String(b.tags || '').split(',').map(t => t.trim()).filter(Boolean));
ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes']));
})
.post('/votes/update/:id', koaBody(), async ctx => {
const b = ctx.request.body, parsedOptions = b.options ? b.options.split(',').map(o => o.trim()).filter(Boolean) : undefined;
await votesModel.updateVoteById(ctx.params.id, { question: stripDangerousTags(b.question), deadline: b.deadline, options: parsedOptions, tags: b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : [] });
ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes']));
})
.post('/votes/delete/:id', koaBody(), async ctx => {
await votesModel.deleteVoteById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes']));
})
.post('/votes/vote/:id', koaBody(), async ctx => {
await votesModel.voteOnVote(ctx.params.id, ctx.request.body.choice);
ctx.redirect(safeReturnTo(ctx, '/votes?filter=open', ['/votes']));
})
.post('/votes/opinions/:voteId/:category', koaBody(), async ctx => {
try { await votesModel.createOpinion(ctx.params.voteId, ctx.params.category); }
catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; }
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, '/votes', ['/votes']));
})
.post('/events/opinions/:eventId/:category', koaBody(), async ctx => {
try { await eventsModel.createOpinion(ctx.params.eventId, ctx.params.category); }
catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; }
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, '/events', ['/events']));
})
.post('/tasks/opinions/:taskId/:category', koaBody(), async ctx => {
try { await tasksModel.createOpinion(ctx.params.taskId, ctx.params.category); }
catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; }
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, '/tasks', ['/tasks']));
})
.post('/reports/opinions/:reportId/:category', koaBody(), async ctx => {
try { await reportsModel.createOpinion(ctx.params.reportId, ctx.params.category); }
catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; }
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, '/reports', ['/reports']));
})
.post('/projects/opinions/:projectId/:category', koaBody(), async ctx => {
try { await projectsModel.createOpinion(ctx.params.projectId, ctx.params.category); }
catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; }
try { activityModel.invalidateCache(); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, '/projects', ['/projects']));
})
.post('/votes/:voteId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'votes', 'voteId'))
.post('/parliament/candidatures/propose', koaBody(), async (ctx) => {
const b = ctx.request.body || {}, id = String(b.candidateId || '').trim(), m = String(b.method || '').trim().toUpperCase();
if (!id) ctx.throw(400, 'Candidate is required.');
if (!new Set(['DEMOCRACY','MAJORITY','MINORITY','DICTATORSHIP','KARMATOCRACY']).has(m)) ctx.throw(400, 'Invalid method.');
await parliamentModel.proposeCandidature({ candidateId: id, method: m }).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect('/parliament?filter=candidatures');
})
.post('/tribe/:id/governance/publish-candidature', koaBody(), async (ctx) => {
const tribeId = ctx.params.id;
const uid = getViewerId();
const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!tribe) ctx.throw(404, 'Tribe not found');
if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
const isCreator = tribe.author === uid;
const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
const globalTerm = await parliamentModel.getCurrentTerm().catch(() => null);
const already = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribeId, globalTerm?.startAt).catch(() => false);
if (already) ctx.throw(400, 'This tribe already has an open candidature in the current global parliament cycle.');
const term = await parliamentModel.tribe.getCurrentTerm(tribeId).catch(() => null);
const rawMethod = (term?.method && String(term.method).toUpperCase()) || 'DEMOCRACY';
const method = rawMethod === 'ANARCHY' ? 'DEMOCRACY' : rawMethod;
await parliamentModel.proposeCandidature({ candidateId: tribeId, method }).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect('/parliament?filter=candidatures');
})
.post('/tribe/:id/governance/candidature/propose', koaBody(), async (ctx) => {
const tribeId = ctx.params.id;
const uid = getViewerId();
const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!tribe) ctx.throw(404, 'Tribe not found');
if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
const isCreator = tribe.author === uid;
const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
const b = ctx.request.body || {};
const candidateId = String(b.candidateId || '').trim();
const method = String(b.method || '').trim().toUpperCase();
if (!candidateId) ctx.throw(400, 'Candidate required');
await parliamentModel.tribe.publishTribeCandidature({ tribeId, candidateId, method }).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=candidatures`);
})
.post('/tribe/:id/governance/candidature/vote', koaBody(), async (ctx) => {
const tribeId = ctx.params.id;
const uid = getViewerId();
const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!tribe) ctx.throw(404, 'Tribe not found');
if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
const isCreator = tribe.author === uid;
const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
const candidatureId = String(ctx.request.body?.candidatureId || '').trim();
if (!candidatureId) ctx.throw(400, 'Missing candidatureId');
await parliamentModel.tribe.voteTribeCandidature({ tribeId, candidatureId }).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=candidatures`);
})
.post('/tribe/:id/governance/rule/add', koaBody(), async (ctx) => {
const tribeId = ctx.params.id;
const uid = getViewerId();
const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!tribe) ctx.throw(404, 'Tribe not found');
if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can add rules');
const b = ctx.request.body || {};
await parliamentModel.tribe.publishTribeRule({ tribeId, title: stripDangerousTags(String(b.title || '')), body: stripDangerousTags(String(b.body || '')) }).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=rules`);
})
.post('/tribe/:id/governance/rule/delete', koaBody(), async (ctx) => {
const tribeId = ctx.params.id;
const uid = getViewerId();
const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!tribe) ctx.throw(404, 'Tribe not found');
if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can delete rules');
const ruleId = String(ctx.request.body?.ruleId || '').trim();
if (!ruleId) ctx.throw(400, 'Missing ruleId');
await parliamentModel.tribe.deleteTribeRule(ruleId).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=rules`);
})
.post('/parliament/candidatures/:id/vote', koaBody(), async (ctx) => {
await parliamentModel.voteCandidature(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect('/parliament?filter=candidatures');
})
.post('/parliament/proposals/create', koaBody(), async (ctx) => {
const b = ctx.request.body || {}, t = String(b.title || '').trim(), d = String(b.description || '').trim();
if (!t) ctx.throw(400, 'Title is required.');
if (d.length > 1000) ctx.throw(400, 'Description must be ≤ 1000 chars.');
await parliamentModel.createProposal({ title: stripDangerousTags(t), description: stripDangerousTags(d) }).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect('/parliament?filter=proposals');
})
.post('/parliament/proposals/close/:id', koaBody(), async (ctx) => {
const canClose = await parliamentModel.canPropose();
if (!canClose) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; }
await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
ctx.redirect('/parliament?filter=proposals');
})
.post('/parliament/resolve', koaBody(), async (ctx) => {
await ensureTerm();
ctx.redirect('/parliament?filter=government');
})
.post('/parliament/revocations/create', koaBody(), async (ctx) => {
const b = ctx.request.body || {}, rawLawId = Array.isArray(b.lawId) ? b.lawId[0] : (b.lawId ?? b['lawId[]'] ?? b.law_id ?? '');
const lawId = String(rawLawId || '').trim();
if (!lawId) ctx.throw(400, 'Law required');
await parliamentModel.createRevocation({ lawId, title: b.title, reasons: b.reasons });
ctx.redirect('/parliament?filter=revocations');
})
.post('/courts/cases/create', koaBody(), async (ctx) => {
const b = ctx.request.body || {}, titleSuffix = String(b.titleSuffix || '').trim(), titlePreset = String(b.titlePreset || '').trim();
const respondent = String(b.respondentId || '').trim(), method = String(b.method || '').trim().toUpperCase();
if (!titleSuffix && !titlePreset) { ctx.flash = { message: 'Title is required.' }; return ctx.redirect('/courts?filter=cases'); }
if (!respondent) { ctx.flash = { message: 'Accused / Respondent is required.' }; return ctx.redirect('/courts?filter=cases'); }
if (!/^@[A-Za-z0-9+/]+=*\.ed25519$/.test(respondent)) { ctx.flash = { message: 'Invalid respondent ID. Must be a valid SSB ID (@...ed25519).' }; return ctx.redirect('/courts?filter=cases'); }
if (!new Set(['JUDGE','DICTATOR','POPULAR','MEDIATION','KARMATOCRACY']).has(method)) { ctx.flash = { message: 'Invalid resolution method.' }; return ctx.redirect('/courts?filter=cases'); }
try { await courtsModel.openCase({ titleBase: [titlePreset, titleSuffix].filter(Boolean).join(' - '), respondentInput: respondent, method }); }
catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect('/courts?filter=mycases');
})
.post('/courts/cases/:id/evidence/add', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
const caseId = ctx.params.id, b = ctx.request.body || {};
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
try { await courtsModel.addEvidence({ caseId, text: stripDangerousTags(String(b.text || '')), link: String(b.link || ''), imageMarkdown: ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null }); }
catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/answer', koaBody(), async (ctx) => {
const caseId = ctx.params.id, b = ctx.request.body || {}, answer = String(b.answer || ''), stance = String(b.stance || '').toUpperCase();
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
if (!answer) { ctx.flash = { message: 'Response brief is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
if (!new Set(['DENY','ADMIT','PARTIAL']).has(stance)) { ctx.flash = { message: 'Invalid stance.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
try { await courtsModel.answerCase({ caseId, stance, text: stripDangerousTags(answer) }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/decide', koaBody(), async (ctx) => {
const caseId = ctx.params.id, b = ctx.request.body || {}, result = String(b.outcome || '').trim(), orders = String(b.orders || '');
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
if (!result) { ctx.flash = { message: 'Result is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
try { await courtsModel.issueVerdict({ caseId, result, orders }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/settlements/propose', koaBody(), async (ctx) => {
const caseId = ctx.params.id, terms = String(ctx.request.body?.terms || '');
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
if (!terms) { ctx.flash = { message: 'Terms are required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
try { await courtsModel.proposeSettlement({ caseId, terms }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/settlements/accept', koaBody(), async (ctx) => {
const caseId = ctx.params.id;
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
try { await courtsModel.acceptSettlement({ caseId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/support', koaBody(), async (ctx) => {
const caseId = ctx.params.id;
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
try { await courtsModel.supportCase({ caseId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/mediators/accuser', koaBody(), async (ctx) => {
const caseId = ctx.params.id, mediators = String(ctx.request.body?.mediators || '').split(',').map(s => s.trim()).filter(Boolean);
const uid = getViewerId();
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
if (!mediators.length) { ctx.flash = { message: 'At least one mediator is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
if (uid && mediators.includes(uid)) { ctx.flash = { message: 'You cannot appoint yourself as mediator.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
try { await courtsModel.setMediators({ caseId, side: 'accuser', mediators }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/mediators/respondent', koaBody(), async (ctx) => {
const caseId = ctx.params.id, mediators = String(ctx.request.body?.mediators || '').split(',').map(s => s.trim()).filter(Boolean);
const uid = getViewerId();
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
if (!mediators.length) { ctx.flash = { message: 'At least one mediator is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
if (uid && mediators.includes(uid)) { ctx.flash = { message: 'You cannot appoint yourself as mediator.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
try { await courtsModel.setMediators({ caseId, side: 'respondent', mediators }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/judge', koaBody(), async (ctx) => {
const caseId = ctx.params.id, judgeId = String(ctx.request.body?.judgeId || '').trim(), uid = getViewerId();
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
if (!judgeId) { ctx.flash = { message: 'Judge is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
if (uid && judgeId === uid) { ctx.flash = { message: 'You cannot assign yourself as judge.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
try { await courtsModel.assignJudge({ caseId, judgeId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/public', koaBody(), async (ctx) => {
const caseId = ctx.params.id, pref = String(ctx.request.body?.preference || '').toUpperCase();
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
if (pref !== 'YES' && pref !== 'NO') { ctx.flash = { message: 'Invalid visibility preference.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
try { await courtsModel.setPublicPreference({ caseId, preference: pref === 'YES' }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/cases/:id/openVote', koaBody(), async (ctx) => {
const caseId = ctx.params.id;
if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
try { await courtsModel.openPopularVote({ caseId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
})
.post('/courts/judges/nominate', koaBody(), async (ctx) => {
const judgeId = String(ctx.request.body?.judgeId || '').trim();
if (!judgeId) { ctx.flash = { message: 'Judge is required.' }; return ctx.redirect('/courts?filter=judges'); }
try { await courtsModel.nominateJudge({ judgeId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect('/courts?filter=judges');
})
.post('/courts/judges/:id/vote', koaBody(), async (ctx) => {
if (!ctx.params.id) { ctx.flash = { message: 'Nomination not found.' }; return ctx.redirect('/courts?filter=judges'); }
try { await courtsModel.voteNomination(ctx.params.id); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
ctx.redirect('/courts?filter=judges');
})
.post("/market/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, image = await handleBlobUpload(ctx, "image"), parsedStock = parseInt(String(b.stock || "0"), 10);
if (!parsedStock || parsedStock <= 0) ctx.throw(400, "Stock must be a positive number.");
const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping);
await marketModel.createItem(b.item_type, stripDangerousTags(b.title), stripDangerousTags(b.description), image, b.price, b.tags, b.item_status, b.deadline, shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", parsedStock, stripDangerousTags(b.mapUrl), { industry: stripDangerousTags(b.industry || "") }, b.visibility);
ctx.redirect(safeReturnTo(ctx, "/market", ["/market"]));
})
.post("/market/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, parsedStock = parseInt(String(b.stock || "0"), 10);
if (parsedStock < 0) ctx.throw(400, "Stock cannot be negative.");
const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping);
const updatedData = { item_type: b.item_type, title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), price: b.price, item_status: b.item_status, deadline: b.deadline, includesShipping: shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", tags: String(b.tags || "").split(",").map(t => t.trim()).filter(Boolean), stock: parsedStock, mapUrl: stripDangerousTags(b.mapUrl), visibility: b.visibility };
const image = await handleBlobUpload(ctx, "image");
if (image) updatedData.image = image;
await marketModel.updateItemById(ctx.params.id, updatedData);
ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"]));
})
.post("/market/delete/:id", koaBody(), async ctx => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
await marketModel.deleteItemById(ctx.params.id)
ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"]))
})
.post("/market/visibility/:id", koaBody(), async ctx => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const next = String(ctx.request.body?.visibility || '').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
await marketModel.updateItemById(ctx.params.id, { visibility: next });
ctx.redirect(safeReturnTo(ctx, `/market/${encodeURIComponent(ctx.params.id)}`, ["/market"]));
})
.post("/market/sold/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const item = await marketModel.getItemById(ctx.params.id);
if (!item) ctx.throw(404, "Item not found");
if (Number(item.stock || 0) <= 0) ctx.throw(400, "No stock left to mark as sold.");
if (item.status !== "SOLD") { await marketModel.setItemAsSold(ctx.params.id); await marketModel.decrementStock(ctx.params.id); }
ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"]));
})
.post("/market/buy/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const item = await marketModel.getItemById(ctx.params.id);
if (!item) ctx.throw(404, "Item not found");
if (String(item.seller) === String(getViewerId())) ctx.throw(403, "You cannot buy your own item");
if (item.item_type === "auction") ctx.throw(400, "Auction items are acquired via bids");
if (String(item.status || "").toUpperCase() === "SOLD") ctx.throw(400, "Item already sold");
if (Number(item.stock || 0) <= 0) ctx.throw(400, "Out of stock");
try {
await pmModel.sendMessage([item.seller], "MARKET_SOLD", `item "${item.title}" has been sold -> /market/${ctx.params.id} OASIS ID: ${getViewerId()} for: ${item.price} ECO`);
} catch (_) {}
if (item.item_type === "exchange") await marketModel.setItemAsSold(ctx.params.id);
else await marketModel.decrementStock(ctx.params.id);
try { await mediaFavorites.addFavorite('market', item.id || ctx.params.id); } catch (_) {}
if (item.shopProductId && checkMod(ctx, 'shopsMod')) {
try { await shopsModel.buyProduct(item.shopProductId); } catch (_) {}
}
ctx.redirect(safeReturnTo(ctx, "/inbox?filter=sent", ["/inbox", "/market"]));
})
.post("/market/status/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
const desired = String(ctx.request.body.status || "").toUpperCase().replace(/_/g, " ").replace(/\s+/g, " ").trim();
if (!["FOR SALE", "SOLD", "DISCARDED"].includes(desired)) ctx.throw(400, "Invalid status.");
const item = await marketModel.getItemById(ctx.params.id);
if (!item) ctx.throw(404, "Item not found");
const cur = String(item.status || "").toUpperCase().replace(/\s+/g, " ").trim();
if (cur !== "SOLD" && cur !== "DISCARDED" && desired !== cur && desired !== "FOR SALE") {
if (desired === "SOLD") {
if (Number(item.stock || 0) <= 0) ctx.throw(400, "No stock left to mark as sold.");
await marketModel.setItemAsSold(ctx.params.id); await marketModel.decrementStock(ctx.params.id);
} else if (desired === "DISCARDED") await marketModel.updateItemById(ctx.params.id, { status: "DISCARDED", stock: 0 });
}
ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"]));
})
.post("/market/bid/:id", koaBody(), async ctx => {
if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
await marketModel.addBidToAuction(ctx.params.id, getViewerId(), ctx.request.body.bidAmount)
ctx.redirect(safeReturnTo(ctx, "/market?filter=auctions", ["/market"]))
})
.post("/market/:itemId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'market', 'itemId'))
.post('/jobs/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
await jobsModel.createJob({ job_type: stripDangerousTags(b.job_type), title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), requirements: stripDangerousTags(b.requirements), languages: stripDangerousTags(b.languages), job_time: b.job_time, tasks: stripDangerousTags(b.tasks), location: stripDangerousTags(b.location), vacants: b.vacants ? parseInt(b.vacants, 10) : 1, salary: b.salary != null && b.salary !== '' ? parseFloat(String(b.salary).replace(',', '.')) : 0, hoursOffered: b.hoursOffered != null && b.hoursOffered !== '' ? parseFloat(String(b.hoursOffered).replace(',', '.')) : 0, hoursRequested: b.hoursRequested != null && b.hoursRequested !== '' ? parseFloat(String(b.hoursRequested).replace(',', '.')) : 0, exchangeSkill: stripDangerousTags(b.exchangeSkill || ''), tags: b.tags, image: imageBlob, mapUrl: stripDangerousTags(b.mapUrl), industry: stripDangerousTags(b.industry || ''), visibility: b.visibility, clearnetPublic: b.clearnetPublic });
ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs']));
})
.post('/jobs/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined;
const patch = { job_type: stripDangerousTags(b.job_type), title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), requirements: stripDangerousTags(b.requirements), languages: stripDangerousTags(b.languages), job_time: b.job_time, tasks: stripDangerousTags(b.tasks), location: stripDangerousTags(b.location), tags: b.tags, mapUrl: stripDangerousTags(b.mapUrl), visibility: b.visibility, exchangeSkill: stripDangerousTags(b.exchangeSkill || ''), clearnetPublic: b.clearnetPublic };
if (b.vacants !== undefined && b.vacants !== '') patch.vacants = parseInt(b.vacants, 10);
if (b.salary !== undefined && b.salary !== '') patch.salary = parseFloat(String(b.salary).replace(',', '.'));
if (b.hoursOffered !== undefined && b.hoursOffered !== '') patch.hoursOffered = parseFloat(String(b.hoursOffered).replace(',', '.'));
if (b.hoursRequested !== undefined && b.hoursRequested !== '') patch.hoursRequested = parseFloat(String(b.hoursRequested).replace(',', '.'));
if (imageBlob !== undefined) patch.image = imageBlob;
await jobsModel.updateJob(ctx.params.id, patch);
ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs']));
})
.post('/jobs/delete/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
await jobsModel.deleteJob(ctx.params.id)
ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs']))
})
.post('/jobs/status/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
await jobsModel.updateJobStatus(ctx.params.id, String(ctx.request.body.status).toUpperCase())
ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs']))
})
.post('/jobs/visibility/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
const next = String(ctx.request.body?.visibility || '').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
await jobsModel.updateJob(ctx.params.id, { visibility: next });
ctx.redirect(safeReturnTo(ctx, `/jobs/${encodeURIComponent(ctx.params.id)}`, ['/jobs']));
})
.post('/jobs/subscribe/:id', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
const userId = getViewerId();
let job;
try { job = await jobsModel.getJobById(ctx.params.id, userId); } catch (_) {}
if (!job) { ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); return; }
const alreadySubscribed = Array.isArray(job.subscribers) && job.subscribers.includes(userId);
if (alreadySubscribed || job.author === userId) {
ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs']));
return;
}
try { await jobsModel.subscribeToJob(ctx.params.id, userId); } catch (_) {}
try { await pmModel.sendMessage([job.author], 'JOB_SUBSCRIBED', `has subscribed to your job offer "${job.title || ''}" -> /jobs/${encodeURIComponent(job.id)}`); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs']));
})
.post('/jobs/unsubscribe/:id', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
const userId = getViewerId();
let job;
try { job = await jobsModel.getJobById(ctx.params.id, userId); } catch (_) {}
if (!job) { ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); return; }
const wasSubscribed = Array.isArray(job.subscribers) && job.subscribers.includes(userId);
if (!wasSubscribed) {
ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs']));
return;
}
try { await jobsModel.unsubscribeFromJob(ctx.params.id, userId); } catch (_) {}
try { await pmModel.sendMessage([job.author], 'JOB_UNSUBSCRIBED', `has unsubscribed from your job offer "${job.title || ''}" -> /jobs/${encodeURIComponent(job.id)}`); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs']));
})
.post('/jobs/:jobId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'jobs', 'jobId'))
.post("/shops/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
await shopsModel.createShop(stripDangerousTags(b.title), stripDangerousTags(b.shortDescription), stripDangerousTags(b.description), imageBlob, stripDangerousTags(b.url), stripDangerousTags(b.location), b.tags, b.visibility, stripDangerousTags(b.mapUrl), b.clearnetPublic);
ctx.redirect(safeReturnTo(ctx, '/shops?filter=mine', ['/shops']));
})
.post("/shops/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined;
const patch = { title: stripDangerousTags(b.title), shortDescription: stripDangerousTags(b.shortDescription), description: stripDangerousTags(b.description), url: stripDangerousTags(b.url), location: stripDangerousTags(b.location), tags: b.tags, visibility: b.visibility, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic };
if (imageBlob !== undefined) patch.image = imageBlob;
await shopsModel.updateShopById(ctx.params.id, patch);
ctx.redirect(safeReturnTo(ctx, '/shops?filter=mine', ['/shops']));
})
.post("/shops/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
await shopsModel.deleteShopById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/shops?filter=mine', ['/shops']));
})
.post("/shops/visibility/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
await shopsModel.setShopVisibility(ctx.params.id, ctx.request.body.visibility);
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.id)}`, ['/shops']));
})
.post("/shops/generate-invite/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
try {
const { code } = await shopsModel.generateInvite(ctx.params.id);
ctx.body = renderShopInvitePage(code);
} catch (_) {
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.id)}`, ['/shops']));
}
})
.post("/shops/open-invite/create/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
try { await shopsModel.generateOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.id)}`, ['/shops']));
})
.post("/shops/open-invite/remove/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
try { await shopsModel.removeOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.id)}`, ['/shops']));
})
.get("/shops/open-invite/join/:id", async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
let dest = ctx.params.id;
try {
const oi = await shopsModel.getOpenInvite(ctx.params.id);
if (oi && oi.code) { const r = await shopsModel.joinByCode(oi.code); if (r && r.shopId) dest = r.shopId; }
} catch (_) {}
ctx.redirect(`/shops/${encodeURIComponent(dest)}`);
})
.post("/shops/join-code", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const code = String((ctx.request.body || {}).code || '').trim();
try {
const { shopId } = await shopsModel.joinByCode(code);
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(shopId)}`, ['/shops']));
} catch (_) {
ctx.redirect(safeReturnTo(ctx, '/shops', ['/shops']));
}
})
.post("/shops/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'shops', 'add'))
.post("/shops/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'shops', 'remove'))
.post("/shops/opinions/:shopId/:category", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
await shopsModel.createOpinion(ctx.params.shopId, ctx.params.category);
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.shopId)}`, ['/shops']));
})
.post("/shops/product/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
const productMsg = await shopsModel.createProduct(b.shopId, stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.price, b.stock, [].concat(b.featured).includes("1"));
if ([].concat(b.sendToMarket).includes("1") && checkMod(ctx, 'marketMod')) {
const shop = await shopsModel.getShopById(b.shopId);
const deadline = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
const stock = parseInt(String(b.stock || '0'), 10) || 1;
try {
await marketModel.createItem('exchange', stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.price, [], 'NEW', deadline, false, stock, '', { shopProductId: productMsg.key, shopId: b.shopId, shopTitle: shop ? shop.title : '' });
} catch (e) { console.error("market-from-shop:", e.message) }
}
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(b.shopId)}`, ['/shops']));
})
.post("/shops/product/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined;
const patch = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), price: b.price, stock: b.stock, featured: [].concat(b.featured).includes("1") };
if (imageBlob !== undefined) patch.image = imageBlob;
await shopsModel.updateProductById(ctx.params.id, patch);
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(b.shopId || '')}`, ['/shops']));
})
.post("/shops/product/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const product = await shopsModel.getProductById(ctx.params.id);
await shopsModel.deleteProductById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(product?.shopId || '')}`, ['/shops']));
})
.post("/shops/product/buy/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const deliveryAddress = stripDangerousTags(String(b.deliveryAddress || "")).trim();
if (!deliveryAddress) {
const { i18n: i18nMod } = require('../views/main_views');
sendErrorPage(ctx, i18nMod.shopBuyDeliveryRequired || "Delivery address is required.", { status: 400 });
return;
}
await shopsModel.createPurchaseOrder(ctx.params.id, {
deliveryAddress,
contact: stripDangerousTags(String(b.contact || "")).trim(),
notes: stripDangerousTags(String(b.notes || "")).trim()
});
await shopsModel.buyProduct(ctx.params.id);
try {
const pr = await shopsModel.getProductById(ctx.params.id).catch(() => null);
if (pr && pr.author && String(pr.author) !== String(getViewerId())) {
try {
await pmModel.sendMessage([pr.author], "SHOP_SOLD", `product "${pr.title}" has been sold -> /shops/product/${ctx.params.id} OASIS ID: ${getViewerId()} for: ${pr.price} ECO`);
} catch (_) {}
}
await mediaFavorites.addFavorite('shopProducts', (pr && pr.rootId) || ctx.params.id);
} catch (_) {}
if (checkMod(ctx, 'marketMod')) {
try { const mi = await marketModel.getItemByShopProductId(ctx.params.id); if (mi) await marketModel.decrementStock(mi.id); } catch (_) {}
}
ctx.redirect(safeReturnTo(ctx, `/shops/product/${encodeURIComponent(ctx.params.id)}`, ['/shops']));
})
.post("/shops/product/opinions/:productId/:category", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; }
await shopsModel.createOpinion(ctx.params.productId, ctx.params.category);
ctx.redirect(safeReturnTo(ctx, `/shops/product/${encodeURIComponent(ctx.params.productId)}`, ['/shops']));
})
.post("/shops/:shopId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'shops', 'shopId'))
.post("/chats/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body;
const tribeId = b.tribeId || null;
if (tribeId) {
const t = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {});
}
const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
await chatsModel.createChat(stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.category, b.status, b.tags, tribeId);
ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=chats` : safeReturnTo(ctx, '/chats?filter=mine', ['/chats']));
})
.post("/chats/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body;
const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : undefined;
const patch = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, status: b.status, tags: b.tags };
if (imageBlob !== undefined) patch.image = imageBlob;
await chatsModel.updateChatById(ctx.params.id, patch);
ctx.redirect(safeReturnTo(ctx, '/chats?filter=mine', ['/chats']));
})
.post("/chats/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
await chatsModel.deleteChatById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/chats?filter=mine', ['/chats']));
})
.post("/chats/close/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
await chatsModel.closeChatById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats']));
})
.post("/chats/generate-invite", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const chatId = ctx.request.body.chatId;
const code = await chatsModel.generateInvite(chatId);
ctx.body = renderChatInvitePage(code);
})
.post("/chats/open-invite/create", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const chatId = ctx.request.body.chatId;
try { await chatsModel.generateOpenInvite(chatId); } catch (_) {}
ctx.redirect(`/chats/${encodeURIComponent(chatId)}`);
})
.post("/chats/open-invite/remove", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const chatId = ctx.request.body.chatId;
try { await chatsModel.removeOpenInvite(chatId); } catch (_) {}
ctx.redirect(`/chats/${encodeURIComponent(chatId)}`);
})
.post("/chats/join-code", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const code = String(ctx.request.body.code || '').trim();
try {
const chatKey = await chatsModel.joinByInvite(code);
ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(chatKey)}`, ['/chats']));
} catch (_) {
ctx.redirect(safeReturnTo(ctx, '/chats', ['/chats']));
}
})
.post("/chats/join/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId();
const chat = await chatsModel.getChatById(ctx.params.id);
if (!chat) { sendErrorPage(ctx, "Chat not found", { status: 404 }); return; }
if (chat.status === "CLOSED") { sendErrorPage(ctx, "Chat is closed", { status: 403 }); return; }
if (chat.status === "INVITE-ONLY" && !chat.members.includes(uid) && chat.author !== uid) { sendErrorPage(ctx, "Invite-only chat", { status: 403 }); return; }
if (chat.tribeId) {
try {
const t = await tribesModel.getTribeById(chat.tribeId);
if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
} catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats']));
return;
}
try {
await chatsModel.joinChat(ctx.params.id);
} catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats']));
})
.post("/chats/leave/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
try {
await chatsModel.leaveChat(ctx.params.id);
} catch (_) {}
ctx.redirect(safeReturnTo(ctx, '/chats?filter=all', ['/chats']));
})
.post("/chats/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'add'))
.post("/chats/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'remove'))
.post("/chats/:chatId/message", koaBody({ multipart: true }), async (ctx) => {
if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId();
const chat = await chatsModel.getChatById(ctx.params.chatId);
if (chat && chat.tribeId) {
try {
const t = await tribesModel.getTribeById(chat.tribeId);
if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
} catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
}
const text = stripDangerousTags(String(ctx.request.body.text || '').trim());
const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
const replyToRaw = String(ctx.request.body.replyTo || '').trim();
const replyTo = replyToRaw.startsWith('%') ? replyToRaw : null;
if (!text && !imageBlob) { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; }
await chatsModel.sendMessage(ctx.params.chatId, text, imageBlob, replyTo);
ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.chatId)}`, ['/chats']));
})
.post("/pads/create", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const tribeId = b.tribeId || null;
if (tribeId) {
const t = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {});
}
const msg = await padsModel.createPad(
stripDangerousTags(b.title || ""),
b.status || "OPEN",
b.deadline || "",
b.tags || "",
tribeId
);
ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=pads` : `/pads/${encodeURIComponent(msg.key)}`);
})
.post("/pads/update/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
await padsModel.updatePadById(ctx.params.id, {
title: stripDangerousTags(b.title || ""),
status: b.status || "OPEN",
deadline: b.deadline || "",
tags: b.tags || ""
});
ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
})
.post("/pads/close/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
try { await padsModel.closePadById(ctx.params.id); } catch (_) {}
ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
})
.post("/pads/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
await padsModel.deletePadById(ctx.params.id);
ctx.redirect('/pads');
})
.post("/pads/generate-invite/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
const code = await padsModel.generateInvite(ctx.params.id);
ctx.body = renderPadInvitePage(code);
})
.post("/pads/open-invite/create/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
try { await padsModel.generateOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
})
.post("/pads/open-invite/remove/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
try { await padsModel.removeOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
})
.post("/pads/join-code", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
const code = String((ctx.request.body || {}).code || "").trim();
try {
const padId = await padsModel.joinByInvite(code);
ctx.redirect(`/pads/${encodeURIComponent(padId)}`);
} catch (_) {
ctx.redirect('/pads');
}
})
.post("/pads/join/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId();
const pad = await padsModel.getPadById(ctx.params.id);
if (!pad) { sendErrorPage(ctx, "Pad not found", { status: 404 }); return; }
if (pad.isClosed || pad.status === "CLOSED") { sendErrorPage(ctx, "Pad is closed", { status: 403 }); return; }
if (pad.status === "INVITE-ONLY" && !pad.members.includes(uid) && pad.author !== uid) { sendErrorPage(ctx, "Invite-only pad", { status: 403 }); return; }
if (pad.tribeId) {
try {
const t = await tribesModel.getTribeById(pad.tribeId);
if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
} catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
return;
}
await padsModel.addMemberToPad(ctx.params.id, uid);
ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
})
.post("/pads/entry/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId();
const pad = await padsModel.getPadById(ctx.params.id);
if (pad && pad.tribeId) {
try {
const t = await tribesModel.getTribeById(pad.tribeId);
if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
} catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
}
const b = ctx.request.body || {};
const text = stripDangerousTags(String(b.text || "").trim());
if (text) await padsModel.addEntry(ctx.params.id, text);
ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
})
.post("/pads/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'pads', 'add'))
.post("/pads/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'pads', 'remove'))
.post("/calendars/create", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const tribeId = b.tribeId || null;
if (tribeId) {
const t = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
const intervalWeekly = [].concat(b.intervalWeekly).includes("1");
const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
const intervalYearly = [].concat(b.intervalYearly).includes("1");
try {
const msg = await calendarsModel.createCalendar({
title: stripDangerousTags(b.title || ""),
status: b.status || "OPEN",
deadline: b.deadline || "",
tags: b.tags || "",
firstDate: b.firstDate || "",
firstDateLabel: stripDangerousTags(b.firstDateLabel || ""),
firstNote: stripDangerousTags(b.firstNote || ""),
intervalWeekly, intervalMonthly, intervalYearly,
tribeId
});
ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : `/calendars/${encodeURIComponent(msg.key)}`);
} catch (e) {
console.error("[calendars/create]", e && e.message ? e.message : e)
ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : '/calendars');
}
})
.post("/calendars/update/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
const b = ctx.request.body || {};
try {
await calendarsModel.updateCalendarById(ctx.params.id, {
title: stripDangerousTags(b.title || ""),
status: b.status || "OPEN",
deadline: b.deadline || "",
tags: b.tags || ""
});
} catch (_) {}
ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
})
.post("/calendars/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
const tribeId = target && target.tribeId;
if (tribeId) {
const t = await tribesModel.getTribeById(tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
await calendarsModel.deleteCalendarById(ctx.params.id);
ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : '/calendars');
})
.post("/calendars/join/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
try { await calendarsModel.joinCalendar(ctx.params.id); } catch (_) {}
ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
})
.post("/calendars/generate-invite/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
try {
const code = await calendarsModel.generateInvite(ctx.params.id);
ctx.body = renderCalendarInvitePage(code);
} catch (e) {
ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
}
})
.post("/calendars/open-invite/create/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
try { await calendarsModel.generateOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
})
.post("/calendars/open-invite/remove/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
try { await calendarsModel.removeOpenInvite(ctx.params.id); } catch (_) {}
ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
})
.post("/calendars/join-code", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const code = String((ctx.request.body || {}).code || "").trim();
try {
const calId = await calendarsModel.joinByInvite(code);
ctx.redirect(`/calendars/${encodeURIComponent(calId)}`);
} catch (_) {
ctx.redirect('/calendars');
}
})
.post("/calendars/leave/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
try { await calendarsModel.leaveCalendar(ctx.params.id); } catch (_) {}
ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
})
.post("/calendars/add-date/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId();
const calForGate = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
if (calForGate && calForGate.tribeId) {
try {
const t = await tribesModel.getTribeById(calForGate.tribeId);
if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
} catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
}
const b = ctx.request.body || {};
const intervalWeekly = [].concat(b.intervalWeekly).includes("1");
const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
const intervalYearly = [].concat(b.intervalYearly).includes("1");
try {
const dateMsgs = await calendarsModel.addDate(ctx.params.id, b.date || "", stripDangerousTags(b.label || ""), intervalWeekly, intervalMonthly, intervalYearly, b.intervalDeadline || "");
const noteText = stripDangerousTags(String(b.text || "").trim());
if (noteText && Array.isArray(dateMsgs)) {
for (const msg of dateMsgs) {
if (msg && msg.key) {
try { await calendarsModel.addNote(ctx.params.id, msg.key, noteText); } catch (_) {}
}
}
}
} catch (e) {
console.error("[calendars/add-date]", e && e.message ? e.message : e)
}
ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
})
.post("/calendars/add-note/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId();
const calForGate = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
if (calForGate && calForGate.tribeId) {
try {
const t = await tribesModel.getTribeById(calForGate.tribeId);
if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
} catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
}
const b = ctx.request.body || {};
const text = stripDangerousTags(String(b.text || "").trim());
if (text) {
try { await calendarsModel.addNote(ctx.params.id, b.dateId || "", text); } catch (_) {}
}
ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
})
.post("/calendars/delete-note/:noteId", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const calendarId = (ctx.request.body || {}).calendarId || "";
if (calendarId) {
const target = await calendarsModel.getCalendarById(calendarId).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
}
try { await calendarsModel.deleteNote(ctx.params.noteId); } catch (_) {}
ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
})
.post("/calendars/delete-date/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
const calendarId = (ctx.request.body || {}).calendarId || "";
if (calendarId) {
const target = await calendarsModel.getCalendarById(calendarId).catch(() => null);
if (target && target.tribeId) {
const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
}
}
try { await calendarsModel.deleteDate(ctx.params.id, calendarId); } catch (_) {}
ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
})
.post("/calendars/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'calendars', 'add'))
.post("/calendars/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'calendars', 'remove'))
.post("/projects/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {}, image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null;
const bounties = b.bountiesInput ? String(b.bountiesInput).split("\n").filter(Boolean).map(l => { const [t,a,d] = String(l).split("|"); return { title: String(t||"").trim(), amount: parseFloat(a||0)||0, description: String(d||"").trim(), milestoneIndex: null }; }) : [];
await projectsModel.createProject({ title: b.title, description: b.description, goal: b.goal != null && b.goal !== "" ? parseFloat(b.goal) : 0, deadline: b.deadline ? new Date(b.deadline).toISOString() : null, progress: b.progress != null && b.progress !== "" ? parseInt(b.progress,10) : 0, bounties, image, milestoneTitle: b.milestoneTitle, milestoneDescription: b.milestoneDescription, milestoneTargetPercent: b.milestoneTargetPercent, milestoneDueDate: b.milestoneDueDate, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic });
ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"]));
})
.post("/projects/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {};
const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : undefined;
const bounties = b.bountiesInput !== undefined ? String(b.bountiesInput).split("\n").filter(Boolean).map(l => { const [t,a,d] = String(l).split("|"); return { title: String(t||"").trim(), amount: parseFloat(a||0)||0, description: String(d||"").trim(), milestoneIndex: null }; }) : undefined;
await projectsModel.updateProject(id, { title: b.title, description: b.description, goal: b.goal !== "" && b.goal != null ? parseFloat(b.goal) : undefined, deadline: b.deadline ? new Date(b.deadline).toISOString() : undefined, progress: b.progress !== "" && b.progress != null ? parseInt(b.progress,10) : undefined, bounties, image, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic });
ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"]));
})
.post("/projects/delete/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
await projectsModel.deleteProject(await projectsModel.getProjectTipId(ctx.params.id));
ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"]));
})
.post("/projects/status/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id);
await projectsModel.updateProjectStatus(id, String(ctx.request.body?.status || "").toUpperCase());
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/progress/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id);
await projectsModel.updateProjectProgress(id, ctx.request.body?.progress);
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/pledge/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const latestId = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {};
const pledgeAmount = parseFloat(b.amount), uid = getViewerId();
if (isNaN(pledgeAmount) || pledgeAmount <= 0) ctx.throw(400, "Invalid amount");
const project = await projectsModel.getProjectById(latestId);
if (String(project.status || "ACTIVE").toUpperCase() !== "ACTIVE") ctx.throw(400, "Project is not active");
if (project.deadline && moment(project.deadline).isValid() && moment(project.deadline).isBefore(moment())) ctx.throw(400, "Project deadline passed");
if (project.author === uid) ctx.throw(403, "Authors cannot pledge to their own project");
let milestoneIndex = null, bountyIndex = null, mob = b.milestoneOrBounty || "";
if (String(mob).startsWith("milestone:")) milestoneIndex = parseInt(String(mob).split(":")[1], 10);
else if (String(mob).startsWith("bounty:")) bountyIndex = parseInt(String(mob).split(":")[1], 10);
const transfer = await transfersModel.createTransfer(project.author, "Project Pledge", pledgeAmount, moment().add(14, "days").toISOString(), ["backer-pledge", `project:${latestId}`]);
await projectsModel.pledgeToProject(latestId, uid, pledgeAmount, { transferId: transfer.key || transfer.id, milestoneIndex, bountyIndex });
await pmModel.sendMessage([project.author], "PROJECT_PLEDGE", `has pledged ${pledgeAmount} ECO to your project "${project.title || ''}" -> /projects/${latestId}`);
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(latestId)}`, ["/projects"]));
})
.post("/projects/confirm-transfer/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const uid = getViewerId(), transfer = await transfersModel.getTransferById(ctx.params.id);
if (transfer.to !== uid) ctx.throw(403, "Unauthorized action");
const tagProject = (Array.isArray(transfer.tags) ? transfer.tags : []).find(t => String(t).startsWith("project:"));
if (!tagProject) ctx.throw(400, "Missing project tag on transfer");
const projectId = String(tagProject).split(":")[1];
await transfersModel.confirmTransferById(ctx.params.id);
try { await projectsModel.confirmPledge(projectId, ctx.params.id); } catch (_) {}
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(projectId)}`, ["/projects", "/transfers"]));
})
.post("/projects/follow/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const latestId = await projectsModel.getProjectTipId(ctx.params.id), project = await projectsModel.getProjectById(latestId);
await projectsModel.followProject(ctx.params.id, getViewerId());
await pmModel.sendMessage([project.author], "PROJECT_FOLLOWED", `has followed your project "${project.title || ''}" -> /projects/${latestId}`);
ctx.redirect(safeReturnTo(ctx, "/projects", ["/projects"]));
})
.post("/projects/unfollow/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const latestId = await projectsModel.getProjectTipId(ctx.params.id), project = await projectsModel.getProjectById(latestId);
await projectsModel.unfollowProject(ctx.params.id, getViewerId());
await pmModel.sendMessage([project.author], "PROJECT_UNFOLLOWED", `has unfollowed your project "${project.title || ''}" -> /projects/${latestId}`);
ctx.redirect(safeReturnTo(ctx, "/projects", ["/projects"]));
})
.post("/projects/milestones/add/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {};
await projectsModel.addMilestone(id, { title: b.title, description: b.description || "", targetPercent: b.targetPercent != null && b.targetPercent !== "" ? parseInt(b.targetPercent, 10) : 0, dueDate: b.dueDate ? new Date(b.dueDate).toISOString() : null });
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/milestones/update/:id/:index", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id), idx = parseInt(ctx.params.index, 10), b = ctx.request.body || {};
const patch = { title: b.title, ...(b.description !== undefined ? { description: b.description } : {}), ...(b.targetPercent !== undefined && b.targetPercent !== "" ? { targetPercent: parseInt(b.targetPercent, 10) } : {}), ...(b.dueDate !== undefined ? { dueDate: b.dueDate ? new Date(b.dueDate).toISOString() : null } : {}), ...(b.done !== undefined ? { done: !!b.done } : {}) };
await projectsModel.updateMilestone(id, idx, patch);
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/milestones/complete/:id/:index", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id);
await projectsModel.completeMilestone(id, parseInt(ctx.params.index, 10), getViewerId());
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/bounties/add/:id", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {};
await projectsModel.addBounty(id, { title: b.title, amount: b.amount, description: b.description, milestoneIndex: b.milestoneIndex === "" || b.milestoneIndex === undefined ? null : parseInt(b.milestoneIndex, 10) });
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/bounties/update/:id/:index", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id), idx = parseInt(ctx.params.index, 10), b = ctx.request.body || {};
const patch = { ...(b.title !== undefined ? { title: b.title } : {}), ...(b.amount !== undefined && b.amount !== "" ? { amount: parseFloat(b.amount) } : {}), ...(b.description !== undefined ? { description: b.description } : {}), ...(b.milestoneIndex !== undefined ? { milestoneIndex: b.milestoneIndex === "" ? null : parseInt(b.milestoneIndex, 10) } : {}), ...(b.done !== undefined ? { done: !!b.done } : {}) };
await projectsModel.updateBounty(id, idx, patch);
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/bounties/claim/:id/:index", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id);
await projectsModel.claimBounty(id, parseInt(ctx.params.index, 10), getViewerId());
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/bounties/complete/:id/:index", koaBody(), async (ctx) => {
if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
const id = await projectsModel.getProjectTipId(ctx.params.id);
await projectsModel.completeBounty(id, parseInt(ctx.params.index, 10), getViewerId());
ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
})
.post("/projects/:projectId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'projects', 'projectId'))
.post("/banking/claim-ubi", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const userId = getViewerId();
try {
await bankingModel.claimUBI(userId);
ctx.redirect("/banking?filter=overview&msg=claimed_pending");
} catch (e) {
ctx.redirect(`/banking?filter=overview&msg=${encodeURIComponent(e.message || "error")}`);
}
})
.post("/banking/claim/:id", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const { i18n: _i18n } = require("../views/main_views");
const userId = getViewerId(), allocation = await bankingModel.getAllocationById(ctx.params.id);
if (!allocation) { sendErrorPage(ctx, _i18n.errorNoAllocation); return; }
if (allocation.to !== userId || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) { sendErrorPage(ctx, _i18n.errorInvalidClaim); return; }
if (!bankingModel.isPubNode()) {
ctx.redirect("/banking?filter=overview&msg=claimed_pending");
return;
}
const { txid } = await bankingModel.claimAllocation({ transferId: ctx.params.id, claimerId: userId });
await bankingModel.publishBankClaim({ amount: allocation.amount, epochId: allocation.concept, allocationId: allocation.id, txid });
ctx.redirect(`/banking?claimed=${encodeURIComponent(txid)}`);
})
.post("/banking/simulate", koaBody(), async (ctx) => {
if (!bankingModel.isPubNode()) { sendErrorPage(ctx, require("../views/main_views").i18n.bankPubOnly, { status: 403 }); return; }
const { epochId, rules } = ctx.request.body || {};
ctx.body = await bankingModel.computeEpoch({ epochId, rules });
})
.post("/banking/run", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!bankingModel.isPubNode()) { sendErrorPage(ctx, require("../views/main_views").i18n.bankPubOnly, { status: 403 }); return; }
const { epochId, rules } = ctx.request.body || {};
ctx.body = await bankingModel.executeEpoch({ epochId, rules });
})
.post("/banking/addresses", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const b = ctx.request.body || {};
const viewerId = getViewerId();
const submittedId = (b.userId || "").trim();
if (submittedId && submittedId !== viewerId) {
ctx.redirect(`/banking?filter=addresses&msg=forbidden`);
return;
}
const res = await bankingModel.addAddress({ userId: viewerId, address: (b.address || "").trim() });
ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
})
.post("/banking/addresses/delete", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const res = await bankingModel.removeAddress({ userId: getViewerId() });
ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
})
.post("/favorites/remove/:kind/:id", koaBody(), async (ctx) => {
await favoritesModel.removeFavorite(ctx.params.kind, ctx.params.id);
const fallback = `/favorites?filter=${encodeURIComponent(ctx.query.filter || "all")}`;
ctx.redirect(safeReturnTo(ctx, fallback, ["/favorites"]));
})
.post("/update", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const exec = require("node:util").promisify(require("node:child_process").exec);
const repoRoot = path.resolve(__dirname, '..', '..');
const { stdout, stderr } = await exec("git reset --hard && git pull", { cwd: repoRoot });
console.log("oasis@version: updating Oasis...", stdout, stderr);
const { stdout: shOut, stderr: shErr } = await exec("sh install.sh", { cwd: repoRoot });
console.log("oasis@version: running install.sh...", shOut, shErr);
safeRefererRedirect(ctx, '/settings');
})
.get("/settings/bottombar", async (ctx) => {
ctx.body = require("../views/main_views").bottomBarPickerView();
})
.post("/settings/bottombar", koaBody(), async (ctx) => {
const cfg = getConfig();
const MAX = 4;
const body = ctx.request.body || {};
const action = String(body.action || '').trim();
const key = String(body.key || '').trim();
const catalog = require("../views/main_views").PINNABLE_MODULES || [];
let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : [];
if (action === 'add') {
if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key);
} else if (action === 'remove') {
pins = pins.filter(k => k !== key);
} else if (action === 'clear') {
pins = [];
}
cfg.bottomBarPins = pins.slice(0, MAX);
saveConfig(cfg);
ctx.redirect('/settings/bottombar');
})
.post("/settings/theme", koaBody(), async (ctx) => {
const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig();
cfg.themes.current = theme || "Dark-SNH";
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
ctx.cookies.set("theme", cfg.themes.current, { httpOnly: true, sameSite: 'strict', secure: ctx.secure });
ctx.redirect("/settings");
})
.post("/language", koaBody(), async (ctx) => {
const lang = String(ctx.request.body.language || "en");
const cfg = getConfig();
cfg.language = lang;
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
ctx.cookies.set("language", lang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'strict', secure: ctx.secure });
try { onboardingModel.markStep('language'); } catch (_) {}
safeRefererRedirect(ctx, '/settings');
})
.post("/settings/conn/start", koaBody(), async ctx => { await meta.connStart(); ctx.redirect("/peers"); })
.post("/settings/conn/stop", koaBody(), async ctx => { await meta.connStop(); ctx.redirect("/peers"); })
.post("/settings/conn/sync", koaBody(), async ctx => { await meta.sync(); ctx.redirect("/peers"); })
.post("/settings/conn/restart", koaBody(), async ctx => { await meta.connRestart(); ctx.redirect("/peers"); })
.post("/settings/invite/accept", koaBody(), async ctx => {
const invite = String(ctx.request.body.invite || '');
const pubKey = (invite.match(/@[A-Za-z0-9+/=_-]{43,}\.ed25519/) || [])[0] || null;
if (pubKey) {
try {
const os = require('os'), fsx = require('fs'), px = require('path');
const gossip = JSON.parse(fsx.readFileSync(px.join(os.homedir(), '.ssb', 'gossip.json'), 'utf8') || '[]');
let unfollowed = [];
try { unfollowed = JSON.parse(fsx.readFileSync(px.join(os.homedir(), '.ssb', 'gossip_unfollowed.json'), 'utf8') || '[]'); } catch (_) {}
const activePub = Array.isArray(gossip) && gossip.some(p => p && p.key === pubKey) && !unfollowed.some(u => u && u.key === pubKey);
if (activePub) { ctx.redirect('/invites?flash=alreadyFederated'); return; }
} catch (_) {}
}
try { await meta.acceptInvite(invite); } catch (_) {}
safeRefererRedirect(ctx, "/invites");
})
.post("/invites/inhabitant/follow", koaBody(), async (ctx) => {
const feedId = String(ctx.request.body?.feedId || '').trim();
if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(feedId)) {
ctx.redirect(`/search?query=${encodeURIComponent(feedId)}`);
return;
}
try {
const name = await about.name(feedId);
if (!name || name === feedId.slice(1, 9)) {
ctx.redirect(`/search?query=${encodeURIComponent(feedId)}`);
return;
}
const ssb = await cooler.open();
await new Promise((res, rej) => ssb.publish({ type: 'contact', contact: feedId, following: true }, (e) => e ? rej(e) : res()));
ctx.redirect(`/author/${encodeURIComponent(feedId)}`);
} catch (_) {
ctx.redirect(`/search?query=${encodeURIComponent(feedId)}`);
}
})
.post("/settings/invite/unfollow", koaBody(), async (ctx) => {
const { key } = ctx.request.body || {};
if (!key) return ctx.redirect("/invites");
const pubs = readJSON(gossipPath), kcanon = canonicalKey(key);
const idx = pubs.findIndex(x => x && canonicalKey(x.key) === kcanon);
const removed = idx >= 0 ? (pubs.splice(idx, 1)[0], writeJSON(gossipPath, pubs), pubs[idx-1] !== undefined ? pubs.splice(idx,1)[0] : null) : null;
const ssb = await cooler.open(), addr = removed?.host ? msAddrFrom(removed.host, removed.port, removed.key) : null;
if (addr) { try { await new Promise(res => ssb.conn.disconnect(addr, res)); } catch {} try { ssb.conn.forget(addr); } catch {} }
try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, following: false, blocking: true }, e => e ? rej(e) : res())); } catch {}
const unf = readJSON(unfollowedPath);
if (!unf.find(x => x && canonicalKey(x.key) === kcanon)) { unf.push(removed || { key: kcanon }); writeJSON(unfollowedPath, unf); }
ctx.redirect("/invites");
})
.post("/settings/invite/follow", koaBody(), async (ctx) => {
const { key, host, port } = ctx.request.body || {};
if (!key || !host) return ctx.redirect("/invites");
const pubs = readJSON(gossipPath), kcanon = canonicalKey(key);
if (pubs.find(p => p.host === host)?.error) return ctx.redirect("/invites");
const ssb = await cooler.open(), unf = readJSON(unfollowedPath);
const rec = unf.find(x => x && canonicalKey(x.key) === kcanon) || { host, port: Number(port) || 8008, key: kcanon };
if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) { pubs.push({ host: rec.host, port: Number(rec.port) || 8008, key: kcanon }); writeJSON(gossipPath, pubs); }
const addr = msAddrFrom(rec.host, rec.port, kcanon);
try { ssb.conn.remember(addr, { type: "pub", autoconnect: true, key: kcanon }); } catch {}
try { await new Promise(res => ssb.conn.connect(addr, { type: "pub" }, res)); } catch {}
try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, blocking: false }, e => e ? rej(e) : res())); } catch {}
writeJSON(unfollowedPath, unf.filter(x => !(x && canonicalKey(x.key) === kcanon)));
ctx.redirect("/invites");
})
.post("/peers/connect", koaBody(), async (ctx) => {
const { key, host, port } = ctx.request.body || {};
if (!key || !host) { sendErrorPage(ctx, "Missing IP or public key.", { status: 400 }); return; }
const hostStr = String(host).trim().toLowerCase();
const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostStr);
const isHostname = /^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$/.test(hostStr);
if ((!isIPv4 && !isHostname) || hostStr.length > 253) { sendErrorPage(ctx, `Invalid IP/hostname: ${hostStr}`, { status: 400 }); return; }
if (isIPv4 && hostStr.split('.').some(o => Number(o) > 255)) { sendErrorPage(ctx, `Invalid IPv4: ${hostStr}`, { status: 400 }); return; }
const prt = Number(port) || 8008;
if (!Number.isInteger(prt) || prt < 1 || prt > 65535) { sendErrorPage(ctx, `Invalid port: ${port}`, { status: 400 }); return; }
const keyStr = String(key).trim();
if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(keyStr)) { sendErrorPage(ctx, `Invalid public key. Expected @<44 chars>=.ed25519`, { status: 400 }); return; }
const kcanon = canonicalKey(keyStr);
const pubs = readJSON(gossipPath);
if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) {
pubs.push({ host: hostStr, port: prt, key: kcanon });
writeJSON(gossipPath, pubs);
}
const ssb = await cooler.open();
const addr = msAddrFrom(hostStr, prt, kcanon);
try { ssb.conn.remember(addr, { type: "peer", autoconnect: true, key: kcanon }); } catch (e) { console.error('[peers/connect] remember failed:', e.message || e); }
try { await new Promise((res, rej) => ssb.conn.connect(addr, { type: "peer" }, (err) => err ? rej(err) : res())); } catch (_) {}
try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, following: true }, e => e ? rej(e) : res())); } catch (_) {}
const unf = readJSON(unfollowedPath);
writeJSON(unfollowedPath, unf.filter(x => !(x && canonicalKey(x.key) === kcanon)));
ctx.redirect("/peers");
})
.post("/peers/disconnect", koaBody(), async (ctx) => {
const { key, host, port } = ctx.request.body || {};
if (!key) return ctx.redirect("/peers");
const keyStr = String(key).trim();
if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(keyStr)) return ctx.redirect("/peers");
const kcanon = canonicalKey(keyStr);
const ssb = await cooler.open();
const candidates = new Set();
if (host && Number(port)) candidates.add(msAddrFrom(String(host), Number(port), kcanon));
try {
const snapshot = (ssb.conn && typeof ssb.conn.dbPeers === 'function') ? await ssb.conn.dbPeers() : [];
for (const entry of (snapshot || [])) {
const addr = Array.isArray(entry) ? entry[0] : null;
const data = Array.isArray(entry) ? entry[1] : entry;
if (data && canonicalKey(data.key || '') === kcanon && addr) candidates.add(addr);
}
} catch (_) {}
try {
const staged = (ssb.conn && typeof ssb.conn.stagedPeers === 'function') ? ssb.conn.stagedPeers() : [];
for (const entry of (staged || [])) {
const addr = Array.isArray(entry) ? entry[0] : null;
const data = Array.isArray(entry) ? entry[1] : entry;
if (data && canonicalKey(data.key || '') === kcanon && addr) candidates.add(addr);
}
} catch (_) {}
try {
const livePeers = (ssb.peers && typeof ssb.peers === 'object') ? ssb.peers : {};
const rpcs = livePeers[kcanon];
if (Array.isArray(rpcs)) {
for (const r of rpcs) {
const addrLive = r?.stream?.address || null;
if (addrLive) candidates.add(addrLive);
}
}
} catch (_) {}
for (const addr of candidates) {
try { await new Promise(res => ssb.conn.disconnect(addr, res)); } catch {}
try { ssb.conn.forget(addr); } catch {}
}
ctx.redirect("/peers");
})
.post("/invites/refresh-pubs", koaBody(), async (ctx) => {
try {
const ssb = await cooler.open();
const pubs = readJSON(gossipPath);
if (Array.isArray(pubs)) {
for (const p of pubs) {
if (!p || !p.key || !p.host) continue;
let addr;
try { addr = msAddrFrom(p.host, p.port, p.key); } catch (_) { continue; }
try { ssb.conn.connect(addr, { type: "pub" }, () => {}); } catch (_) {}
}
}
} catch (_) {}
ctx.redirect("/invites");
})
.post("/invites/clear-unreachable", koaBody(), async (ctx) => {
try {
const pubs = readJSON(gossipPath);
if (Array.isArray(pubs)) {
const kept = pubs.filter(p => p && !p.error && !(typeof p.failure === 'number' && p.failure > 0));
if (kept.length !== pubs.length) writeJSON(gossipPath, kept);
}
} catch (_) {}
ctx.redirect("/invites");
})
.get("/invites/export-pubs", async (ctx) => {
const lines = [];
lines.push('# Oasis pubs — multiserver addresses, one per line');
lines.push('# Generated ' + new Date().toISOString());
try {
const pubs = readJSON(gossipPath);
if (Array.isArray(pubs)) {
const seen = new Set();
for (const p of pubs) {
if (!p || !p.key || !p.host) continue;
try {
const addr = msAddrFrom(p.host, p.port, p.key);
if (seen.has(addr)) continue;
seen.add(addr);
lines.push(addr);
} catch (_) {}
}
}
} catch (_) {}
ctx.type = 'text/plain';
ctx.set('Content-Disposition', 'attachment; filename="oasis-pubs.txt"');
ctx.body = lines.join('\n') + '\n';
})
.post("/invites/import-pubs", koaBody({ multipart: true, formidable: { maxFileSize: 1 * 1024 * 1024 } }), async (ctx) => {
let raw = String((ctx.request.body && ctx.request.body.peerList) || '');
try {
const f = ctx.request.files && (ctx.request.files.peerFile || ctx.request.files.file);
const file = Array.isArray(f) ? f[0] : f;
if (file && file.filepath) {
const buf = await promisesFs.readFile(file.filepath, 'utf8');
raw = raw ? (raw + '\n' + buf) : buf;
}
} catch (_) {}
if (!raw.trim()) return ctx.redirect("/invites");
let ssb = null;
try { ssb = await cooler.open(); } catch (_) {}
const pubs = readJSON(gossipPath);
let writeBack = false;
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const msMatch = trimmed.match(/^net:([^:]+):(\d+)~shs:([A-Za-z0-9+/_\-]{43}=?)(?:\.ed25519)?$/);
if (msMatch) {
const host = msMatch[1];
const port = Number(msMatch[2]);
const keyCore = msMatch[3].endsWith('=') ? msMatch[3] : (msMatch[3] + '=');
const kcanon = canonicalKey('@' + keyCore + '.ed25519');
const addr = msAddrFrom(host, port, kcanon);
if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) {
pubs.push({ host, port, key: kcanon });
writeBack = true;
}
if (ssb && ssb.conn && typeof ssb.conn.remember === 'function') {
try { ssb.conn.remember(addr, { type: 'pub', autoconnect: true, key: kcanon }); } catch (_) {}
}
continue;
}
const inviteMatch = trimmed.match(/^[^:]+:\d+:@[A-Za-z0-9+/_\-]{43}=?\.ed25519~/);
if (inviteMatch) {
try { await meta.acceptInvite(trimmed); } catch (_) {}
continue;
}
}
if (writeBack) writeJSON(gossipPath, pubs);
ctx.redirect("/invites");
})
.post("/peers/refresh", koaBody(), async (ctx) => {
try {
const ssb = await cooler.open();
try { if (ssb.lan && typeof ssb.lan.stop === 'function') ssb.lan.stop(); } catch (_) {}
try { if (ssb.lan && typeof ssb.lan.start === 'function') ssb.lan.start(); } catch (_) {}
} catch (_) {}
const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || '');
ctx.redirect(['/peers', '/graphos'].includes(returnTo) ? returnTo : '/peers');
})
.post("/peers/prune", koaBody(), async (ctx) => {
try {
const ssb = await cooler.open();
const connectedKeys = new Set();
try {
const livePeers = (ssb.peers && typeof ssb.peers === 'object') ? ssb.peers : {};
for (const k of Object.keys(livePeers)) {
const rpcs = livePeers[k];
if (Array.isArray(rpcs) && rpcs.length > 0) connectedKeys.add(canonicalKey(k));
}
} catch (_) {}
try {
const snapshot = (ssb.conn && typeof ssb.conn.dbPeers === 'function') ? await ssb.conn.dbPeers() : [];
for (const entry of (snapshot || [])) {
const data = Array.isArray(entry) ? entry[1] : entry;
const addr = Array.isArray(entry) ? entry[0] : null;
if (!data || !addr) continue;
const kc = data.key ? canonicalKey(data.key) : null;
if (kc && connectedKeys.has(kc)) continue;
try { ssb.conn.forget(addr); } catch (_) {}
}
} catch (_) {}
try {
const pubs = readJSON(gossipPath);
if (Array.isArray(pubs)) {
const kept = pubs.filter(g => g && g.key && connectedKeys.has(canonicalKey(g.key)));
if (kept.length !== pubs.length) writeJSON(gossipPath, kept);
}
} catch (_) {}
} catch (_) {}
const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || '');
ctx.redirect(['/peers', '/graphos'].includes(returnTo) ? returnTo : '/peers');
})
.get("/peers/export", async (ctx) => {
const lines = [];
lines.push('# Oasis peers — multiserver addresses, one per line');
lines.push('# Generated ' + new Date().toISOString());
const seen = new Set();
const writePeer = (host, port, key) => {
if (!host || !key) return;
let addr;
try { addr = msAddrFrom(host, port || 8008, key); } catch (_) { return; }
if (seen.has(addr)) return;
seen.add(addr);
lines.push(addr);
};
try {
const pubs = readJSON(gossipPath);
if (Array.isArray(pubs)) for (const g of pubs) if (g && g.key && g.host) writePeer(g.host, g.port, g.key);
} catch (_) {}
try {
const ssb = await cooler.open();
try {
const snapshot = (ssb.conn && typeof ssb.conn.dbPeers === 'function') ? await ssb.conn.dbPeers() : [];
for (const entry of (snapshot || [])) {
const data = Array.isArray(entry) ? entry[1] : entry;
const addr = Array.isArray(entry) ? entry[0] : null;
if (!data || !data.key) continue;
let host = data.host, port = data.port;
if ((!host || !port) && addr) {
const m = String(addr).match(/^net:([^:]+):(\d+)/);
if (m) { host = host || m[1]; port = port || Number(m[2]); }
}
writePeer(host, port, data.key);
}
} catch (_) {}
try {
if (ssb.conn && typeof ssb.conn.stagedPeers === 'function') {
const staged = await new Promise((resolve) => {
try {
pull(
ssb.conn.stagedPeers(),
pull.take(1),
pull.collect((err, results) => {
if (err || !results || !results[0]) return resolve([]);
resolve(Array.isArray(results[0]) ? results[0] : []);
})
);
} catch (_) { resolve([]); }
});
for (const entry of staged) {
const data = Array.isArray(entry) ? entry[1] : entry;
const addr = Array.isArray(entry) ? entry[0] : null;
if (!data || !data.key) continue;
let host = data.host, port = data.port;
if ((!host || !port) && addr) {
const m = String(addr).match(/^net:([^:]+):(\d+)/);
if (m) { host = host || m[1]; port = port || Number(m[2]); }
}
writePeer(host, port, data.key);
}
}
} catch (_) {}
} catch (_) {}
ctx.type = 'text/plain';
ctx.set('Content-Disposition', 'attachment; filename="oasis-peers.txt"');
ctx.body = lines.join('\n') + '\n';
})
.post("/peers/import", koaBody({ multipart: true, formidable: { maxFileSize: 1 * 1024 * 1024 } }), async (ctx) => {
let raw = String((ctx.request.body && ctx.request.body.peerList) || '');
try {
const f = ctx.request.files && (ctx.request.files.peerFile || ctx.request.files.file);
const file = Array.isArray(f) ? f[0] : f;
if (file && file.filepath) {
const buf = await promisesFs.readFile(file.filepath, 'utf8');
raw = raw ? (raw + '\n' + buf) : buf;
}
} catch (_) {}
if (!raw.trim()) return ctx.redirect("/peers");
let ssb = null;
try { ssb = await cooler.open(); } catch (_) {}
const pubs = readJSON(gossipPath);
let added = 0;
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const m = trimmed.match(/^net:([^:]+):(\d+)~shs:([A-Za-z0-9+/_\-]{43}=?)(?:\.ed25519)?$/);
if (!m) continue;
const host = m[1];
const port = Number(m[2]);
const keyCore = m[3].endsWith('=') ? m[3] : (m[3] + '=');
const kcanon = canonicalKey('@' + keyCore + '.ed25519');
const addr = msAddrFrom(host, port, kcanon);
if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) {
pubs.push({ host, port, key: kcanon });
}
if (ssb && ssb.conn && typeof ssb.conn.remember === 'function') {
try { ssb.conn.remember(addr, { type: 'peer', autoconnect: true, key: kcanon }); added++; } catch (_) {}
} else {
added++;
}
}
writeJSON(gossipPath, pubs);
ctx.redirect("/peers");
})
.post("/settings/ssb-logstream", koaBody(), async (ctx) => {
const logLimit = parseInt(ctx.request.body.ssb_log_limit, 10);
if (!isNaN(logLimit) && logLimit > 0 && logLimit <= 100000) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.ssbLogStream = { ...(config.ssbLogStream || {}), limit: logLimit };
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
}
ctx.redirect("/settings");
})
.post("/settings/replication", koaBody(), async (ctx) => {
const hops = parseInt(ctx.request.body.hops, 10);
if (Number.isFinite(hops) && hops >= 0 && hops <= 6) {
const serverConfigPath = path.join(__dirname, '..', 'configs', 'server-config.json');
try {
const cfg = JSON.parse(fs.readFileSync(serverConfigPath, 'utf8'));
cfg.friends = { ...(cfg.friends || {}), hops };
fs.writeFileSync(serverConfigPath, JSON.stringify(cfg, null, 2));
} catch (_) {}
}
ctx.redirect("/settings");
})
.post("/settings/home-page", koaBody(), async (ctx) => {
const cfg = getConfig();
cfg.homePage = String(ctx.request.body.homePage || "").trim() || "activity";
saveConfig(cfg);
ctx.redirect("/settings");
})
.post("/settings/ux", koaBody(), async (ctx) => {
const cfg = getConfig();
const v = String(ctx.request.body.ux || "").trim().toLowerCase();
const aiNavEnabled = cfg.modules && cfg.modules.aiNavMod === 'on';
const next = (v === "ainav" && aiNavEnabled) ? "ainav" : "blocks";
cfg.ux = { ...(cfg.ux && typeof cfg.ux === 'object' ? cfg.ux : {}), current: next };
saveConfig(cfg);
ctx.redirect(next === "ainav" ? "/" : "/settings");
})
.post("/settings/lan-broadcasting", koaBody(), async (ctx) => {
const enabled = !!(ctx.request.body && (ctx.request.body.lanBroadcasting === 'on' || ctx.request.body.lanBroadcasting === '1' || ctx.request.body.lanBroadcasting === 'true'));
const cfg = getConfig();
cfg.lanBroadcasting = enabled;
saveConfig(cfg);
try {
const ssb = await cooler.open();
if (ssb && ssb.lan) {
if (enabled && typeof ssb.lan.start === 'function') { try { ssb.lan.start(); } catch (_) {} }
if (!enabled && typeof ssb.lan.stop === 'function') { try { ssb.lan.stop(); } catch (_) {} }
}
} catch (_) {}
ctx.redirect("/settings");
})
.post("/inhabitants/follow/accept", koaBody(), async (ctx) => {
const b = ctx.request.body || {};
const followerId = String(b.followerId || '').trim();
if (!followerId) { ctx.redirect('/inhabitants?filter=pending'); return; }
if (viewerFilters.canAutoAcceptNow()) viewerFilters.markAutoAccept();
viewerFilters.addAccepted(followerId);
viewerFilters.removePending(followerId);
ctx.redirect('/inhabitants?filter=pending');
})
.post("/inhabitants/follow/reject", koaBody(), async (ctx) => {
const b = ctx.request.body || {};
const followerId = String(b.followerId || '').trim();
if (!followerId) { ctx.redirect('/inhabitants?filter=pending'); return; }
viewerFilters.removeAccepted(followerId);
viewerFilters.removePending(followerId);
ctx.redirect('/inhabitants?filter=pending');
})
.post("/settings/wish", koaBody(), async (ctx) => {
const cfg = getConfig();
const v = String(ctx.request.body.wish || '').trim();
cfg.wish = ['mutuals', 'only-lan'].includes(v) ? v : 'whole';
saveConfig(cfg);
ctx.redirect("/settings");
})
.post("/settings/pm-visibility", koaBody(), async (ctx) => {
const cfg = getConfig();
const v = String(ctx.request.body.pmVisibility || '').trim();
cfg.pmVisibility = v === 'mutuals' ? 'mutuals' : 'whole';
saveConfig(cfg);
const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || '');
ctx.redirect(['/settings', '/inbox'].includes(returnTo) ? returnTo : '/settings');
})
.post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); })
.post("/modules/preset", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents'];
const PRESETS = {
minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops', 'industry'],
full: ALL_MODULES
};
const preset = String(ctx.request.body.preset || '');
const enabledMods = PRESETS[preset];
if (!enabledMods) { ctx.redirect('/modules'); return; }
const cfg = getConfig();
ALL_MODULES.forEach(mod => cfg.modules[`${mod}Mod`] = enabledMods.includes(mod) ? 'on' : 'off');
saveConfig(cfg);
ctx.redirect('/modules');
})
.post("/save-modules", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'graphos', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents'];
const cfg = getConfig();
modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off');
saveConfig(cfg);
ctx.redirect(`/modules`);
})
.post("/settings/ai", koaBody(), async (ctx) => {
const aiPrompt = String(ctx.request.body.ai_prompt || "").trim();
if (aiPrompt.length > 128) { sendErrorPage(ctx, "Prompt too long. Must be 128 characters or fewer.", { status: 400 }); return; }
const cfg = getConfig();
cfg.ai = { ...(cfg.ai || {}), prompt: aiPrompt };
saveConfig(cfg);
ctx.redirect("/settings");
})
.post("/settings/pub-id", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const b = ctx.request.body, cfg = getConfig();
cfg.walletPub = { pubId: String(b.pub_id || "").trim() };
saveConfig(cfg);
ctx.redirect("/settings");
})
.post('/transfers/create', koaBody(), async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body;
await transfersModel.createTransfer(b.to, b.concept, b.amount, b.deadline, b.tags, b.category);
ctx.redirect(safeReturnTo(ctx, '/transfers?filter=all', ['/transfers']));
})
.post('/transfers/update/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body;
await transfersModel.updateTransferById(ctx.params.id, b.to, b.concept, b.amount, b.deadline, b.tags, b.category);
ctx.redirect(safeReturnTo(ctx, '/transfers?filter=mine', ['/transfers']));
})
.post('/transfers/confirm/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
await transfersModel.confirmTransferById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/transfers', ['/transfers']));
})
.post('/transfers/delete/:id', koaBody(), async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
await transfersModel.deleteTransferById(ctx.params.id);
ctx.redirect(safeReturnTo(ctx, '/transfers?filter=mine', ['/transfers']));
})
.post('/transfers/opinions/:transferId/:category', koaBody(), async ctx => {
if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
await transfersModel.createOpinion(ctx.params.transferId, ctx.params.category);
ctx.redirect(safeReturnTo(ctx, '/transfers', ['/transfers']));
})
.post("/settings/wallet", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const b = ctx.request.body, cfg = getConfig();
if (b.wallet_url) cfg.wallet.url = String(b.wallet_url);
if (b.wallet_user) cfg.wallet.user = String(b.wallet_user);
if (b.wallet_pass) cfg.wallet.pass = String(b.wallet_pass);
if (b.wallet_fee) cfg.wallet.fee = String(b.wallet_fee);
saveConfig(cfg);
const res = await bankingModel.ensureSelfAddressPublished();
ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
})
.post("/wallet/send", koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
if (!checkMod(ctx, 'walletMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body, action = String(b.action), dest = String(b.destination), amt = Number(b.amount), fee = Number(b.fee);
const { url, user, pass } = getConfig().wallet;
let balance = null;
try { balance = await walletModel.getBalance(url, user, pass); } catch (error) { ctx.body = await walletErrorView(error); return; }
if (action === 'confirm') {
const v = await walletModel.validateSend(url, user, pass, dest, amt, fee);
try { ctx.body = v.isValid ? await walletSendConfirmView(balance, dest, amt, fee) : await walletSendFormView(balance, dest, amt, fee, { type: 'error', title: 'validation_errors', messages: v.errors }); }
catch (error) { ctx.body = await walletErrorView(error); }
} else if (action === 'send') {
try { ctx.body = await walletSendResultView(balance, dest, amt, await walletModel.sendToAddress(url, user, pass, dest, amt)); }
catch (error) { ctx.body = await walletErrorView(error); }
}
});
const routes = router.routes();
const middleware = [
async (ctx, next) => {
if (config.public && ctx.method !== "GET") { sendErrorPage(ctx, "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again.", { status: 403 }); return; }
await next();
},
async (ctx, next) => { setLanguage(ctx.cookies.get("language") || getConfig().language || "en"); await next(); },
async (ctx, next) => {
const isBinary = ctx.path.startsWith('/qr') || ctx.path.startsWith('/image/') || ctx.path.startsWith('/blob/') || ctx.path.startsWith('/assets/');
if (isBinary) {
try { await next(); } catch (err) {
ctx.status = err.status || 500;
ctx.body = '';
}
return;
}
const allowDuringSync =
ctx.path === '/profile/edit' ||
ctx.path === '/profile' ||
ctx.path === '/modules' ||
ctx.path.startsWith('/c/') ||
ctx.path.startsWith('/settings');
if (allowDuringSync) {
try { await next(); } catch (err) {
const { i18n } = require('../views/main_views');
sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 });
}
return;
}
const ssb = await cooler.open(), status = await ssb.status(), values = Object.values(status.sync.plugins);
const totalCurrent = values.reduce((acc, cur) => acc + cur, 0), totalTarget = status.sync.since * values.length;
if (totalTarget - totalCurrent > 1024 * 1024) ctx.response.body = indexingView({ percent: Math.floor((totalCurrent / totalTarget) * 1000) / 10 });
else { try { await next(); } catch (err) {
const { i18n } = require('../views/main_views');
if (err.name === 'FileTooLargeError' || (err.message && err.message.includes('maxFileSize'))) {
sendErrorPage(ctx, i18n.fileTooLargeMessage, { title: i18n.fileTooLargeTitle, status: 413 });
} else {
sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 });
}
} }
},
async (ctx, next) => {
if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/') && !ctx.path.startsWith('/qr') && !ctx.path.startsWith('/c/')) {
// filtro del hive (Personal/Community) del home; lo leen la topbar (enlaces) y renderHiveNav (categorías mostradas)
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
const now = Date.now();
if (now - sharedState.getLastRefresh() > 60000) {
sharedState.setLastRefresh(now);
try {
// avatar propio para el círculo del topbar móvil (lo lee renderMobileTopbar)
const meId = getViewerId();
const selfImg = meId ? await about.image(meId) : null;
const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256';
if (typeof selfImg === 'string' && selfImg[0] === '&' && selfImg !== NULL_IMG) {
global.__OASIS_SELF_AVATAR__ = `/image/256/${encodeURIComponent(selfImg)}`;
} else if (typeof selfImg === 'string' && selfImg[0] === '/') {
global.__OASIS_SELF_AVATAR__ = selfImg;
} else {
global.__OASIS_SELF_AVATAR__ = null;
}
} catch (_) { }
try {
const stats = await statsModel.getStats('ALL');
const totalMB = parseSizeMB(stats.statsBlobsSize) + parseSizeMB(stats.statsBlockchainSize);
const hcT = parseFloat((totalMB * 0.0002 * 475).toFixed(2));
const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
const hcH = inhabitants > 0 ? parseFloat((hcT / inhabitants).toFixed(2)) : 0;
sharedState.setCarbonHcT(hcT);
sharedState.setCarbonHcH(hcH);
} catch (_) {}
try { await refreshInboxCount(); } catch (_) {}
try { await calendarsModel.checkDueReminders(); } catch (_) {}
try { await tasksModel.checkDueReminders(); } catch (_) {}
try { await checkPoliticalChanges(); } catch (_) {}
try {
const peers = await meta.connectedPeers();
sharedState.setOnlinePeerCount(Array.isArray(peers) ? peers.length : 0);
} catch (_) {}
try {
sharedState.setInboxUnreadCount(sharedState.getInboxCount());
sharedState.setLastSyncTs(now);
} catch (_) {}
try {
const ex = await bankingModel.calculateEcoinValue();
if (ex && ex.isSynced) sharedState.setEcoValue(Number(ex.ecoValue).toFixed(4));
} catch (_) {}
try {
const me = getViewerId();
const actions = await activityModel.listFeed('all').catch(() => []);
const mine = (actions || []).filter(a => a && a.author === me && a.type !== 'tombstone' && a.type !== 'post');
mine.sort((a, b) => (b.ts || 0) - (a.ts || 0));
const prev = mine[1] || mine[0];
if (prev) {
const hrefByType = {
vote: prev.content?.vote?.link ? `/thread/${encodeURIComponent(prev.content.vote.link)}#${encodeURIComponent(prev.content.vote.link)}` : null,
transfer: `/transfers/${encodeURIComponent(prev.id)}`,
tribe: `/tribe/${encodeURIComponent(prev.id)}`,
shop: `/shops/${encodeURIComponent(prev.id)}`,
job: `/jobs/${encodeURIComponent(prev.id)}`,
event: `/events/${encodeURIComponent(prev.id)}`,
project: `/projects/${encodeURIComponent(prev.id)}`,
image: `/images/${encodeURIComponent(prev.id)}`,
audio: `/audios/${encodeURIComponent(prev.id)}`,
video: `/videos/${encodeURIComponent(prev.id)}`,
document: `/documents/${encodeURIComponent(prev.id)}`,
torrent: `/torrents/${encodeURIComponent(prev.id)}`,
forum: `/forum/${encodeURIComponent(prev.content?.key || prev.id)}`,
bookmark: `/bookmarks/${encodeURIComponent(prev.id)}`,
task: `/tasks/${encodeURIComponent(prev.id)}`,
event_: `/events/${encodeURIComponent(prev.id)}`,
about: `/author/${encodeURIComponent(prev.author)}`,
map: `/maps/${encodeURIComponent(prev.id)}`,
chat: `/chats/${encodeURIComponent(prev.id)}`,
pad: `/pads/${encodeURIComponent(prev.id)}`,
pixelia: `/pixelia`
};
sharedState.setLastActivity({
id: prev.id,
type: prev.type,
ts: prev.ts,
href: hrefByType[prev.type] || `/activity?filter=mine`
});
}
} catch (_) {}
}
}
await next();
},
routes,
];
const app = http({ host, port, middleware, allowHost: config.allowHost });
let pubEngineTimer = null;
async function runPubEngineTick() {
if (!bankingModel.isPubNode()) return;
try { await bankingModel.executeEpoch({}); } catch (_) {}
try { await bankingModel.processPendingClaims(); } catch (_) {}
try { await bankingModel.publishPubAvailability(); } catch (_) {}
}
if (bankingModel.isPubNode()) {
setTimeout(() => { runPubEngineTick(); }, 15000);
pubEngineTimer = setInterval(runPubEngineTick, 30 * 60 * 1000);
}
setTimeout(() => { larpModel.init().catch(() => {}); }, 10000);
let welcomePmAttempted = false;
async function sendWelcomePmIfFirstLaunch() {
if (welcomePmAttempted) return;
welcomePmAttempted = true;
const flagPath = path.join(ssbConfig.path, 'oasis-first-contact');
try {
const ssbClient = await cooler.open();
if (!ssbClient || !ssbClient.id) { welcomePmAttempted = false; return; }
const ownId = ssbClient.id;
if (fs.existsSync(flagPath)) {
let prev = '';
try { prev = fs.readFileSync(flagPath, 'utf8'); } catch (_) {}
if (prev.includes(ownId)) return;
try { fs.unlinkSync(flagPath); } catch (_) {}
}
const i18nAll = require('../client/assets/translations/i18n');
const lang = (getConfig() && getConfig().language) || 'en';
const t = i18nAll[lang] || i18nAll.en;
const subject = t.welcomePmSubject || 'Hello.';
const text = t.welcomePmBody || 'Hello.';
try {
await pmModel.sendMessage([], subject, text);
} catch (err) {
console.error('[welcome-pm] publish failed:', err && err.message ? err.message : err);
welcomePmAttempted = false;
return;
}
try {
if (!onboardingModel.begin(ownId)) console.error('[welcome-pm] flag write failed');
} catch (e) {
console.error('[welcome-pm] flag write failed:', e && e.message ? e.message : e);
}
} catch (err) {
console.error('[welcome-pm] unexpected error:', err && err.message ? err.message : err);
welcomePmAttempted = false;
}
}
let welcomePmRetries = 0;
const welcomePmTick = async () => {
if (onboardingModel.firstContactSeen()) return;
await sendWelcomePmIfFirstLaunch();
if (onboardingModel.firstContactSeen()) return;
if (welcomePmRetries++ >= 30) return;
setTimeout(welcomePmTick, 4000);
};
setTimeout(welcomePmTick, 3000);
const pbFmtDate = (iso) => iso ? moment(iso).format('YYYY-MM-DD HH:mm:ss') : '';
const pbFmtRemaining = (endIso) => {
if (!endIso) return '';
const ms = moment(endIso).diff(moment());
if (ms <= 0) return '0d 00:00:00';
const d = Math.floor(ms / 86400000);
const h = Math.floor((ms % 86400000) / 3600000);
const mi = Math.floor((ms % 3600000) / 60000);
const se = Math.floor((ms % 60000) / 1000);
const pad = n => String(n).padStart(2, '0');
return `${d}d ${pad(h)}:${pad(mi)}:${pad(se)}`;
};
const pbFill = (tpl, vars) => String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
const pbTitleCase = (s) => { const t = String(s || ''); return t ? t.charAt(0).toUpperCase() + t.slice(1).toLowerCase() : ''; };
const pbAtId = (id) => id ? '@' + String(id).replace(/^@/, '') : '';
function buildLarpRulingMsg(data = {}, t = {}) {
const house = data.house || {};
const name = house.name || data.houseKey || '';
const intro = pbFill(t.politicalBotLarpIntro || 'Now {house} rules during this cycle: {cycle}', {
house: `[${name}](/larp/${encodeURIComponent(data.houseKey || '')})`,
cycle: `[${data.cycleFormatted || ''}](/larp)`
});
const lines = [
intro, '', name, house.description || '',
`${t.larpRolesLabel || 'Roles'}: ${house.roles || ''}`,
`${t.larpFunctionLabel || 'Function'}: ${house.function || ''}`,
`${t.larpMonthLabel || 'Governance cycle'}: ${house.month != null ? house.month : ''}`,
`${t.larpMembersCount || 'Members'}: ${data.memberCount || 0}`,
house.motto ? `${house.motto}` : ''
];
return { subject: 'LARP_RULING', body: lines.filter(l => l != null && l !== '').join('\n') };
}
function pbGovBlock(gov = {}, t = {}) {
const lines = [`${t.parliamentGovernmentCard || 'Current Government'}: ${String(gov.method || '').toUpperCase()}`];
if (gov.leaderId) lines.push(`${t.politicalBotLeaderLabel || 'Leader'}: ${pbAtId(gov.leaderId)}`);
lines.push(`${t.parliamentLegSince || 'CYCLE SINCE'}: ${pbFmtDate(gov.since)}`);
lines.push(`${t.parliamentLegEnd || 'CYCLE END'}: ${pbFmtDate(gov.end)}`);
lines.push(`${t.parliamentTimeRemaining || 'Time remaining'}: ${pbFmtRemaining(gov.end)}`);
lines.push(`${t.parliamentPopulation || 'Population'}: ${gov.population || 0}`);
return lines;
}
function buildParliamentGovMsg(gov = {}, t = {}) {
const govLink = `[${pbTitleCase(gov.method)}](/parliament)`;
const intro = gov.leaderId
? pbFill(t.politicalBotParliamentIntroLeader || 'The current government is {gov}, led by {leader}', { gov: govLink, leader: pbAtId(gov.leaderId) })
: pbFill(t.politicalBotParliamentIntro || 'The current government is {gov}', { gov: govLink });
return { subject: 'PARLIAMENT_GOV', body: [intro, ''].concat(pbGovBlock(gov, t)).join('\n') };
}
function buildTribeGovMsg(gov = {}, t = {}) {
const govLink = `[${pbTitleCase(gov.method)}](/tribe/${encodeURIComponent(gov.tribeId || '')}?section=governance)`;
const vars = { tribe: gov.tribeName || '', gov: govLink, leader: pbAtId(gov.leaderId) };
const intro = gov.leaderId
? pbFill(t.politicalBotTribeIntroLeader || 'The current government in the Tribe {tribe} is {gov}, led by {leader}', vars)
: pbFill(t.politicalBotTribeIntro || 'The current government in the Tribe {tribe} is {gov}', vars);
return { subject: 'TRIBE_GOV', body: [intro, ''].concat(pbGovBlock(gov, t)).join('\n') };
}
let politicalCheckRunning = false;
async function checkPoliticalChanges() {
if (politicalCheckRunning) return;
politicalCheckRunning = true;
try {
const ssbClient = await cooler.open().catch(() => null);
if (!ssbClient || !ssbClient.id) return;
const ownId = ssbClient.id;
const i18nAll = require('../client/assets/translations/i18n');
const lang = (getConfig() && getConfig().language) || 'en';
const t = i18nAll[lang] || i18nAll.en;
const announced = await pmModel.sentRefs().catch(() => new Set());
const alreadyAnnounced = (subject, ref) => announced.has(`${subject}|${ref}`);
const send = async (subject, body, ref) => {
if (!ref || alreadyAnnounced(subject, ref)) return;
try { await pmModel.sendMessage([], subject, body, false, ref); announced.add(`${subject}|${ref}`); }
catch (e) { console.error('[political-bot] send failed:', e && e.message); }
};
try {
const houseKey = larpModel.getGoverningHouseKey();
if (houseKey) {
let cycleFormatted = '';
try { cycleFormatted = larpModel.computeCycle().formatted; } catch (_) {}
const ref = `${houseKey}:${cycleFormatted}`;
if (!alreadyAnnounced('LARP_RULING', ref)) {
const house = (larpModel.getHouse && larpModel.getHouse(houseKey)) || {};
let memberCount = 0;
try { const hc = await larpModel.listHousesWithCounts(); const h = (hc || []).find(x => x.key === houseKey); memberCount = h ? (h.memberCount || 0) : 0; } catch (_) {}
const { subject, body } = buildLarpRulingMsg({ houseKey, house, memberCount, cycleFormatted }, t);
await send(subject, body, ref);
}
}
} catch (e) { console.error('[political-bot] larp:', e && e.message); }
try {
const card = await parliamentModel.getLatestGovernmentCard().catch(() => null);
if (card) {
const ref = String(card.id || card.since || card.method || '');
if (ref && !alreadyAnnounced('PARLIAMENT_GOV', ref)) {
let population = 0;
try { const inh = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }); population = Array.isArray(inh) ? inh.length : 0; } catch (_) {}
const leaderId = (card.powerType === 'inhabitant' && card.powerId) ? card.powerId : null;
const { subject, body } = buildParliamentGovMsg({ method: card.method, since: card.since, end: card.end, population, leaderId }, t);
await send(subject, body, ref);
}
}
} catch (e) { console.error('[political-bot] parliament:', e && e.message); }
try {
let tribes = [];
try { tribes = await tribesModel.listAll(); } catch (_) { tribes = []; }
for (const tribe of (tribes || [])) {
if (!tribe || !tribe.id) continue;
if (!Array.isArray(tribe.members) || !tribe.members.includes(ownId)) continue;
if (tribe.parentTribeId) continue;
let term = null;
try { term = await parliamentModel.tribe.getCurrentTerm(tribe.id); } catch (_) { term = null; }
if (!term) continue;
const ref = `${tribe.id}:${String(term.id || term.startAt || term.method || '')}`;
if (!alreadyAnnounced('TRIBE_GOV', ref)) {
const lead = Array.isArray(term.leaders) && term.leaders.length ? term.leaders[0] : null;
const leaderId = lead && typeof lead === 'object' ? (lead.id || lead.powerId || null) : lead;
const population = Array.isArray(tribe.members) ? tribe.members.length : 0;
const { subject, body } = buildTribeGovMsg({ method: term.method, since: term.startAt, end: term.endAt, population, leaderId, tribeId: tribe.id, tribeName: tribe.title || tribe.name || tribe.id }, t);
await send(subject, body, ref);
}
}
} catch (e) { console.error('[political-bot] tribe:', e && e.message); }
} catch (err) {
console.error('[political-bot] unexpected:', err && err.message);
} finally {
politicalCheckRunning = false;
}
}
async function logClearnetStatus() {
try {
const ssbClient = await cooler.open();
if (!ssbClient || !ssbClient.id) return;
const prefs = await about.visibilityPrefs(ssbClient.id).catch(() => null);
const modules = [
['Shops', 'clearnetShops'],
['Jobs', 'clearnetJobs'],
['Events', 'clearnetEvents'],
['Projects', 'clearnetProjects'],
['Blogs', 'clearnetPosts'],
['Audios', 'clearnetAudios'],
['Videos', 'clearnetVideos'],
['Images', 'clearnetImages'],
['Documents', 'clearnetDocuments'],
['Torrents', 'clearnetTorrents']
];
const active = prefs ? modules.filter(([_, k]) => prefs[k] === true).map(([label]) => label) : [];
try {
const { setClearnetModules } = require('../server/ssb_metadata');
setClearnetModules(active);
} catch (_) {}
} catch (_) {
try {
const { setClearnetModules } = require('../server/ssb_metadata');
setClearnetModules([]);
} catch (_) {}
}
}
setTimeout(() => { logClearnetStatus(); }, 8000);
app._close = () => {
if (pubEngineTimer) clearInterval(pubEngineTimer);
nameWarmup.close();
cooler.close();
};
module.exports = app;
if (config.open === true && process.env.OASIS_MOBILE !== '1') open(url);