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.
8982 lines
485 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
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);
|