#!/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'); const { fieldsForSnippet, buildContext, clip, publishExchange, getBestTrainedAnswer } = require('../AI/buildAIContext.js'); let aiStarted = false; function startAI() { if (aiStarted) return; aiStarted = true; const aiProcess = spawn('node', [path.resolve(__dirname, '../AI/ai_service.mjs')], { detached: true, stdio: 'ignore' }); aiProcess.unref(); } 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(() => {}).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 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 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 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) { 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 { 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(); 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 }); const models = require("../models/main_models"); const { about, blob, friend, meta, post, vote } = 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 legacyModel = require('../models/legacy_model'); const walletModel = require('../models/wallet_model') const pmModel = require('../models/pm_model')({ cooler, isPublic: config.public }); const bookmarksModel = require("../models/bookmarking_model")({ cooler, isPublic: config.public }); const opinionsModel = require('../models/opinions_model')({ cooler, isPublic: config.public }); const eventsModel = require('../models/events_model')({ cooler, isPublic: config.public }); const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.public }); const votesModel = require('../models/votes_model')({ cooler, isPublic: config.public }); const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public }); const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public }); const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public }); 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 videosModel = require("../models/videos_model")({ cooler, isPublic: config.public }); const documentsModel = require("../models/documents_model")({ cooler, isPublic: config.public }); const agendaModel = require("../models/agenda_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 }); const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public }); const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public }); const searchModel = require('../models/search_model')({ cooler, isPublic: config.public }); const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public }); const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public }); const marketModel = require('../models/market_model')({ cooler, isPublic: config.public }); const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public }); const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public }); const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public }); const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public }); const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public }); const favoritesModel = require("../models/favorites_model")({services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel }); const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } }); const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel } }); 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 withCount = (item, comments) => ({ ...item, commentCount: comments.length }); 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) }; const mediaModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod' }; const favAction = async (ctx, kind, action) => { if (!checkMod(ctx, mediaModCheck[kind])) { ctx.redirect('/modules'); return; } const rootId = await mediaResolvers[kind](ctx.params.id); await mediaFavorites[action + 'Favorite'](kind, rootId); 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 }; const deleteModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel }; 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); 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 } = ctx.request.body; await mediaCreateModels[kind][`create${kind.charAt(0).toUpperCase()}${kind.slice(1, -1)}`](blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description)); 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 } = 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)); 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); about._startNameWarmup(); async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) { if (!text) return ''; const mentionByFeed = {}; Object.values(mentions).forEach(arr => { arr.forEach(m => { mentionByFeed[m.feed] = m; }); }); text = text.replace(/\[@([^\]]+)\]\(([^)]+)\)/g, (_, name, id) => { return `@${name}`; }); 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], `@${resolvedName}`); } return word; }) )).join(' '); text = text .replace(/!\[image:[^\]]+\]\(([^)]+)\)/g, (_, id) => `image`) .replace(/\[audio:[^\]]+\]\(([^)]+)\)/g, (_, id) => ``) .replace(/\[video:[^\]]+\]\(([^)]+)\)/g, (_, id) => ``) .replace(/\[pdf:([^\]]*)\]\(([^)]+)\)/g, (_, name, id) => { const { i18n } = require("../views/main_views"); return `${name || (i18n && i18n.pdfFallbackLabel) || 'PDF'}`; }); 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 || "") 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 = //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, "
") } return { authorMeta, text, formattedText, mentions, contentWarning } } const megabyte = Math.pow(2, 20); const maxSize = 50 * megabyte; const homeDir = os.homedir(); const blobsPath = path.join(homeDir, '.ssb', 'blobs', 'tmp'); 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 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 } = 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 { 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 } = require("../views/audio_view"); const { eventView, singleEventView } = 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 { opinionsView } = require("../views/opinions_view"); const { peersView } = require("../views/peers_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 { settingsView } = require("../views/settings_view"); const { trendingView } = require("../views/trending_view"); const { marketView, singleMarketView } = require("../views/market_view"); const { aiView } = require("../views/AI_view"); const { forumView, singleForumView } = require("../views/forum_view"); const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view"); const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view"); const { projectsView, singleProjectView } = require("../views/projects_view") const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views") const { favoritesView } = require("../views/favorites_view"); 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) { ctx.status = 400; ctx.body = `${label} too long (max ${max})`; return true; } return false; }; 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(); 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 }; 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); 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 })); 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]))))))) }); }) .get("/modules", async (ctx) => { const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', '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('/pixelia', async (ctx) => { if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; } const pixelArt = await pixeliaModel.listPixels(); ctx.body = pixeliaView(pixelArt); }) .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); ctx.body = renderBlockchainView(blockchainData, filter, userId, search); }) .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; const block = await blockchainModel.getBlockById(blockId); const viewMode = query.view || 'block'; ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode); }) .get("/public/latest", async (ctx) => { if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; } const messages = sanitizeMessages(await post.latest()); ctx.body = await latestView({ messages }); }) .get("/public/latest/extended", async (ctx) => { if (!checkMod(ctx, 'extendedMod')) { ctx.redirect('/modules'); return; } const messages = sanitizeMessages(await post.latestExtended()); ctx.body = await extendedView({ messages }); }) .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)); ctx.body = await topicsView({ messages, prefix }); }) .get("/public/latest/summaries", async (ctx) => { if (!checkMod(ctx, 'summariesMod')) { ctx.redirect('/modules'); return; } const messages = sanitizeMessages(await post.latestSummaries()); ctx.body = await summaryView({ messages }); }) .get("/public/latest/threads", async (ctx) => { if (!checkMod(ctx, 'threadsMod')) { ctx.redirect('/modules'); return; } const messages = sanitizeMessages(await post.latestThreads()); ctx.body = await threadsView({ messages }); }) .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 [description, name, image, messages, firstPost, lastPost, relationship, ecoAddress, bankData] = 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), bankingModel.getUserAddress(feedId), bankingModel.getBankingData(feedId) ]); const sanitizedMsgs = sanitizeMessages(messages); const normTs = t => { const n = Number(t || 0); return !isFinite(n) || n <= 0 ? 0 : n < 1e12 ? n * 1000 : n; }; const pull = require('../server/node_modules/pull-stream'), ssb = await require('../client/gui')({ offline: require('../server/ssb_config').offline }).open(); const latestFromStream = await new Promise(res => pull(ssb.createUserStream({ id: feedId, reverse: true }), pull.filter(m => m?.value?.content?.type !== 'tombstone'), pull.take(1), pull.collect((err, arr) => res(!err && arr?.[0] ? normTs(arr[0].value?.timestamp || arr[0].timestamp) : 0)))); const days = latestFromStream ? (Date.now() - latestFromStream) / 86400000 : Infinity; ctx.body = await authorView({ feedId, messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, lastActivityBucket: days < 14 ? 'green' : days < 182.5 ? 'orange' : 'red' }); }) .get("/search", async (ctx) => { const query = ctx.query.query || ''; if (!query) return ctx.body = await searchView({ messages: [], query, types: [] }); const results = await searchModel.search({ query, types: [] }); ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => { acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' }); return acc; }, {}), query, types: [] }); }) .get("/images", async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const { filter = 'all', q = '', sort = 'recent' } = ctx.query; 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); await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); ctx.body = await imageView(enriched, filter, null, { q, sort }); }) .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); 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']) }); }) .get("/audios", async (ctx) => { if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } const { filter = 'all', q = '', sort = 'recent' } = ctx.query; 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); await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); ctx.body = await audioView(enriched, filter, null, { q, sort }); }) .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); 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']) }); }) .get("/videos", async (ctx) => { if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; } const { filter = 'all', q = '', sort = 'recent' } = ctx.query; 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); await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); ctx.body = await videoView(enriched, filter, null, { q, sort }); }) .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); 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']) }); }) .get("/documents", async (ctx) => { const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const items = await documentsModel.listAll({ filter, q, sort }); await Promise.all(items.map(async x => { x.commentCount = (await getVoteComments(x.rootId || x.key)).length; })); ctx.body = await documentView(items, filter, null, { q, sort }); }) .get("/documents/edit/:id", async (ctx) => { const doc = await documentsModel.getDocumentById(ctx.params.id); ctx.body = await documentView([doc], '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 comments = await getVoteComments(document.rootId || 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"]) }); }) .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 = '' } = 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); }) .get('/inbox', async ctx => { if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; } const messages = sanitizeMessages(await pmModel.listAllPrivate()); await refreshInboxCount(messages); ctx.body = await privateView({ messages }, ctx.query.filter || undefined); }) .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), reports = await enrichWithComments(await reportsModel.listAll()); ctx.body = await reportView(reports, filter, null, ctx.query.category || ''); }) .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); ctx.body = await singleReportView(withCount(report, comments), filter, comments); }) .get('/trending', async (ctx) => { const filter = qf(ctx, 'RECENT'), { filtered = [] } = await trendingModel.listTrending(filter); ctx.body = await trendingView(filtered, filter, trendingModel.categories); }) .get('/agenda', async (ctx) => { const filter = qf(ctx), data = await agendaModel.listAgenda(filter); 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 (['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 }; } catch { return { id: u.id, karmaScore: 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 addrMap = new Map(addresses.map(x => [x.id, x.address])); const karmaMap = new Map(karmaList.map(x => [x.id, x.karmaScore])); const activityMap = new Map(activityList.map(x => [x.id, x.lastActivityBucket])); let enriched = inhabitants.map(u => ({ ...u, ecoAddress: addrMap.get(u.id) || null, karmaScore: karmaMap.get(u.id) ?? (typeof u.karmaScore === 'number' ? u.karmaScore : 0), lastActivityBucket: activityMap.get(u.id) })); 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); }) .get('/inhabitant/:id', async (ctx) => { const id = ctx.params.id; const [about, cv, feed, photo, bank, lastTs] = 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) ]); const bucketInfo = inhabitantsModel.bucketLastActivity(lastTs || null); const currentUserId = getViewerId(); const karmaScore = bank && typeof bank.karmaScore === 'number' ? bank.karmaScore : 0; ctx.body = await inhabitantsProfileView({ about, cv, feed, photo, karmaScore, lastActivityBucket: bucketInfo.bucket, viewedId: id }, currentUserId); }) .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; const governmentCard = governmentCardRaw ? { ...governmentCardRaw, inhabitantsTotal } : null; 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'); 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 }; 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 filter = qf(ctx), search = ctx.query.search || '', tribes = await tribesModel.listAll(); const filteredTribes = search ? tribes.filter(t => t.title.toLowerCase().includes(search.toLowerCase())) : tribes; 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; } 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); const uid = getViewerId(); const query = { feedFilter: 'TOP', ...ctx.query }; if (!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); } 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); 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 combined = [...allContent, ...subContent]; 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 tagMap = new Map(); for (const item of allContent) { for (const tag of (item.tags || []).filter(Boolean)) { const lower = 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 === '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 }; } const subTribes = await tribesModel.listSubTribes(tribe.id); 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); } 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 (_) {} const allActions = await activityModel.listFeed('all'); ctx.body = activityView(allActions, filter, userId, q); }) .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 [description, name, image, messages, firstPost, lastPost, ecoAddress, bankData] = await Promise.all([ about.description(myFeedId), about.name(myFeedId), about.image(myFeedId), post.fromPublicFeed(myFeedId, gt, lt), post.firstBy(myFeedId), post.latestBy(myFeedId), bankingModel.getUserAddress(myFeedId), bankingModel.getBankingData(myFeedId) ]); 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); }; let lastActivityTs = Math.max(Array.isArray(messages) && messages.length ? Math.max(...messages.map(pickTs)) : 0, pickTs(lastPost), pickTs(firstPost)); if (!lastActivityTs) { const pull = require("../server/node_modules/pull-stream"), ssb = await require("../client/gui")({ offline: require("../server/ssb_config").offline }).open(); lastActivityTs = await new Promise(res => pull(ssb.createUserStream({ id: myFeedId, reverse: true }), pull.filter(m => m?.value?.content?.type !== "tombstone"), pull.take(1), pull.collect((err, arr) => res(!err && arr?.[0] ? normTs(arr[0].value?.timestamp || arr[0].timestamp) : 0)))); } const days = lastActivityTs ? (Date.now() - lastActivityTs) / 86400000 : Infinity; ctx.body = await authorView({ feedId: myFeedId, messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, lastActivityBucket: days < 14 ? "green" : days < 182.5 ? "orange" : "red" }); }) .get("/profile/edit", async (ctx) => { const myFeedId = await meta.myFeedId(); ctx.body = await editProfileView({ name: await about.name(myFeedId), description: await about.description(myFeedId) }); }) .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; await post.publishProfileEdit({ name: stripDangerousTags(String(ctx.request.body?.name || '')), description: stripDangerousTags(String(ctx.request.body?.description || '')), image: imageData }); 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("/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 || "", pubWalletUrl: cfg.walletPub?.url || '', pubWalletUser: cfg.walletPub?.user || '', pubWalletPass: cfg.walletPub?.pass || '' }); }) .get("/peers", async (ctx) => { const { discoveredPeers, unknownPeers } = await meta.discovered(); ctx.body = await peersView({ onlinePeers: await meta.onlinePeers(), discoveredPeers, unknownPeers }); }) .get("/invites", async (ctx) => { if (!checkMod(ctx, 'invitesMod')) return ctx.redirect('/modules'); ctx.body = await invitesView({}); }) .get("/likes/:feed", async (ctx) => { const { feed } = ctx.params; ctx.body = await likesView({ messages: await post.likes({ feed }), feed, name: await about.name(feed) }); }) .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 }); }) .get('/opinions', async (ctx) => { const filter = qf(ctx, 'RECENT'), opinions = await opinionsModel.listOpinions(filter); ctx.body = await opinionsView(opinions, filter); }) .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 : ""; const feeds = await feedModel.listFeeds({ filter, q, tag }); 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('/forum', async ctx => { if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } const filter = qf(ctx, 'recent'), forums = await forumModel.listAll(filter); ctx.body = await forumView(forums, filter); }) .get('/forum/:forumId', async ctx => { const msg = await forumModel.getMessageById(ctx.params.forumId), isReply = Boolean(msg.root), forumId = isReply ? msg.root : ctx.params.forumId; ctx.body = await singleForumView(await forumModel.getForumById(forumId), await forumModel.getMessagesByForumId(forumId), ctx.query.filter, isReply ? ctx.params.forumId : null); }) .get('/legacy', async (ctx) => { if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules'); try { ctx.body = await legacyView(); } catch (error) { ctx.body = { error: error.message }; } }) .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); await enrichWithComments(bookmarks, 'rootId'); ctx.body = await bookmarkView(bookmarks, filter, null, { q, sort }); }) .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.getFavoritesSet("bookmarks"); ctx.body = await bookmarkView([{ ...bookmark, isFav: 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); 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']) }); }) .get('/tasks', async ctx => { const filter = qf(ctx), tasks = await enrichWithComments(await tasksModel.listAll()); ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo); }) .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); ctx.body = await singleTaskView(withCount(task, comments), filter, comments); }) .get('/events', async (ctx) => { if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } const filter = qf(ctx), events = await enrichWithComments(await eventsModel.listAll(null, filter)); ctx.body = await eventView(events, filter, null, ctx.query.returnTo); }) .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 => { const { eventId } = ctx.params, filter = qf(ctx), event = await eventsModel.getEventById(eventId); const comments = await getVoteComments(eventId); ctx.body = await singleEventView(withCount(event, comments), filter, comments); }) .get('/votes', async ctx => { const filter = qf(ctx), voteList = await enrichWithComments(await votesModel.listAll(filter)); ctx.body = await voteView(voteList, filter, null, [], filter); }) .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); ctx.body = await voteView([withCount(voteData, comments)], 'detail', voteId, comments, filter); }) .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); ctx.body = await marketView(marketItems, filter, null, { q, minPrice, maxPrice, sort }); }) .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 comments = await getVoteComments(itemId) 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("&")}` : ""}` })() ctx.body = await singleMarketView(withCount(item, comments), filter, comments, { q, minPrice, maxPrice, sort, returnTo }) }) .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 query = { search: ctx.query.search || '', minSalary: ctx.query.minSalary ?? '', maxSalary: ctx.query.maxSalary ?? '', sort: ctx.query.sort || 'recent' } 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() const jobs = await jobsModel.listJobs(filter, viewerId, query) await enrichWithComments(jobs) ctx.body = await jobsView(jobs, filter, query) }) .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; } const jobId = ctx.params.jobId 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']) } const job = await jobsModel.getJobById(jobId, viewerId) const comments = await getVoteComments(jobId) ctx.body = await singleJobsView(withCount(job, comments), filter, comments, params) }) .get("/projects", async (ctx) => { if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } const filter = String(ctx.query.filter || "ALL").toUpperCase() if (filter === "CREATE") { ctx.body = await projectsView([], "CREATE") return } const modelFilter = filter === "BACKERS" ? "ALL" : filter const projects = await projectsModel.listProjects(modelFilter) await enrichWithComments(projects) ctx.body = await projectsView(projects, filter) }) .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 comments = await getVoteComments(projectId) ctx.body = await singleProjectView(withCount(project, comments), filter, comments) }) .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); 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, ecoInHours, currentSupply, isSynced } = await bankingModel.calculateEcoinValue(); data.exchange = { ecoValue: ecoValue, inflationFactor, ecoInHours, currentSupply: currentSupply, totalSupply: 25500000, isSynced: isSynced }; ctx.body = renderBankingView(data, filter, userId); }) .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('/cipher', async (ctx) => { if (!checkMod(ctx, 'cipherMod')) { ctx.redirect('/modules'); return; } try { ctx.body = await cipherView(); } catch (error) { ctx.body = { error: error.message }; } }) .get("/thread/:message", async (ctx) => { const { message } = ctx.params; const thread = async (message) => { const messages = await post.fromThread(message); return threadView({ messages }); }; 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]; ctx.body = await subtopicView({ messages, myFeedId }); }) .get("/publish", async (ctx) => { ctx.body = await publishView(); }) .get("/comment/:message", async (ctx) => { const { messages, myFeedId, parentMessage } = await resolveCommentComponents(ctx); ctx.body = await commentView({ messages, myFeedId, parentMessage }); }) .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'; const list = await transfersModel.listAll(filter, getViewerId()); ctx.body = await transferView(list, filter, null, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent' }); }) .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/: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()); 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']) }); }) .post('/ai', koaBody(), async (ctx) => { const { input } = ctx.request.body; if (!input) { ctx.status = 400; ctx.body = { error: 'No input provided' }; 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 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: {} }); item.trainStatus = 'approved'; } 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/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); }) .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 } = ctx.request.body; const recipientsArr = (recipients || '').split(',').map(s => s.trim()).filter(Boolean); await pmModel.sendMessage(recipientsArr, subject, text); await refreshInboxCount(); ctx.redirect('/inbox?filter=sent'); }) .post('/pm/preview', koaBody(), async ctx => { const { recipients = '', subject = '', text = '' } = ctx.request.body; ctx.body = await pmView(recipients, subject, text, true); }) .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 results = await searchModel.search({ query, types }); ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => { acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' }); 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); } await post.root({ text, mentions, contentWarning: cw.length > 0 ? cw : undefined }); ctx.redirect("/public/latest"); }) .post("/publish/custom", koaBody(), async (ctx) => { const text = String(ctx.request.body.text); const obj = JSON.parse(text); ctx.body = await post.publishCustom(obj); ctx.redirect(`/public/latest`); }) .post("/follow/:feed", koaBody(), async (ctx) => { ctx.body = await friend.follow(ctx.params.feed); ctx.redirect(new URL(ctx.request.header.referer).href); }) .post("/unfollow/:feed", koaBody(), async (ctx) => { ctx.body = await friend.unfollow(ctx.params.feed); ctx.redirect(new URL(ctx.request.header.referer).href); }) .post("/block/:feed", koaBody(), async (ctx) => { ctx.body = await friend.block(ctx.params.feed); ctx.redirect(new URL(ctx.request.header.referer).href); }) .post("/unblock/:feed", koaBody(), async (ctx) => { ctx.body = await friend.unblock(ctx.params.feed); ctx.redirect(new URL(ctx.request.header.referer).href); }) .post("/like/:message", koaBody(), async (ctx) => { const { message } = ctx.params, voteValue = Number(ctx.request.body.voteValue); const referer = new URL(ctx.request.header.referer); referer.hash = `centered-footer-${encodeURIComponent(message)}`; 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(referer.href); }) .post('/forum/create', koaBody(), async ctx => { const { category, title, text } = ctx.request.body; await forumModel.createForum(category, stripDangerousTags(title), stripDangerousTags(text)); ctx.redirect('/forum'); }) .post('/forum/:id/message', koaBody(), async ctx => { const { message, parentId } = ctx.request.body; const cleanedMsg = stripDangerousTags(message); 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 }, 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 => { await forumModel.deleteForumById(ctx.params.id); ctx.redirect('/forum'); }) .post('/legacy/export', koaBody(), async (ctx) => { const pw = ctx.request.body.password; if (!pw || pw.length < 32) return ctx.redirect('/legacy'); try { ctx.body = { message: 'Data exported successfully!', file: await legacyModel.exportData({ password: pw }) }; ctx.redirect('/legacy'); } catch (error) { ctx.status = 500; ctx.body = { error: `Error: ${error.message}` }; ctx.redirect('/legacy'); } }) .post('/legacy/import', koaBody({ multipart: true, formidable: { keepExtensions: true, uploadDir: '/tmp', } }), 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 */ } ctx.redirect(ctx.get("Referer") || "/feed"); }) .post("/feed/refeed/:id", koaBody(), async (ctx) => { await feedModel.createRefeed(ctx.params.id); 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)); 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)); 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("/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("/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('/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'); await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode); 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'); await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode || 'open', ctx.params.id); 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) : []; await tribesModel.updateTribeById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image'), location: stripDangerousTags(b.location), tags, isLARP: b.isLARP === 'true', isAnonymous: b.isAnonymous === 'true', inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' }); 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('/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) => { const { exec } = require('child_process'); try { await panicmodeModel.removeSSB(); ctx.body = { message: 'Your blockchain has been succesfully deleted!' }; exec('pkill -f "node SSB_server.js start"'); setTimeout(() => process.exit(0), 1000); } catch (error) { ctx.body = { error: 'Error deleting your blockchain: ' + error.message }; } }) .post('/export/create', async (ctx) => { 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) { ctx.body = { error: '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: b.title, description: b.description, category: b.category, image, tags: b.tags, severity: b.severity, template: { stepsToReproduce: b.stepsToReproduce, expectedBehavior: b.expectedBehavior, actualBehavior: b.actualBehavior, environment: b.environment, reproduceRate: b.reproduceRate, problemStatement: b.problemStatement, userStory: b.userStory, acceptanceCriteria: b.acceptanceCriteria, whatHappened: b.whatHappened, reportedUser: b.reportedUser, evidenceLinks: b.evidenceLinks, contentLocation: b.contentLocation, whyInappropriate: b.whyInappropriate, requestedAction: 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; await eventsModel.createEvent(stripDangerousTags(b.title), desc, b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic); 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 }); 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/: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." }; } ctx.redirect(safeReturnTo(ctx, '/votes', ['/votes'])); }) .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('/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) => { 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/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 = ctx.state?.user?.id; 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 = ctx.state?.user?.id; 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 = ctx.state?.user?.id; 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); 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 }; 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/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 (item.item_type === "exchange" && item.status !== "SOLD") { await pmModel.sendMessage([item.seller], "MARKET_SOLD", `item "${item.title}" has been sold -> /market/${ctx.params.id} OASIS ID: ${getViewerId()} for: ${item.price} ECO`); await marketModel.setItemAsSold(ctx.params.id); } else await marketModel.decrementStock(ctx.params.id); 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: b.job_type, title: b.title, description: b.description, requirements: b.requirements, languages: b.languages, job_time: b.job_time, tasks: b.tasks, location: b.location, vacants: b.vacants ? parseInt(b.vacants, 10) : 1, salary: b.salary != null && b.salary !== '' ? parseFloat(String(b.salary).replace(',', '.')) : 0, tags: b.tags, image: imageBlob }); 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: b.job_type, title: b.title, description: b.description, requirements: b.requirements, languages: b.languages, job_time: b.job_time, tasks: b.tasks, location: b.location, tags: b.tags }; 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 (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/subscribe/:id', koaBody(), async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } const userId = getViewerId(), job = await jobsModel.getJobById(ctx.params.id, userId); await jobsModel.subscribeToJob(ctx.params.id, userId); await pmModel.sendMessage([job.author], 'JOB_SUBSCRIBED', `has subscribed to your job offer "${job.title || ''}" -> /jobs/${encodeURIComponent(job.id)}`); ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); }) .post('/jobs/unsubscribe/:id', koaBody(), async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } const userId = getViewerId(), job = await jobsModel.getJobById(ctx.params.id, userId); await jobsModel.unsubscribeFromJob(ctx.params.id, userId); await pmModel.sendMessage([job.author], 'JOB_UNSUBSCRIBED', `has unsubscribed from your job offer "${job.title || ''}" -> /jobs/${encodeURIComponent(job.id)}`); ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); }) .post('/jobs/:jobId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'jobs', 'jobId')) .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 }); 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 }); 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}`]); const backers = [...(project.backers || []), { userId: uid, amount: pledgeAmount, at: new Date().toISOString(), transferId: transfer.key || transfer.id, confirmed: false, milestoneIndex, bountyIndex }]; const pledged = (parseFloat(project.pledged || 0) || 0) + pledgeAmount; await projectsModel.updateProject(latestId, { backers, pledged, progress: project.goal ? (pledged / parseFloat(project.goal)) * 100 : 0 }); 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); const project = await projectsModel.getProjectById(projectId), backers = [...(project.backers || [])]; const idx = backers.findIndex(b => b?.transferId === ctx.params.id); if (idx !== -1) backers[idx].confirmed = true; await projectsModel.updateProject(projectId, { backers, progress: project.goal ? (parseFloat(project.pledged || 0) / parseFloat(project.goal)) * 100 : 0 }); 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/:id", koaBody(), async (ctx) => { const userId = getViewerId(), allocation = await bankingModel.getAllocationById(ctx.params.id); if (!allocation) { ctx.body = { error: i18n.errorNoAllocation }; return; } if (allocation.to !== userId || allocation.status !== "UNCONFIRMED") { ctx.body = { error: i18n.errorInvalidClaim }; return; } const { url, user, pass } = getConfig().walletPub; const { txid } = await bankingModel.claimAllocation({ transferId: ctx.params.id, claimerId: userId, pubWalletUrl: url, pubWalletUser: user, pubWalletPass: pass }); await bankingModel.updateAllocationStatus(ctx.params.id, "CLOSED", txid); await bankingModel.publishBankClaim({ amount: allocation.amount, epochId: allocation.epochId, allocationId: allocation.id, txid }); ctx.redirect(`/banking?claimed=${encodeURIComponent(txid)}`); }) .post("/banking/simulate", koaBody(), async (ctx) => { const { epochId, rules } = ctx.request.body || {}; ctx.body = await bankingModel.computeEpoch({ epochId, rules }); }) .post("/banking/run", koaBody(), async (ctx) => { const { epochId, rules } = ctx.request.body || {}; ctx.body = await bankingModel.executeEpoch({ epochId, rules }); }) .post("/banking/addresses", koaBody(), async (ctx) => { const b = ctx.request.body || {}, res = await bankingModel.addAddress({ userId: (b.userId || "").trim(), address: (b.address || "").trim() }); ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`); }) .post("/banking/addresses/delete", koaBody(), async (ctx) => { const res = await bankingModel.removeAddress({ userId: ((ctx.request.body?.userId) || "").trim() }); 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) => { const exec = require("node:util").promisify(require("node:child_process").exec); const { stdout, stderr } = await exec("git reset --hard && git pull"); console.log("oasis@version: updating Oasis...", stdout, stderr); const { stdout: shOut, stderr: shErr } = await exec("sh install.sh"); console.log("oasis@version: running install.sh...", shOut, shErr); ctx.redirect(new URL(ctx.request.header.referer).href); }) .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' }); 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' }); ctx.redirect(new URL(ctx.request.header.referer).href); }) .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 => { await meta.acceptInvite(String(ctx.request.body.invite)); ctx.redirect("/invites"); }) .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) return ctx.redirect("/peers?err=missing"); 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) return ctx.redirect("/peers?err=invalidHost"); if (isIPv4 && hostStr.split('.').some(o => Number(o) > 255)) return ctx.redirect("/peers?err=invalidHost"); const prt = Number(port) || 8008; if (!Number.isInteger(prt) || prt < 1 || prt > 65535) return ctx.redirect("/peers?err=invalidPort"); const keyStr = String(key).trim(); if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(keyStr)) return ctx.redirect("/peers?err=invalidKey"); 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 {} try { await new Promise(res => ssb.conn.connect(addr, { type: "peer" }, 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("/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/home-page", koaBody(), async (ctx) => { const cfg = getConfig(); cfg.homePage = String(ctx.request.body.homePage || "").trim() || "activity"; saveConfig(cfg); ctx.redirect("/settings"); }) .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); }) .post("/modules/preset", koaBody(), async (ctx) => { const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', 'banking', 'parliament', 'courts']; const PRESETS = { minimal: ['feed', 'forum', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'], social: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'], economy: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs'], 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) => { const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', 'banking', 'parliament', 'courts']; 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) { ctx.status = 400; ctx.body = "Prompt too long. Must be 128 characters or fewer."; return; } const cfg = getConfig(); cfg.ai = { ...(cfg.ai || {}), prompt: aiPrompt }; saveConfig(cfg); ctx.redirect("/settings"); }) .post("/settings/pub-wallet", koaBody(), async (ctx) => { const b = ctx.request.body, cfg = getConfig(); cfg.walletPub = { url: String(b.wallet_url || "").trim(), user: String(b.wallet_user || "").trim(), pass: String(b.wallet_pass || "").trim() }; fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); 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); 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); 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) => { 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) => { 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") 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."); await next(); }, async (ctx, next) => { setLanguage(ctx.cookies.get("language") || getConfig().language || "en"); await next(); }, async (ctx, next) => { 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) { if (err.name === 'FileTooLargeError' || (err.message && err.message.includes('maxFileSize'))) { const { template, i18n } = require('../views/main_views'); const referer = ctx.get('referer') || '/'; ctx.status = 413; ctx.body = template( i18n.fileTooLargeTitle, section( div({ class: 'tags-header' }, h2(i18n.fileTooLargeTitle), p(i18n.fileTooLargeMessage), p(a({ href: referer, class: 'filter-btn', style: 'display:inline-block;text-decoration:none;margin-top:16px;' }, i18n.goBack)) ) ) ); } else { ctx.status = err.status || 500; ctx.body = { message: err.message || 'Internal Server Error' }; } } } }, async (ctx, next) => { if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/')) { const now = Date.now(); if (now - sharedState.getLastRefresh() > 60000) { sharedState.setLastRefresh(now); 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 (_) {} } } await next(); }, routes, ]; const app = http({ host, port, middleware, allowHost: config.allowHost }); app._close = () => { nameWarmup.close(); cooler.close(); }; module.exports = app; if (config.open === true) open(url);