backend.js 189 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268
  1. #!/usr/bin/env node
  2. "use strict";
  3. const path = require("path");
  4. const fs = require("fs");
  5. const promisesFs = fs.promises;
  6. const os = require('os');
  7. const envPaths = require("../server/node_modules/env-paths");
  8. const {cli} = require("../client/oasis_client");
  9. const SSBconfig = require('../server/SSB_server.js');
  10. const moment = require('../server/node_modules/moment');
  11. const FileType = require('../server/node_modules/file-type');
  12. const ssbRef = require("../server/node_modules/ssb-ref");
  13. const defaultConfig = {};
  14. const defaultConfigFile = path.join(envPaths("oasis", { suffix: "" }).config, "/default.json");
  15. let haveConfig = false;
  16. try {
  17. Object.assign(defaultConfig, JSON.parse(fs.readFileSync(defaultConfigFile, "utf8")));
  18. haveConfig = true;
  19. } catch (e) { if (e.code !== "ENOENT") { console.log(`Problem loading ${defaultConfigFile}`); throw e; } }
  20. const config = cli(defaultConfig, defaultConfigFile);
  21. if (config.debug) {
  22. process.env.DEBUG = "oasis,oasis:*";
  23. }
  24. const axiosMod = require('../server/node_modules/axios');
  25. const axios = axiosMod.default || axiosMod;
  26. const { spawn } = require('child_process');
  27. const { fieldsForSnippet, buildContext, clip, publishExchange, getBestTrainedAnswer } = require('../AI/buildAIContext.js');
  28. let aiStarted = false;
  29. function startAI() {
  30. if (aiStarted) return;
  31. aiStarted = true;
  32. const aiProcess = spawn('node', [path.resolve(__dirname, '../AI/ai_service.mjs')], { detached: true, stdio: 'ignore' });
  33. aiProcess.unref();
  34. }
  35. const ADDR_PATH = path.join(__dirname, '..', 'configs', 'wallet-addresses.json');
  36. const readAddrMap = () => { try { return JSON.parse(fs.readFileSync(ADDR_PATH, 'utf8')); } catch { return {}; } };
  37. const writeAddrMap = (map) => { fs.mkdirSync(path.dirname(ADDR_PATH), { recursive: true }); fs.writeFileSync(ADDR_PATH, JSON.stringify(map, null, 2)); };
  38. //parliament model
  39. let electionInFlight = null;
  40. const ensureTerm = async () => {
  41. const cur = await parliamentModel.getCurrentTerm().catch(() => null);
  42. if (cur) return cur;
  43. if (electionInFlight) return electionInFlight;
  44. electionInFlight = parliamentModel.resolveElection().catch(() => null).finally(() => { electionInFlight = null; });
  45. return electionInFlight;
  46. };
  47. let sweepInFlight = null;
  48. const runSweepOnce = async () => {
  49. if (sweepInFlight) return sweepInFlight;
  50. sweepInFlight = parliamentModel.sweepProposals().catch(() => {}).finally(() => { sweepInFlight = null; });
  51. return sweepInFlight;
  52. };
  53. async function buildState(filter) {
  54. const f = (filter || 'government').toLowerCase();
  55. await ensureTerm();
  56. await runSweepOnce();
  57. const [govCard, candidatures, proposals, canPropose, laws, historical] = await Promise.all([
  58. parliamentModel.getGovernmentCard(),
  59. parliamentModel.listCandidatures('OPEN'),
  60. parliamentModel.listProposalsCurrent(),
  61. parliamentModel.canPropose(),
  62. parliamentModel.listLaws(),
  63. parliamentModel.listHistorical()
  64. ]);
  65. return { filter: f, governmentCard: govCard, candidatures, proposals, canPropose, laws, historical };
  66. }
  67. function pickLeader(cands = []) {
  68. if (!cands.length) return null;
  69. return [...cands].sort((a, b) => {
  70. const d = (x, y) => y - x;
  71. return d(Number(a.votes||0), Number(b.votes||0)) || d(Number(a.karma||0), Number(b.karma||0)) ||
  72. (Number(a.profileSince||0) - Number(b.profileSince||0)) ||
  73. (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) ||
  74. String(a.targetId).localeCompare(String(b.targetId));
  75. })[0];
  76. }
  77. async function buildLeaderMeta(leader) {
  78. if (!leader) return null;
  79. if (leader.targetType === 'inhabitant') {
  80. let name = null, image = null, description = null;
  81. try { name = about?.name && await about.name(leader.targetId); } catch {}
  82. try { image = about?.image && await about.image(leader.targetId); } catch {}
  83. try { description = about?.description && await about.description(leader.targetId); } catch {}
  84. const imgId = typeof image === 'string' ? image : image?.link || image?.url || null;
  85. return { isTribe: false, name: name || leader.targetId, avatarUrl: imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-avatar.png', bio: typeof description === 'string' ? description : '' };
  86. }
  87. let tribe = null;
  88. try { tribe = await tribesModel.getTribeById(leader.targetId); } catch {}
  89. const imgId = tribe?.image || null;
  90. 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 || '' };
  91. }
  92. const safeArr = v => Array.isArray(v) ? v : [];
  93. const safeText = v => String(v || '').trim();
  94. 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; };
  95. // anti-injections
  96. const { stripDangerousTags, sanitizeHtml } = require('./sanitizeHtml');
  97. const sharedState = require('../configs/shared-state');
  98. module.exports = stripDangerousTags;
  99. const sanitizeMsgText = (msg) => {
  100. if (!msg?.value?.content) return msg;
  101. const c = msg.value.content;
  102. if (typeof c.text === 'string') c.text = stripDangerousTags(c.text);
  103. if (typeof c.description === 'string') c.description = stripDangerousTags(c.description);
  104. if (typeof c.title === 'string') c.title = stripDangerousTags(c.title);
  105. if (typeof c.contentWarning === 'string') c.contentWarning = stripDangerousTags(c.contentWarning);
  106. return msg;
  107. };
  108. const sanitizeMessages = (msgs) => Array.isArray(msgs) ? msgs.map(sanitizeMsgText) : msgs;
  109. const parseBool01 = v => String(Array.isArray(v) ? v[v.length - 1] : v || '') === '1';
  110. const checkMod = (ctx, mod) => {
  111. const cfg = getConfig();
  112. const serverValue = cfg.modules?.[mod];
  113. if (serverValue === 'off') return false;
  114. const cookieValue = ctx.cookies.get(mod);
  115. if (cookieValue) return cookieValue === 'on';
  116. return serverValue === 'on' || serverValue === undefined;
  117. };
  118. const getViewerId = () => SSBconfig?.config?.keys?.id || SSBconfig?.keys?.id;
  119. const refreshInboxCount = async (messagesOpt) => {
  120. const messages = messagesOpt || await pmModel.listAllPrivate();
  121. const userId = getViewerId();
  122. const isToUser = m => Array.isArray(m?.value?.content?.to) && m.value.content.to.includes(userId);
  123. const filtered = messages.filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true);
  124. sharedState.setInboxCount(filtered.filter(isToUser).length);
  125. };
  126. const mediaFavorites = require("./media-favorites.js");
  127. const customStyleFile = path.join(envPaths("oasis", { suffix: "" }).config, "/custom-style.css");
  128. let haveCustomStyle = false;
  129. try { fs.readFileSync(customStyleFile, "utf8"); haveCustomStyle = true; } catch (e) { if (e.code !== "ENOENT") { console.log(`Problem loading ${customStyleFile}`); throw e; } }
  130. const { get } = require("node:http");
  131. const debug = require("../server/node_modules/debug")("oasis");
  132. const log = (formatter, ...args) => {
  133. const isDebugEnabled = debug.enabled;
  134. debug.enabled = true;
  135. debug(formatter, ...args);
  136. debug.enabled = isDebugEnabled;
  137. };
  138. delete config._;
  139. delete config.$0;
  140. const { host } = config;
  141. const { port } = config;
  142. const url = `http://${host}:${port}`;
  143. debug("Current configuration: %O", config);
  144. debug(`You can save the above to ${defaultConfigFile} to make \
  145. these settings the default. See the readme for details.`);
  146. const { saveConfig, getConfig } = require('../configs/config-manager');
  147. const configPath = path.join(__dirname, '../configs/oasis-config.json');
  148. const oasisCheckPath = "/.well-known/oasis";
  149. process.on("uncaughtException", function (err) {
  150. if (err["code"] === "EADDRINUSE") {
  151. get(url + oasisCheckPath, (res) => {
  152. let rawData = "";
  153. res.on("data", (chunk) => {
  154. rawData += chunk;
  155. });
  156. res.on("end", () => {
  157. log(rawData);
  158. if (rawData === "oasis") {
  159. log(`Oasis is already running on host ${host} and port ${port}`);
  160. if (config.open === true) {
  161. log("Opening link to existing instance of Oasis");
  162. open(url);
  163. } else {
  164. log(
  165. "Not opening your browser because opening is disabled by your config"
  166. );
  167. }
  168. process.exit(0);
  169. } else {
  170. throw new Error(`Another server is already running at ${url}.
  171. It might be another copy of Oasis or another program on your computer.
  172. You can run Oasis on a different port number with this option:
  173. oasis --port ${config.port + 1}
  174. Alternatively, you can set the default port in ${defaultConfigFile} with:
  175. {
  176. "port": ${config.port + 1}
  177. }
  178. `);
  179. }
  180. });
  181. });
  182. } else {
  183. console.log("");
  184. console.log("Oasis traceback (share below content with devs to report!):");
  185. console.log("===========================================================");
  186. console.log(err);
  187. console.log("");
  188. }
  189. });
  190. process.argv = [];
  191. const http = require("../client/middleware");
  192. const {koaBody} = require("../server/node_modules/koa-body");
  193. const { nav, ul, li, a, form, button, div, section, h2, p } = require("../server/node_modules/hyperaxe");
  194. const open = require("../server/node_modules/open");
  195. const pull = require("../server/node_modules/pull-stream");
  196. const koaRouter = require("../server/node_modules/@koa/router");
  197. const ssbMentions = require("../server/node_modules/ssb-mentions");
  198. const isSvg = require('../server/node_modules/is-svg');
  199. const { isFeed, isMsg, isBlob } = require("../server/node_modules/ssb-ref");
  200. const ssb = require("../client/gui");
  201. const router = new koaRouter();
  202. const extractMentions = async (text) => {
  203. const mentions = ssbMentions(text) || [];
  204. const resolvedMentions = await Promise.all(mentions.map(async (mention) => {
  205. const name = mention.name || await about.name(mention.link);
  206. return {
  207. link: mention.link,
  208. name: name || 'Anonymous',
  209. };
  210. }));
  211. return resolvedMentions;
  212. };
  213. const cooler = ssb({ offline: config.offline });
  214. const models = require("../models/main_models");
  215. const { about, blob, friend, meta, post, vote } = models({
  216. cooler,
  217. isPublic: config.public,
  218. });
  219. const { handleBlobUpload, serveBlob, FileTooLargeError } = require('../backend/blobHandler.js');
  220. const extractBlobId = (md) => md ? (md.match(/\((&[^)]+)\)/)?.[1] ?? null) : null;
  221. const exportmodeModel = require('../models/exportmode_model');
  222. const panicmodeModel = require('../models/panicmode_model');
  223. const cipherModel = require('../models/cipher_model');
  224. const legacyModel = require('../models/legacy_model');
  225. const walletModel = require('../models/wallet_model')
  226. const pmModel = require('../models/pm_model')({ cooler, isPublic: config.public });
  227. const bookmarksModel = require("../models/bookmarking_model")({ cooler, isPublic: config.public });
  228. const opinionsModel = require('../models/opinions_model')({ cooler, isPublic: config.public });
  229. const eventsModel = require('../models/events_model')({ cooler, isPublic: config.public });
  230. const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.public });
  231. const votesModel = require('../models/votes_model')({ cooler, isPublic: config.public });
  232. const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public });
  233. const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
  234. const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public });
  235. const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
  236. const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
  237. const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
  238. const imagesModel = require("../models/images_model")({ cooler, isPublic: config.public });
  239. const audiosModel = require("../models/audios_model")({ cooler, isPublic: config.public });
  240. const videosModel = require("../models/videos_model")({ cooler, isPublic: config.public });
  241. const documentsModel = require("../models/documents_model")({ cooler, isPublic: config.public });
  242. const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public });
  243. const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public });
  244. const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public });
  245. const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public });
  246. const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public });
  247. const searchModel = require('../models/search_model')({ cooler, isPublic: config.public });
  248. const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public });
  249. const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
  250. const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
  251. const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
  252. const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
  253. const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
  254. const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
  255. const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
  256. const favoritesModel = require("../models/favorites_model")({services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel });
  257. const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
  258. const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel } });
  259. const getVoteComments = async (voteId) => {
  260. const raw = await post.topicComments(voteId);
  261. return (raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId)
  262. .sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0));
  263. };
  264. const enrichWithComments = async (items, idKey = 'id') => {
  265. await Promise.all(items.map(async x => { x.commentCount = (await getVoteComments(x[idKey] || x.key || x.rootId)).length; }));
  266. return items;
  267. };
  268. const withCount = (item, comments) => ({ ...item, commentCount: comments.length });
  269. const mediaResolvers = {
  270. images: id => imagesModel.resolveRootId(id),
  271. audios: id => audiosModel.resolveRootId(id),
  272. videos: id => videosModel.resolveRootId(id),
  273. documents: id => documentsModel.resolveRootId(id),
  274. bookmarks: id => bookmarksModel.resolveRootId(id)
  275. };
  276. const mediaModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod' };
  277. const favAction = async (ctx, kind, action) => {
  278. if (!checkMod(ctx, mediaModCheck[kind])) { ctx.redirect('/modules'); return; }
  279. const rootId = await mediaResolvers[kind](ctx.params.id);
  280. await mediaFavorites[action + 'Favorite'](kind, rootId);
  281. ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`]));
  282. };
  283. const commentAction = async (ctx, kind, idParam) => {
  284. const modKey = mediaModCheck[kind];
  285. if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
  286. const itemId = ctx.params[idParam];
  287. let text = stripDangerousTags((ctx.request.body.text || '').trim());
  288. const rt = safeReturnTo(ctx, `/${kind}/${encodeURIComponent(itemId)}`, [`/${kind}`]);
  289. const blobMarkdown = await handleBlobUpload(ctx, 'blob');
  290. if (blobMarkdown) text += blobMarkdown;
  291. if (!text) { ctx.redirect(rt); return; }
  292. await post.publish({ text, root: itemId, dest: itemId });
  293. ctx.redirect(rt);
  294. };
  295. const opinionModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel };
  296. const deleteModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel };
  297. const opinionAction = async (ctx, kind, idParam) => {
  298. const modKey = mediaModCheck[kind];
  299. if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
  300. await opinionModels[kind].createOpinion(ctx.params[idParam], ctx.params.category);
  301. ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`]));
  302. };
  303. const deleteAction = async (ctx, kind, deleteFn = 'delete' + kind.charAt(0).toUpperCase() + kind.slice(1, -1) + 'ById') => {
  304. const modKey = mediaModCheck[kind];
  305. if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
  306. await deleteModels[kind][deleteFn](ctx.params.id);
  307. ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=mine`, [`/${kind}`]));
  308. };
  309. const mediaCreateModels = { audios: audiosModel, videos: videosModel };
  310. const mediaCreateAction = async (ctx, kind) => {
  311. const modKey = mediaModCheck[kind];
  312. if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
  313. const blob = await handleBlobUpload(ctx, kind.slice(0, -1));
  314. const { tags, title, description } = ctx.request.body;
  315. await mediaCreateModels[kind][`create${kind.charAt(0).toUpperCase()}${kind.slice(1, -1)}`](blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description));
  316. ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=all`, [`/${kind}`]));
  317. };
  318. const mediaUpdateAction = async (ctx, kind) => {
  319. const modKey = mediaModCheck[kind];
  320. if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; }
  321. const { tags, title, description } = ctx.request.body;
  322. const singular = kind.slice(0, -1);
  323. const blob = ctx.request.files?.[singular] ? await handleBlobUpload(ctx, singular) : null;
  324. await mediaCreateModels[kind][`update${kind.charAt(0).toUpperCase()}${kind.slice(1, -1)}ById`](ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description));
  325. ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=mine`, [`/${kind}`]));
  326. };
  327. const qf = (ctx, def = 'all') => ctx.query.filter || def;
  328. const qp = (ctx, def = 1) => Math.max(1, parseInt(ctx.query.page) || def);
  329. about._startNameWarmup();
  330. async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) {
  331. if (!text) return '';
  332. const mentionByFeed = {};
  333. Object.values(mentions).forEach(arr => {
  334. arr.forEach(m => {
  335. mentionByFeed[m.feed] = m;
  336. });
  337. });
  338. text = text.replace(/\[@([^\]]+)\]\(([^)]+)\)/g, (_, name, id) => {
  339. return `<a class="mention" href="/author/${encodeURIComponent(id)}">@${name}</a>`;
  340. });
  341. const words = text.split(' ');
  342. text = (await Promise.all(
  343. words.map(async (word) => {
  344. const match = /@([A-Za-z0-9_\-\.+=\/]+\.ed25519)/.exec(word);
  345. if (match && match[1]) {
  346. const feedId = match[1];
  347. const feedWithAt = feedId.startsWith('@') ? feedId : `@${feedId}`;
  348. let resolvedName;
  349. if (feedId === myFeedId || feedWithAt === myFeedId) {
  350. resolvedName = myUsername;
  351. } else {
  352. try { resolvedName = await about.name(feedWithAt); } catch { resolvedName = feedId.slice(0, 8); }
  353. }
  354. return word.replace(match[0], `<a class="mention" href="/author/${encodeURIComponent(feedWithAt)}">@${resolvedName}</a>`);
  355. }
  356. return word;
  357. })
  358. )).join(' ');
  359. text = text
  360. .replace(/!\[image:[^\]]+\]\(([^)]+)\)/g, (_, id) =>
  361. `<img src="/blob/${encodeURIComponent(id)}" alt="image" class="post-image" />`)
  362. .replace(/\[audio:[^\]]+\]\(([^)]+)\)/g, (_, id) =>
  363. `<audio controls class="post-audio" src="/blob/${encodeURIComponent(id)}"></audio>`)
  364. .replace(/\[video:[^\]]+\]\(([^)]+)\)/g, (_, id) =>
  365. `<video controls class="post-video" src="/blob/${encodeURIComponent(id)}"></video>`)
  366. .replace(/\[pdf:([^\]]*)\]\(([^)]+)\)/g, (_, name, id) => {
  367. const { i18n } = require("../views/main_views");
  368. return `<a class="post-pdf" href="/blob/${encodeURIComponent(id)}" target="_blank">${name || (i18n && i18n.pdfFallbackLabel) || 'PDF'}</a>`;
  369. });
  370. return text;
  371. }
  372. async function resolveMentionText(text) {
  373. if (!text || typeof text !== 'string') return text;
  374. const mentionRe = /@([A-Za-z0-9_\-\.+=\/]+\.ed25519)/g;
  375. const matches = [...text.matchAll(mentionRe)];
  376. if (!matches.length) return text;
  377. const seen = new Map();
  378. for (const m of matches) {
  379. const raw = m[1];
  380. const feed = raw.startsWith('@') ? raw : `@${raw}`;
  381. if (seen.has(feed)) continue;
  382. let name;
  383. try { name = await about.name(feed); } catch { name = feed.slice(1, 9); }
  384. seen.set(feed, name);
  385. }
  386. return text.replace(mentionRe, (full, id) => {
  387. const feed = id.startsWith('@') ? id : `@${id}`;
  388. const name = seen.get(feed) || feed.slice(1, 9);
  389. return `[@${name}](${feed})`;
  390. });
  391. }
  392. const preparePreview = async function (ctx) {
  393. let text = String(ctx.request.body.text || "")
  394. const contentWarning = stripDangerousTags(String(ctx.request.body.contentWarning || ""))
  395. const ensureAt = (id) => {
  396. const s = String(id || "")
  397. if (!s) return ""
  398. return s.startsWith("@") ? s : `@${s.replace(/^@+/, "")}`
  399. }
  400. const stripAt = (id) => String(id || "").replace(/^@+/, "")
  401. const norm = (s) => String(s || "").trim().toLowerCase()
  402. const ssbClient = await cooler.open()
  403. const authorMeta = {
  404. id: ssbClient.id,
  405. name: await about.name(ssbClient.id),
  406. image: await about.image(ssbClient.id),
  407. }
  408. const myId = String(authorMeta.id)
  409. text = text.replace(
  410. /\[@([^\]]+)\]\s*\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g,
  411. (_m, label, feed) => `[@${label}](@${stripAt(feed)})`
  412. )
  413. const mentions = {}
  414. const normalizeMatch = (m) => {
  415. const feed = ensureAt(m?.feed || m?.link || m?.id || "")
  416. const name = String(m?.name || "")
  417. const img = m?.img || m?.image || null
  418. const rel = m?.rel || {}
  419. return { ...m, feed, name, img, rel }
  420. }
  421. const pushUnique = (key, arr) => {
  422. const prev = Array.isArray(mentions[key]) ? mentions[key] : []
  423. const seen = new Set(prev.map((x) => String(x?.feed || "")))
  424. const out = prev.slice()
  425. for (const x of arr) {
  426. const f = String(x?.feed || "")
  427. if (!f) continue
  428. if (seen.has(f)) continue
  429. seen.add(f)
  430. out.push(x)
  431. }
  432. if (out.length) mentions[key] = out
  433. }
  434. const chooseByPhrase = (matches, phrase) => {
  435. const p = norm(phrase)
  436. const exact = matches.filter((mm) => norm(mm.name) === p)
  437. if (exact.length) return exact
  438. const starts = matches.filter((mm) => norm(mm.name).startsWith(p))
  439. if (starts.length) return starts
  440. const incl = matches.filter((mm) => norm(mm.name).includes(p))
  441. if (incl.length) return incl
  442. return null
  443. }
  444. 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
  445. let m
  446. while ((m = rex.exec(text)) !== null) {
  447. const w1 = m[2]
  448. const w2 = m[3]
  449. const w3 = m[4]
  450. if (/\.ed25519$/.test(w1)) {
  451. const feed = ensureAt(w1)
  452. const [name, img, rel] = await Promise.all([
  453. about.name(feed),
  454. about.image(feed),
  455. friend.getRelationship(feed).catch(() => ({ followsMe: false, following: false, blocking: false, me: false }))
  456. ])
  457. pushUnique(w1, [{ feed, name, img, rel }])
  458. continue
  459. }
  460. const phrase1 = w1
  461. const phrase2 = w2 ? `${w1} ${w2}` : null
  462. const phrase3 = w3 ? `${w1} ${w2 ? w2 : ""} ${w3}`.replace(/\s+/g, " ").trim() : null
  463. const matchesRaw = about.named(w1) || []
  464. const matchesAll = matchesRaw.map(normalizeMatch)
  465. const matches = matchesAll.filter((mm) => String(mm.feed) !== myId && !mm?.rel?.me)
  466. let chosenKey = phrase1
  467. let chosenMatches = matches
  468. if (phrase3) {
  469. const best3 = chooseByPhrase(matches, phrase3)
  470. if (best3 && best3.length) {
  471. chosenKey = phrase3
  472. chosenMatches = best3
  473. } else if (phrase2) {
  474. const best2 = chooseByPhrase(matches, phrase2)
  475. if (best2 && best2.length) {
  476. chosenKey = phrase2
  477. chosenMatches = best2
  478. }
  479. }
  480. } else if (phrase2) {
  481. const best2 = chooseByPhrase(matches, phrase2)
  482. if (best2 && best2.length) {
  483. chosenKey = phrase2
  484. chosenMatches = best2
  485. }
  486. }
  487. if (chosenMatches.length > 0) {
  488. pushUnique(chosenKey, chosenMatches)
  489. }
  490. }
  491. Object.keys(mentions).forEach((key) => {
  492. const matches = Array.isArray(mentions[key]) ? mentions[key] : []
  493. const meaningful = matches.filter((mm) => (mm?.rel?.followsMe || mm?.rel?.following) && !mm?.rel?.blocking && String(mm?.feed || "") !== myId && !mm?.rel?.me)
  494. mentions[key] = meaningful.length > 0 ? meaningful : matches
  495. })
  496. 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
  497. const replacer = (match, prefix, w1, w2, w3) => {
  498. const phrase1 = w1
  499. const phrase2 = w2 ? `${w1} ${w2}` : null
  500. const phrase3 = w3 ? `${w1} ${w2 ? w2 : ""} ${w3}`.replace(/\s+/g, " ").trim() : null
  501. const tryKey = (k) => {
  502. const arr = mentions[k]
  503. if (arr && arr.length === 1) {
  504. return `${prefix}[@${arr[0].name}](${ensureAt(arr[0].feed)})`
  505. }
  506. return null
  507. }
  508. if (/\.ed25519$/.test(w1)) {
  509. const arr = mentions[w1]
  510. if (arr && arr.length === 1) return `${prefix}[@${arr[0].name}](${ensureAt(arr[0].feed)})`
  511. return match
  512. }
  513. const r3 = phrase3 ? tryKey(phrase3) : null
  514. if (r3) return r3
  515. const r2 = phrase2 ? tryKey(phrase2) : null
  516. if (r2) return r2
  517. const r1 = tryKey(phrase1)
  518. if (r1) return r1
  519. return match
  520. }
  521. text = text.replace(rexReplace, replacer)
  522. const blobMarkdown = await handleBlobUpload(ctx, "blob")
  523. if (blobMarkdown) {
  524. text += blobMarkdown
  525. }
  526. const renderedText = await renderBlobMarkdown(
  527. text,
  528. mentions,
  529. authorMeta.id,
  530. authorMeta.name
  531. )
  532. const hasBrTags = /<br\s*\/?>/i.test(renderedText)
  533. const hasBlockTags = /<(p|div|ul|ol|li|pre|blockquote|h[1-6]|table|tr|td|th|section|article)\b/i.test(renderedText)
  534. let formattedText = renderedText
  535. if (!hasBrTags && !hasBlockTags && /[\r\n]/.test(renderedText)) {
  536. formattedText = renderedText.replace(/\r\n|\r|\n/g, "<br>")
  537. }
  538. return { authorMeta, text, formattedText, mentions, contentWarning }
  539. }
  540. const megabyte = Math.pow(2, 20);
  541. const maxSize = 50 * megabyte;
  542. const homeDir = os.homedir();
  543. const blobsPath = path.join(homeDir, '.ssb', 'blobs', 'tmp');
  544. const gossipPath = path.join(homeDir, '.ssb', 'gossip.json');
  545. const unfollowedPath = path.join(homeDir, '.ssb', 'gossip_unfollowed.json');
  546. const ensureJSONFile = (p, init = []) => { fs.mkdirSync(path.dirname(p), { recursive: true }); if (!fs.existsSync(p)) fs.writeFileSync(p, JSON.stringify(init, null, 2), 'utf8'); };
  547. const readJSON = p => { ensureJSONFile(p, []); try { return JSON.parse(fs.readFileSync(p, 'utf8') || '[]'); } catch { return []; } };
  548. const writeJSON = (p, d) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(d, null, 2), 'utf8'); };
  549. const canonicalKey = k => { let c = String(k).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/'); if (!c.endsWith('=')) c += '='; return `@${c}.ed25519`; };
  550. const msAddrFrom = (h, p, k) => `net:${h}:${Number(p) || 8008}~shs:${canonicalKey(k).slice(1, -9)}`;
  551. ensureJSONFile(gossipPath, []);
  552. ensureJSONFile(unfollowedPath, []);
  553. const koaBodyMiddleware = koaBody({
  554. multipart: true,
  555. formidable: {
  556. uploadDir: blobsPath,
  557. keepExtensions: true,
  558. maxFieldsSize: maxSize,
  559. maxFileSize: maxSize,
  560. hash: 'sha256',
  561. },
  562. parsedMethods: ['POST'],
  563. });
  564. const resolveCommentComponents = async function (ctx) {
  565. let parentId;
  566. try {
  567. parentId = decodeURIComponent(ctx.params.message);
  568. } catch {
  569. parentId = ctx.params.message;
  570. }
  571. const parentMessage = await post.get(parentId);
  572. if (!parentMessage || !parentMessage.value) {
  573. throw new Error("Invalid parentMessage or missing 'value'");
  574. }
  575. const myFeedId = await meta.myFeedId();
  576. const hasRoot =
  577. typeof parentMessage?.value?.content?.root === "string" &&
  578. ssbRef.isMsg(parentMessage.value.content.root);
  579. const hasFork =
  580. typeof parentMessage?.value?.content?.fork === "string" &&
  581. ssbRef.isMsg(parentMessage.value.content.fork);
  582. const rootMessage = hasRoot
  583. ? hasFork
  584. ? parentMessage
  585. : await post.get(parentMessage.value.content.root)
  586. : parentMessage;
  587. const messages = await post.topicComments(rootMessage.key);
  588. messages.push(rootMessage);
  589. let contentWarning;
  590. if (ctx.request.body) {
  591. const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning || "").trim());
  592. contentWarning = rawContentWarning.length > 0 ? rawContentWarning : undefined;
  593. }
  594. return { messages, myFeedId, parentMessage, contentWarning };
  595. };
  596. 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");
  597. const { activityView } = require("../views/activity_view");
  598. const { cvView, createCVView } = require("../views/cv_view");
  599. const { indexingView } = require("../views/indexing_view");
  600. const { pixeliaView } = require("../views/pixelia_view");
  601. const { statsView } = require("../views/stats_view");
  602. const { tribesView, tribeView, renderInvitePage } = require("../views/tribes_view");
  603. const { agendaView } = require("../views/agenda_view");
  604. const { documentView, singleDocumentView } = require("../views/document_view");
  605. const { inhabitantsView, inhabitantsProfileView } = require("../views/inhabitants_view");
  606. const { walletViewRender, walletView, walletHistoryView, walletReceiveView, walletSendFormView, walletSendConfirmView, walletSendResultView, walletErrorView } = require("../views/wallet_view");
  607. const { pmView } = require("../views/pm_view");
  608. const { tagsView } = require("../views/tags_view");
  609. const { videoView, singleVideoView } = require("../views/video_view");
  610. const { audioView, singleAudioView } = require("../views/audio_view");
  611. const { eventView, singleEventView } = require("../views/event_view");
  612. const { invitesView } = require("../views/invites_view");
  613. const { modulesView } = require("../views/modules_view");
  614. const { reportView, singleReportView } = require("../views/report_view");
  615. const { taskView, singleTaskView } = require("../views/task_view");
  616. const { voteView } = require("../views/vote_view");
  617. const { bookmarkView, singleBookmarkView } = require("../views/bookmark_view");
  618. const { feedView, feedCreateView, singleFeedView } = require("../views/feed_view");
  619. const { legacyView } = require("../views/legacy_view");
  620. const { opinionsView } = require("../views/opinions_view");
  621. const { peersView } = require("../views/peers_view");
  622. const { searchView } = require("../views/search_view");
  623. const { transferView, singleTransferView } = require("../views/transfer_view");
  624. const { cipherView } = require("../views/cipher_view");
  625. const { imageView, singleImageView } = require("../views/image_view");
  626. const { settingsView } = require("../views/settings_view");
  627. const { trendingView } = require("../views/trending_view");
  628. const { marketView, singleMarketView } = require("../views/market_view");
  629. const { aiView } = require("../views/AI_view");
  630. const { forumView, singleForumView } = require("../views/forum_view");
  631. const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
  632. const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
  633. const { projectsView, singleProjectView } = require("../views/projects_view")
  634. const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
  635. const { favoritesView } = require("../views/favorites_view");
  636. const { parliamentView } = require("../views/parliament_view");
  637. const { courtsView, courtsCaseView } = require('../views/courts_view');
  638. let sharp;
  639. try {
  640. sharp = require("sharp");
  641. } catch (e) {
  642. }
  643. const readmePath = path.join(__dirname, "..", ".." ,"README.md");
  644. const packagePath = path.join(__dirname, "..", "server", "package.json");
  645. const readme = fs.readFileSync(readmePath, "utf8");
  646. const version = JSON.parse(fs.readFileSync(packagePath, "utf8")).version;
  647. const nullImageId = '&0000000000000000000000000000000000000000000=.sha256';
  648. const getAvatarUrl = img => !img || img === nullImageId ? '/assets/images/default-avatar.png' : `/image/256/${encodeURIComponent(img)}`;
  649. const MAX_TITLE_LENGTH = 150;
  650. const MAX_TEXT_LENGTH = 8000;
  651. 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); };
  652. const tooLong = (ctx, value, max, label) => {
  653. if (value && value.length > max) {
  654. ctx.status = 400;
  655. ctx.body = `${label} too long (max ${max})`;
  656. return true;
  657. }
  658. return false;
  659. };
  660. router
  661. .param("imageSize", (imageSize, ctx, next) => {
  662. const size = Number(imageSize);
  663. const isInteger = size % 1 === 0;
  664. const overMinSize = size > 2;
  665. const underMaxSize = size <= 256;
  666. ctx.assert(
  667. isInteger && overMinSize && underMaxSize,
  668. 400,
  669. "Invalid image size"
  670. );
  671. return next();
  672. })
  673. .param("blobId", (blobId, ctx, next) => {
  674. ctx.assert(ssbRef.isBlob(blobId), 400, "Invalid blob link");
  675. return next();
  676. })
  677. .param("message", (message, ctx, next) => {
  678. ctx.assert(ssbRef.isMsg(message), 400, "Invalid message link");
  679. return next();
  680. })
  681. .param("feed", (message, ctx, next) => {
  682. ctx.assert(ssbRef.isFeedId(message), 400, "Invalid feed link");
  683. return next();
  684. })
  685. .get("/", async (ctx) => {
  686. const currentConfig = getConfig();
  687. const homePage = currentConfig.homePage || "activity";
  688. ctx.redirect(`/${homePage}`);
  689. })
  690. .get("/robots.txt", (ctx) => {
  691. ctx.body = "User-agent: *\nDisallow: /";
  692. })
  693. .get(oasisCheckPath, (ctx) => {
  694. ctx.body = "oasis";
  695. })
  696. .get('/stats', async (ctx) => {
  697. const filter = qf(ctx, 'ALL'), stats = await statsModel.getStats(filter);
  698. const myId = getViewerId();
  699. const myAddress = await bankingModel.getUserAddress(myId);
  700. const addrRows = await bankingModel.listAddressesMerged();
  701. stats.banking = {
  702. myAddress: myAddress || null,
  703. totalAddresses: Array.isArray(addrRows) ? addrRows.length : 0
  704. };
  705. const totalMB = parseSizeMB(stats.statsBlobsSize) + parseSizeMB(stats.statsBlockchainSize);
  706. const hcT = parseFloat((totalMB * 0.0002 * 475).toFixed(2));
  707. const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
  708. const hcH = inhabitants > 0 ? parseFloat((hcT / inhabitants).toFixed(2)) : 0;
  709. sharedState.setCarbonHcT(hcT);
  710. sharedState.setCarbonHcH(hcH);
  711. ctx.body = statsView(stats, filter);
  712. })
  713. .get("/public/popular/:period", async (ctx) => {
  714. if (!checkMod(ctx, 'popularMod')) return ctx.redirect('/modules');
  715. const i18n = require("../client/assets/translations/i18n"), lang = ctx.cookies.get('language') || getConfig().language || 'en', t = i18n[lang] || i18n['en'];
  716. const messages = sanitizeMessages(await post.popular({ period: ctx.params.period }));
  717. 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]))))))) });
  718. })
  719. .get("/modules", async (ctx) => {
  720. 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'];
  721. const cfg = getConfig().modules;
  722. ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}));
  723. })
  724. .get('/ai', async (ctx) => {
  725. if (!checkMod(ctx, 'aiMod')) return ctx.redirect('/modules');
  726. startAI();
  727. const lang = ctx.cookies.get('language') || getConfig().language || 'en', historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
  728. require('../views/main_views').setLanguage(lang);
  729. let chatHistory = []; try { chatHistory = JSON.parse(fs.readFileSync(historyPath, 'utf-8')); } catch {}
  730. ctx.body = aiView(chatHistory, getConfig().ai?.prompt?.trim() || '');
  731. })
  732. .get('/pixelia', async (ctx) => {
  733. if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; }
  734. const pixelArt = await pixeliaModel.listPixels();
  735. ctx.body = pixeliaView(pixelArt);
  736. })
  737. .get('/blockexplorer', async (ctx) => {
  738. const userId = getViewerId();
  739. const query = ctx.query || {};
  740. const search = {
  741. id: query.id || '',
  742. author: query.author || '',
  743. from: query.from || '',
  744. to: query.to || ''
  745. };
  746. const searchActive = Object.values(search).some(v => String(v || '').trim().length > 0);
  747. let filter = query.filter || 'recent';
  748. if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
  749. const blockchainData = await blockchainModel.listBlockchain(filter, userId, search);
  750. ctx.body = renderBlockchainView(blockchainData, filter, userId, search);
  751. })
  752. .get('/blockexplorer/block/:id', async (ctx) => {
  753. const userId = getViewerId();
  754. const query = ctx.query || {};
  755. const search = {
  756. id: query.id || '',
  757. author: query.author || '',
  758. from: query.from || '',
  759. to: query.to || ''
  760. };
  761. const searchActive = Object.values(search).some(v => String(v || '').trim().length > 0);
  762. let filter = query.filter || 'recent';
  763. if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
  764. const blockId = ctx.params.id;
  765. const block = await blockchainModel.getBlockById(blockId);
  766. const viewMode = query.view || 'block';
  767. ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode);
  768. })
  769. .get("/public/latest", async (ctx) => {
  770. if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; }
  771. const messages = sanitizeMessages(await post.latest());
  772. ctx.body = await latestView({ messages });
  773. })
  774. .get("/public/latest/extended", async (ctx) => {
  775. if (!checkMod(ctx, 'extendedMod')) { ctx.redirect('/modules'); return; }
  776. const messages = sanitizeMessages(await post.latestExtended());
  777. ctx.body = await extendedView({ messages });
  778. })
  779. .get("/public/latest/topics", async (ctx) => {
  780. if (!checkMod(ctx, 'topicsMod')) { ctx.redirect('/modules'); return; }
  781. const messages = sanitizeMessages(await post.latestTopics());
  782. const channels = await post.channels();
  783. const list = channels.map((c) => {
  784. return li(a({ href: `/hashtag/${c}` }, `#${c}`));
  785. });
  786. const prefix = nav(ul(list));
  787. ctx.body = await topicsView({ messages, prefix });
  788. })
  789. .get("/public/latest/summaries", async (ctx) => {
  790. if (!checkMod(ctx, 'summariesMod')) { ctx.redirect('/modules'); return; }
  791. const messages = sanitizeMessages(await post.latestSummaries());
  792. ctx.body = await summaryView({ messages });
  793. })
  794. .get("/public/latest/threads", async (ctx) => {
  795. if (!checkMod(ctx, 'threadsMod')) { ctx.redirect('/modules'); return; }
  796. const messages = sanitizeMessages(await post.latestThreads());
  797. ctx.body = await threadsView({ messages });
  798. })
  799. .get('/author/:feed', async (ctx) => {
  800. const feedId = decodeURIComponent(ctx.params.feed || ''), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1);
  801. if (lt > 0 && gt > 0 && gt >= lt) throw new Error('Given search range is empty');
  802. const [description, name, image, messages, firstPost, lastPost, relationship, ecoAddress, bankData] = await Promise.all([
  803. about.description(feedId), about.name(feedId), about.image(feedId), post.fromPublicFeed(feedId, gt, lt),
  804. post.firstBy(feedId), post.latestBy(feedId), friend.getRelationship(feedId), bankingModel.getUserAddress(feedId), bankingModel.getBankingData(feedId)
  805. ]);
  806. const sanitizedMsgs = sanitizeMessages(messages);
  807. const normTs = t => { const n = Number(t || 0); return !isFinite(n) || n <= 0 ? 0 : n < 1e12 ? n * 1000 : n; };
  808. const pull = require('../server/node_modules/pull-stream'), ssb = await require('../client/gui')({ offline: require('../server/ssb_config').offline }).open();
  809. 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))));
  810. const days = latestFromStream ? (Date.now() - latestFromStream) / 86400000 : Infinity;
  811. 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' });
  812. })
  813. .get("/search", async (ctx) => {
  814. const query = ctx.query.query || '';
  815. if (!query) return ctx.body = await searchView({ messages: [], query, types: [] });
  816. const results = await searchModel.search({ query, types: [] });
  817. ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
  818. acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
  819. return acc;
  820. }, {}), query, types: [] });
  821. })
  822. .get("/images", async (ctx) => {
  823. if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
  824. const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
  825. const items = await imagesModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
  826. const fav = await mediaFavorites.getFavoriteSet('images');
  827. let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
  828. if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
  829. await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
  830. ctx.body = await imageView(enriched, filter, null, { q, sort });
  831. })
  832. .get("/images/edit/:id", async (ctx) => {
  833. if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
  834. const img = await imagesModel.getImageById(ctx.params.id, getViewerId());
  835. const fav = await mediaFavorites.getFavoriteSet('images');
  836. ctx.body = await imageView([{ ...img, isFavorite: fav.has(String(img.rootId || img.key)) }], 'edit', img.key, { returnTo: ctx.query.returnTo || '' });
  837. })
  838. .get("/images/:imageId", async (ctx) => {
  839. if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
  840. const { imageId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
  841. const img = await imagesModel.getImageById(imageId, getViewerId());
  842. const fav = await mediaFavorites.getFavoriteSet('images');
  843. const comments = await getVoteComments(img.key);
  844. 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']) });
  845. })
  846. .get("/audios", async (ctx) => {
  847. if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
  848. const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
  849. const items = await audiosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
  850. const fav = await mediaFavorites.getFavoriteSet('audios');
  851. let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
  852. if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
  853. await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
  854. ctx.body = await audioView(enriched, filter, null, { q, sort });
  855. })
  856. .get("/audios/edit/:id", async (ctx) => {
  857. if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
  858. const audio = await audiosModel.getAudioById(ctx.params.id, getViewerId());
  859. const fav = await mediaFavorites.getFavoriteSet('audios');
  860. ctx.body = await audioView([{ ...audio, isFavorite: fav.has(String(audio.rootId || audio.key)) }], 'edit', audio.key, { returnTo: ctx.query.returnTo || '' });
  861. })
  862. .get("/audios/:audioId", async (ctx) => {
  863. if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; }
  864. const { audioId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
  865. const audio = await audiosModel.getAudioById(audioId, getViewerId());
  866. const fav = await mediaFavorites.getFavoriteSet('audios');
  867. const comments = await getVoteComments(audio.key);
  868. 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']) });
  869. })
  870. .get("/videos", async (ctx) => {
  871. if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; }
  872. const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
  873. const items = await videosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
  874. const fav = await mediaFavorites.getFavoriteSet('videos');
  875. let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
  876. if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
  877. await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
  878. ctx.body = await videoView(enriched, filter, null, { q, sort });
  879. })
  880. .get("/videos/edit/:id", async (ctx) => {
  881. if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; }
  882. const video = await videosModel.getVideoById(ctx.params.id, getViewerId());
  883. const fav = await mediaFavorites.getFavoriteSet('videos');
  884. ctx.body = await videoView([{ ...video, isFavorite: fav.has(String(video.rootId || video.key)) }], 'edit', video.key, { returnTo: ctx.query.returnTo || '' });
  885. })
  886. .get("/videos/:videoId", async (ctx) => {
  887. if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; }
  888. const { videoId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
  889. const video = await videosModel.getVideoById(videoId, getViewerId());
  890. const fav = await mediaFavorites.getFavoriteSet('videos');
  891. const comments = await getVoteComments(video.key);
  892. 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']) });
  893. })
  894. .get("/documents", async (ctx) => {
  895. const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
  896. const items = await documentsModel.listAll({ filter, q, sort });
  897. await Promise.all(items.map(async x => { x.commentCount = (await getVoteComments(x.rootId || x.key)).length; }));
  898. ctx.body = await documentView(items, filter, null, { q, sort });
  899. })
  900. .get("/documents/edit/:id", async (ctx) => {
  901. const doc = await documentsModel.getDocumentById(ctx.params.id);
  902. ctx.body = await documentView([doc], 'edit', doc.key, { returnTo: ctx.query.returnTo || '' });
  903. })
  904. .get("/documents/:documentId", async (ctx) => {
  905. const { filter = "all", q = "", sort = "recent" } = ctx.query;
  906. const document = await documentsModel.getDocumentById(ctx.params.documentId);
  907. const comments = await getVoteComments(document.rootId || document.key);
  908. ctx.body = await singleDocumentView(withCount(document, comments), filter, comments, {
  909. q, sort,
  910. returnTo: safeReturnTo(ctx, `/documents/${encodeURIComponent(document.key)}?filter=${encodeURIComponent(filter)}${q ? `&q=${encodeURIComponent(q)}` : ""}${sort ? `&sort=${encodeURIComponent(sort)}` : ""}`, ["/documents"])
  911. });
  912. })
  913. .get('/cv', async ctx => {
  914. const cv = await cvModel.getCVByUserId()
  915. ctx.body = await cvView(cv)
  916. })
  917. .get('/cv/create', async ctx => {
  918. ctx.body = await createCVView()
  919. })
  920. .get('/cv/edit/:id', async ctx => {
  921. const cv = await cvModel.getCVByUserId()
  922. ctx.body = await createCVView(cv, true)
  923. })
  924. .get('/pm', async ctx => {
  925. const { recipients = '', subject = '', quote = '', preview = '' } = ctx.query;
  926. const quoted = quote ? quote.split('\n').map(l => '> ' + l).join('\n') + '\n\n' : '';
  927. const showPreview = preview === '1';
  928. ctx.body = await pmView(recipients, subject, quoted, showPreview);
  929. })
  930. .get('/inbox', async ctx => {
  931. if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; }
  932. const messages = sanitizeMessages(await pmModel.listAllPrivate());
  933. await refreshInboxCount(messages);
  934. ctx.body = await privateView({ messages }, ctx.query.filter || undefined);
  935. })
  936. .get('/tags', async ctx => {
  937. const filter = qf(ctx), tags = await tagsModel.listTags(filter);
  938. ctx.body = await tagsView(tags, filter);
  939. })
  940. .get('/reports', async ctx => {
  941. const filter = qf(ctx), reports = await enrichWithComments(await reportsModel.listAll());
  942. ctx.body = await reportView(reports, filter, null, ctx.query.category || '');
  943. })
  944. .get('/reports/edit/:id', async ctx => {
  945. const report = await reportsModel.getReportById(ctx.params.id);
  946. ctx.body = await reportView([report], 'edit', ctx.params.id);
  947. })
  948. .get('/reports/:reportId', async ctx => {
  949. const { reportId } = ctx.params, filter = qf(ctx), report = await reportsModel.getReportById(reportId);
  950. const comments = await getVoteComments(reportId);
  951. ctx.body = await singleReportView(withCount(report, comments), filter, comments);
  952. })
  953. .get('/trending', async (ctx) => {
  954. const filter = qf(ctx, 'RECENT'), { filtered = [] } = await trendingModel.listTrending(filter);
  955. ctx.body = await trendingView(filtered, filter, trendingModel.categories);
  956. })
  957. .get('/agenda', async (ctx) => {
  958. const filter = qf(ctx), data = await agendaModel.listAgenda(filter);
  959. ctx.body = await agendaView(data, filter);
  960. })
  961. .get("/hashtag/:hashtag", async (ctx) => {
  962. const { hashtag } = ctx.params;
  963. const messages = sanitizeMessages(await post.fromHashtag(hashtag));
  964. ctx.body = await hashtagView({ hashtag, messages });
  965. })
  966. .get('/inhabitants', async (ctx) => {
  967. const filter = qf(ctx);
  968. const query = { search: ctx.query.search || '' };
  969. const userId = getViewerId();
  970. if (['CVs', 'MATCHSKILLS'].includes(filter)) {
  971. Object.assign(query, {
  972. location: ctx.query.location || '',
  973. language: ctx.query.language || '',
  974. skills: ctx.query.skills || ''
  975. });
  976. }
  977. const inhabitants = await inhabitantsModel.listInhabitants({ filter, ...query });
  978. const [addresses, karmaList] = await Promise.all([
  979. bankingModel.listAddressesMerged(),
  980. Promise.all(
  981. inhabitants.map(async (u) => {
  982. try {
  983. const bank = await bankingModel.getBankingData(u.id);
  984. return { id: u.id, karmaScore: bank?.karmaScore || 0 };
  985. } catch {
  986. return { id: u.id, karmaScore: 0 };
  987. }
  988. })
  989. )
  990. ]);
  991. const activityList = await Promise.all(
  992. inhabitants.map(async (u) => {
  993. try {
  994. const ts = await inhabitantsModel.getLastActivityTimestampByUserId(u.id);
  995. const { bucket } = inhabitantsModel.bucketLastActivity(ts || null);
  996. return { id: u.id, lastActivityBucket: bucket };
  997. } catch {
  998. return { id: u.id, lastActivityBucket: 'red' };
  999. }
  1000. })
  1001. );
  1002. const addrMap = new Map(addresses.map(x => [x.id, x.address]));
  1003. const karmaMap = new Map(karmaList.map(x => [x.id, x.karmaScore]));
  1004. const activityMap = new Map(activityList.map(x => [x.id, x.lastActivityBucket]));
  1005. let enriched = inhabitants.map(u => ({
  1006. ...u,
  1007. ecoAddress: addrMap.get(u.id) || null,
  1008. karmaScore:
  1009. karmaMap.get(u.id) ??
  1010. (typeof u.karmaScore === 'number' ? u.karmaScore : 0),
  1011. lastActivityBucket: activityMap.get(u.id)
  1012. }));
  1013. if (filter === 'TOP KARMA') {
  1014. enriched = enriched.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
  1015. }
  1016. if (filter === 'TOP ACTIVITY') {
  1017. const order = { green: 0, orange: 1, red: 2 };
  1018. enriched = enriched.sort(
  1019. (a, b) => (order[a.lastActivityBucket] ?? 3) - (order[b.lastActivityBucket] ?? 3)
  1020. );
  1021. }
  1022. ctx.body = await inhabitantsView(enriched, filter, query, userId);
  1023. })
  1024. .get('/inhabitant/:id', async (ctx) => {
  1025. const id = ctx.params.id;
  1026. const [about, cv, feed, photo, bank, lastTs] = await Promise.all([
  1027. inhabitantsModel.getLatestAboutById(id),
  1028. inhabitantsModel.getCVByUserId(id),
  1029. inhabitantsModel.getFeedByUserId(id),
  1030. inhabitantsModel.getPhotoUrlByUserId(id, 256),
  1031. bankingModel.getBankingData(id).catch(() => ({ karmaScore: 0 })),
  1032. inhabitantsModel.getLastActivityTimestampByUserId(id).catch(() => null)
  1033. ]);
  1034. const bucketInfo = inhabitantsModel.bucketLastActivity(lastTs || null);
  1035. const currentUserId = getViewerId();
  1036. const karmaScore = bank && typeof bank.karmaScore === 'number' ? bank.karmaScore : 0;
  1037. ctx.body = await inhabitantsProfileView({ about, cv, feed, photo, karmaScore, lastActivityBucket: bucketInfo.bucket, viewedId: id }, currentUserId);
  1038. })
  1039. .get('/parliament', async (ctx) => {
  1040. if (!checkMod(ctx, 'parliamentMod')) return ctx.redirect('/modules');
  1041. const filter = (ctx.query.filter || 'government').toLowerCase();
  1042. await ensureTerm();
  1043. await runSweepOnce();
  1044. const [governmentCardRaw, candidatures, proposals, futureLaws, canPropose, laws, historical, leaders, revocations, futureRevocations, revocationsEnactedCount, inhabitantsAll] = await Promise.all([
  1045. parliamentModel.getGovernmentCard(),
  1046. parliamentModel.listCandidatures('OPEN'),
  1047. parliamentModel.listProposalsCurrent(),
  1048. parliamentModel.listFutureLawsCurrent(),
  1049. parliamentModel.canPropose(),
  1050. parliamentModel.listLaws(),
  1051. parliamentModel.listHistorical(),
  1052. parliamentModel.listLeaders(),
  1053. parliamentModel.listRevocationsCurrent(),
  1054. parliamentModel.listFutureRevocationsCurrent(),
  1055. parliamentModel.countRevocationsEnacted(),
  1056. inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true })
  1057. ]);
  1058. const inhabitantsTotal = Array.isArray(inhabitantsAll) ? inhabitantsAll.length : 0;
  1059. const governmentCard = governmentCardRaw ? { ...governmentCardRaw, inhabitantsTotal } : null;
  1060. const leader = pickLeader(candidatures || []);
  1061. const getActorMeta = async (type, id) => (type === 'tribe' || type === 'inhabitant') ? parliamentModel.getActorMeta({ targetType: type, targetId: id }) : null;
  1062. const leaderMeta = leader ? await getActorMeta(leader.targetType || leader.powerType || 'inhabitant', leader.targetId || leader.powerId) : null;
  1063. const powerMeta = governmentCard ? await getActorMeta(governmentCard.powerType, governmentCard.powerId) : null;
  1064. const buildMetas = async (items, limit) => {
  1065. const m = {};
  1066. for (const g of (items || []).slice(0, limit)) {
  1067. if (g.powerType === 'tribe' || g.powerType === 'inhabitant') {
  1068. const k = `${g.powerType}:${g.powerId}`;
  1069. if (!m[k]) m[k] = await getActorMeta(g.powerType, g.powerId);
  1070. }
  1071. }
  1072. return m;
  1073. };
  1074. const [historicalMetas, leadersMetas] = await Promise.all([buildMetas(historical, 12), buildMetas(leaders, 20)]);
  1075. ctx.body = await parliamentView({
  1076. filter,
  1077. inhabitantsTotal,
  1078. governmentCard,
  1079. candidatures,
  1080. proposals,
  1081. futureLaws,
  1082. canPropose,
  1083. laws,
  1084. historical,
  1085. leaders,
  1086. leaderMeta,
  1087. powerMeta,
  1088. historicalMetas,
  1089. leadersMetas,
  1090. revocations,
  1091. futureRevocations,
  1092. revocationsEnactedCount
  1093. });
  1094. })
  1095. .get('/courts', async (ctx) => {
  1096. if (!checkMod(ctx, 'courtsMod')) return ctx.redirect('/modules');
  1097. const filter = String(ctx.query.filter || 'cases').toLowerCase(), search = String(ctx.query.search || '').trim();
  1098. const currentUserId = await courtsModel.getCurrentUserId();
  1099. const state = { filter, search, cases: [], myCases: [], trials: [], history: [], nominations: [], userId: currentUserId };
  1100. const searchFilter = (items) => !search ? items : items.filter(c => [c.title, c.description].some(s => String(s || '').toLowerCase().includes(search.toLowerCase())));
  1101. if (filter === 'cases') state.cases = searchFilter((await courtsModel.listCases('open')).map(c => ({ ...c, respondent: c.respondentId || c.respondent })));
  1102. if (filter === 'mycases' || filter === 'actions') {
  1103. let myCases = searchFilter(await courtsModel.listCasesForUser(currentUserId));
  1104. if (filter === 'actions') myCases = myCases.filter(c => {
  1105. const s = String(c.status || '').toUpperCase(), m = String(c.method || '').toUpperCase(), id = String(currentUserId || '');
  1106. const roles = { a: !!c.isAccuser, r: !!c.isRespondent, m: !!c.isMediator, j: !!c.isJudge, d: !!c.isDictator };
  1107. const open = s === 'OPEN' || s === 'IN_PROGRESS';
  1108. 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);
  1109. });
  1110. state.myCases = myCases;
  1111. }
  1112. if (filter === 'judges') state.nominations = (await courtsModel.listNominations()) || [];
  1113. if (filter === 'history') {
  1114. const id = String(currentUserId || '');
  1115. state.history = searchFilter((await courtsModel.listCases('history')).map(c => {
  1116. const ma = Array.isArray(c.mediatorsAccuser) ? c.mediatorsAccuser : [], mr = Array.isArray(c.mediatorsRespondent) ? c.mediatorsRespondent : [];
  1117. 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 };
  1118. }));
  1119. }
  1120. ctx.body = await courtsView(state);
  1121. })
  1122. .get('/courts/cases/:id', async (ctx) => {
  1123. if (!checkMod(ctx, 'courtsMod')) return ctx.redirect('/modules');
  1124. ctx.body = await courtsCaseView({ caseData: await courtsModel.getCaseDetails({ caseId: ctx.params.id }).catch(() => null) });
  1125. })
  1126. .get('/tribes', async ctx => {
  1127. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  1128. const filter = qf(ctx), search = ctx.query.search || '', tribes = await tribesModel.listAll();
  1129. const filteredTribes = search ? tribes.filter(t => t.title.toLowerCase().includes(search.toLowerCase())) : tribes;
  1130. ctx.body = await tribesView(filteredTribes, filter, null, ctx.query, tribes);
  1131. })
  1132. .get('/tribes/create', async ctx => {
  1133. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  1134. ctx.body = await tribesView([], 'create', null)
  1135. })
  1136. .get('/tribes/edit/:id', async ctx => {
  1137. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  1138. const tribe = await tribesModel.getTribeById(ctx.params.id)
  1139. ctx.body = await tribesView([tribe], 'edit', ctx.params.id)
  1140. })
  1141. .get('/tribe/:tribeId', async ctx => {
  1142. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  1143. const listByTribeAllChain = async (tribeId, contentType) => {
  1144. const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]);
  1145. const results = await Promise.all(chainIds.map(id => tribesContentModel.listByTribe(id, contentType).catch(() => [])));
  1146. const seen = new Set();
  1147. return results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; });
  1148. };
  1149. const tribe = await tribesModel.getTribeById(ctx.params.tribeId);
  1150. const uid = getViewerId();
  1151. const query = { feedFilter: 'TOP', ...ctx.query };
  1152. if (!tribe.members.includes(uid)) {
  1153. ctx.redirect('/tribes');
  1154. return;
  1155. }
  1156. const section = ctx.query.section || 'activity';
  1157. const contentTypeMap = { events: 'event', tasks: 'task', reports: 'report', votations: 'votation', market: 'market', jobs: 'job', projects: 'project', media: 'media' };
  1158. 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' };
  1159. let sectionData = null;
  1160. if (section === 'inhabitants') {
  1161. const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true });
  1162. sectionData = allInhabitants.filter(u => tribe.members.includes(u.id));
  1163. } else if (section === 'feed') {
  1164. sectionData = await listByTribeAllChain(tribe.id, 'feed').catch(() => []);
  1165. } else if (section === 'forum') {
  1166. const forums = await listByTribeAllChain(tribe.id, 'forum');
  1167. const replies = await listByTribeAllChain(tribe.id, 'forum-reply');
  1168. sectionData = [...forums, ...replies];
  1169. } else if (section === 'subtribes') {
  1170. sectionData = await tribesModel.listSubTribes(tribe.id);
  1171. } else if (mediaSections[section]) {
  1172. sectionData = await listByTribeAllChain(tribe.id, 'media');
  1173. } else if (contentTypeMap[section]) {
  1174. sectionData = await listByTribeAllChain(tribe.id, contentTypeMap[section]);
  1175. } else if (section === 'activity') {
  1176. const allContent = await listByTribeAllChain(tribe.id, null);
  1177. const subTribes = await tribesModel.listSubTribes(tribe.id);
  1178. const subContent = [];
  1179. for (const st of subTribes) {
  1180. const stItems = await listByTribeAllChain(st.id, null).catch(() => []);
  1181. subContent.push(...stItems.map(item => ({ ...item, tribeName: st.title })));
  1182. }
  1183. const combined = [...allContent, ...subContent];
  1184. const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true });
  1185. const allMembers = [...new Set([...tribe.members, ...subTribes.flatMap(st => st.members || [])])];
  1186. const memberMap = new Map(allInhabitants.filter(u => allMembers.includes(u.id)).map(u => [u.id, u]));
  1187. 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);
  1188. sectionData = { activities, memberMap };
  1189. } else if (section === 'trending') {
  1190. const allContent = await listByTribeAllChain(tribe.id, null);
  1191. const period = ctx.query.period || 'all';
  1192. let items = allContent.filter(i => i.contentType !== 'forum-reply' && i.contentType !== 'pixelia');
  1193. if (period === 'day') items = items.filter(i => (Date.parse(i.createdAt) || i._ts || 0) >= Date.now() - 86400000);
  1194. else if (period === 'week') items = items.filter(i => (Date.parse(i.createdAt) || i._ts || 0) >= Date.now() - 7 * 86400000);
  1195. items.sort((a, b) => {
  1196. 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);
  1197. return score(b) - score(a);
  1198. });
  1199. sectionData = { items, period };
  1200. } else if (section === 'tags') {
  1201. const allContent = await listByTribeAllChain(tribe.id, null);
  1202. const tagMap = new Map();
  1203. for (const item of allContent) {
  1204. for (const tag of (item.tags || []).filter(Boolean)) {
  1205. const lower = tag.toLowerCase().trim();
  1206. if (!lower) continue;
  1207. if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0, items: [] });
  1208. const entry = tagMap.get(lower);
  1209. entry.count++;
  1210. entry.items.push(item);
  1211. }
  1212. }
  1213. const selectedTag = (ctx.query.tag || '').toLowerCase().trim();
  1214. sectionData = { tags: [...tagMap.values()].sort((a, b) => b.count - a.count), selectedTag, filteredItems: selectedTag && tagMap.has(selectedTag) ? tagMap.get(selectedTag).items : [] };
  1215. } else if (section === 'search') {
  1216. const sq = (ctx.query.q || '').trim().toLowerCase();
  1217. let results = [];
  1218. if (sq.length >= 2) {
  1219. const allContent = await listByTribeAllChain(tribe.id, null);
  1220. results = allContent.filter(item => (item.title || '').toLowerCase().includes(sq) || (item.description || '').toLowerCase().includes(sq) || (item.tags || []).join(' ').toLowerCase().includes(sq));
  1221. }
  1222. sectionData = { query: ctx.query.q || '', results };
  1223. } else if (section === 'opinions') {
  1224. const allContent = await listByTribeAllChain(tribe.id, null);
  1225. const opinionated = allContent.filter(i => i.opinions && Object.keys(i.opinions).length > 0).sort((a, b) => {
  1226. const sum = o => Object.values(o.opinions || {}).reduce((s, n) => s + n, 0);
  1227. return sum(b) - sum(a);
  1228. });
  1229. sectionData = { items: allContent.filter(i => i.contentType !== 'forum-reply' && i.contentType !== 'pixelia'), opinionated };
  1230. } else if (section === 'pixelia') {
  1231. const pixels = await listByTribeAllChain(tribe.id, 'pixelia');
  1232. const coordMap = new Map();
  1233. 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); }
  1234. sectionData = { pixels: [...coordMap.values()] };
  1235. } else if (section === 'overview') {
  1236. const events = await listByTribeAllChain(tribe.id, 'event').catch(() => []);
  1237. const tasks = await listByTribeAllChain(tribe.id, 'task').catch(() => []);
  1238. const feed = await listByTribeAllChain(tribe.id, 'feed').catch(() => []);
  1239. sectionData = { events, tasks, feed };
  1240. }
  1241. const subTribes = await tribesModel.listSubTribes(tribe.id);
  1242. tribe.subTribes = subTribes;
  1243. if (tribe.parentTribeId) {
  1244. try { tribe.parentTribe = await tribesModel.getTribeById(tribe.parentTribeId); } catch (_) {}
  1245. }
  1246. const resolveItemMentions = async (items) => {
  1247. if (!Array.isArray(items)) return items;
  1248. for (const item of items) {
  1249. if (item.description) item.description = await resolveMentionText(item.description);
  1250. }
  1251. return items;
  1252. };
  1253. if (Array.isArray(sectionData)) {
  1254. await resolveItemMentions(sectionData);
  1255. } else if (sectionData && typeof sectionData === 'object') {
  1256. if (sectionData.activities) await resolveItemMentions(sectionData.activities);
  1257. if (sectionData.items) await resolveItemMentions(sectionData.items);
  1258. if (sectionData.results) await resolveItemMentions(sectionData.results);
  1259. if (sectionData.events) await resolveItemMentions(sectionData.events);
  1260. if (sectionData.tasks) await resolveItemMentions(sectionData.tasks);
  1261. if (sectionData.feed) await resolveItemMentions(sectionData.feed);
  1262. }
  1263. ctx.body = await tribeView(tribe, uid, query, section, sectionData);
  1264. })
  1265. .get('/activity', async ctx => {
  1266. const filter = qf(ctx, 'recent'), userId = getViewerId();
  1267. const q = String((ctx.query && ctx.query.q) || '');
  1268. try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {}
  1269. try { await bankingModel.getUserEngagementScore(userId); } catch (_) {}
  1270. const allActions = await activityModel.listFeed('all');
  1271. ctx.body = activityView(allActions, filter, userId, q);
  1272. })
  1273. .get("/profile", async (ctx) => {
  1274. const myFeedId = await meta.myFeedId(), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1);
  1275. if (lt > 0 && gt > 0 && gt >= lt) throw new Error("Given search range is empty");
  1276. const [description, name, image, messages, firstPost, lastPost, ecoAddress, bankData] = await Promise.all([
  1277. about.description(myFeedId), about.name(myFeedId), about.image(myFeedId), post.fromPublicFeed(myFeedId, gt, lt),
  1278. post.firstBy(myFeedId), post.latestBy(myFeedId), bankingModel.getUserAddress(myFeedId), bankingModel.getBankingData(myFeedId)
  1279. ]);
  1280. const normTs = t => { const n = Number(t || 0); return !isFinite(n) || n <= 0 ? 0 : n < 1e12 ? n * 1000 : n; };
  1281. const pickTs = obj => { if (!obj) return 0; const v = obj.value || obj; return normTs(v.timestamp || v.ts || v.time || v.meta?.timestamp || 0); };
  1282. let lastActivityTs = Math.max(Array.isArray(messages) && messages.length ? Math.max(...messages.map(pickTs)) : 0, pickTs(lastPost), pickTs(firstPost));
  1283. if (!lastActivityTs) {
  1284. const pull = require("../server/node_modules/pull-stream"), ssb = await require("../client/gui")({ offline: require("../server/ssb_config").offline }).open();
  1285. 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))));
  1286. }
  1287. const days = lastActivityTs ? (Date.now() - lastActivityTs) / 86400000 : Infinity;
  1288. 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" });
  1289. })
  1290. .get("/profile/edit", async (ctx) => {
  1291. const myFeedId = await meta.myFeedId();
  1292. ctx.body = await editProfileView({ name: await about.name(myFeedId), description: await about.description(myFeedId) });
  1293. })
  1294. .post("/profile/edit", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  1295. const imageFile = ctx.request.files?.image;
  1296. const mime = imageFile?.mimetype || imageFile?.type || '';
  1297. const isImage = mime.startsWith('image/');
  1298. const imageData = isImage && imageFile?.filepath ? await promisesFs.readFile(imageFile.filepath).catch(() => undefined) : undefined;
  1299. await post.publishProfileEdit({
  1300. name: stripDangerousTags(String(ctx.request.body?.name || '')),
  1301. description: stripDangerousTags(String(ctx.request.body?.description || '')),
  1302. image: imageData
  1303. });
  1304. ctx.redirect("/profile");
  1305. })
  1306. .get("/publish/custom", async (ctx) => {
  1307. ctx.body = await publishCustomView();
  1308. })
  1309. .get("/json/:message", async (ctx) => {
  1310. if (config.public) {
  1311. throw new Error(
  1312. "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again."
  1313. );
  1314. }
  1315. const { message } = ctx.params;
  1316. ctx.type = "application/json";
  1317. const json = async (message) => {
  1318. const json = await meta.get(message);
  1319. return JSON.stringify(json, null, 2);
  1320. };
  1321. ctx.body = await json(message);
  1322. })
  1323. .get("/blob/:blobId", serveBlob)
  1324. .get("/image/:imageSize/:blobId", async (ctx) => {
  1325. const { blobId, imageSize } = ctx.params;
  1326. const size = Number(imageSize);
  1327. const fallbackPixel = Buffer.from(
  1328. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
  1329. "base64"
  1330. );
  1331. const fakeImage = () => {
  1332. if (typeof sharp !== "function") {
  1333. return Promise.resolve(fallbackPixel);
  1334. }
  1335. return sharp({
  1336. create: {
  1337. width: size,
  1338. height: size,
  1339. channels: 4,
  1340. background: { r: 0, g: 0, b: 0, alpha: 0.5 },
  1341. },
  1342. }).png().toBuffer();
  1343. };
  1344. try {
  1345. const buffer = await blob.getResolved({ blobId });
  1346. if (!buffer) {
  1347. ctx.set("Content-Type", "image/png");
  1348. ctx.body = await fakeImage();
  1349. return;
  1350. }
  1351. const fileType = await FileType.fromBuffer(buffer);
  1352. const mimeType = fileType?.mime || "application/octet-stream";
  1353. ctx.set("Content-Type", mimeType);
  1354. if (typeof sharp === "function") {
  1355. ctx.body = await sharp(buffer)
  1356. .resize(size, size)
  1357. .png()
  1358. .toBuffer();
  1359. } else {
  1360. ctx.body = buffer;
  1361. }
  1362. } catch (err) {
  1363. ctx.set("Content-Type", "image/png");
  1364. ctx.body = await fakeImage();
  1365. }
  1366. })
  1367. .get("/settings", async (ctx) => {
  1368. const cfg = getConfig(), theme = ctx.cookies.get("theme") || "Dark-SNH";
  1369. ctx.body = await settingsView({ theme, version: version.toString(), aiPrompt: cfg.ai?.prompt || "", pubWalletUrl: cfg.walletPub?.url || '', pubWalletUser: cfg.walletPub?.user || '', pubWalletPass: cfg.walletPub?.pass || '' });
  1370. })
  1371. .get("/peers", async (ctx) => {
  1372. const { discoveredPeers, unknownPeers } = await meta.discovered();
  1373. ctx.body = await peersView({ onlinePeers: await meta.onlinePeers(), discoveredPeers, unknownPeers });
  1374. })
  1375. .get("/invites", async (ctx) => {
  1376. if (!checkMod(ctx, 'invitesMod')) return ctx.redirect('/modules');
  1377. ctx.body = await invitesView({});
  1378. })
  1379. .get("/likes/:feed", async (ctx) => {
  1380. const { feed } = ctx.params;
  1381. ctx.body = await likesView({ messages: await post.likes({ feed }), feed, name: await about.name(feed) });
  1382. })
  1383. .get("/mentions", async (ctx) => {
  1384. const { messages, myFeedId } = await post.mentionsMe();
  1385. const tribeMentions = [];
  1386. try {
  1387. const allTribes = await tribesModel.listAll();
  1388. const myTribes = allTribes.filter(t => t.members.includes(myFeedId));
  1389. for (const t of myTribes) {
  1390. const items = await tribesContentModel.listByTribe(t.id, null).catch(() => []);
  1391. for (const item of items) {
  1392. const text = (item.description || '') + ' ' + (item.title || '');
  1393. if (text.includes(myFeedId) || text.includes(myFeedId.slice(1))) {
  1394. tribeMentions.push({
  1395. key: item.id,
  1396. value: {
  1397. author: item.author,
  1398. timestamp: Date.parse(item.createdAt) || item._ts || Date.now(),
  1399. content: {
  1400. type: 'tribe-content',
  1401. text: item.description || item.title || '',
  1402. tribeId: t.id,
  1403. tribeName: t.title,
  1404. contentType: item.contentType,
  1405. mentions: { _self: [{ link: myFeedId }] }
  1406. }
  1407. }
  1408. });
  1409. }
  1410. }
  1411. }
  1412. } catch (_) {}
  1413. const combined = [...(Array.isArray(messages) ? messages : []), ...tribeMentions];
  1414. for (const msg of combined) {
  1415. if (!msg.value) continue;
  1416. const authorId = msg.value.author;
  1417. if (authorId) {
  1418. if (!msg.value.meta) msg.value.meta = {};
  1419. if (!msg.value.meta.author) msg.value.meta.author = {};
  1420. if (!msg.value.meta.author.name) {
  1421. try { msg.value.meta.author.name = await about.name(authorId); } catch (_) {}
  1422. }
  1423. }
  1424. }
  1425. ctx.body = await mentionsView({ messages: combined, myFeedId });
  1426. })
  1427. .get('/opinions', async (ctx) => {
  1428. const filter = qf(ctx, 'RECENT'), opinions = await opinionsModel.listOpinions(filter);
  1429. ctx.body = await opinionsView(opinions, filter);
  1430. })
  1431. .get("/feed", async (ctx) => {
  1432. const filter = String(ctx.query.filter || "ALL").toUpperCase();
  1433. const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
  1434. const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : "";
  1435. const msg = typeof ctx.query.msg === "string" ? ctx.query.msg : "";
  1436. const feeds = await feedModel.listFeeds({ filter, q, tag });
  1437. ctx.body = feedView(feeds, { filter, q, tag, msg });
  1438. })
  1439. .get("/feed/create", async (ctx) => {
  1440. const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
  1441. const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : "";
  1442. ctx.body = feedCreateView({ q, tag });
  1443. })
  1444. .get("/feed/:feedId", async (ctx) => {
  1445. const feed = await feedModel.getFeedById(ctx.params.feedId);
  1446. if (!feed) { ctx.redirect('/feed'); return; }
  1447. const comments = await feedModel.getComments(ctx.params.feedId).catch(() => []);
  1448. ctx.body = singleFeedView(feed, comments);
  1449. })
  1450. .get('/forum', async ctx => {
  1451. if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; }
  1452. const filter = qf(ctx, 'recent'), forums = await forumModel.listAll(filter);
  1453. ctx.body = await forumView(forums, filter);
  1454. })
  1455. .get('/forum/:forumId', async ctx => {
  1456. const msg = await forumModel.getMessageById(ctx.params.forumId), isReply = Boolean(msg.root), forumId = isReply ? msg.root : ctx.params.forumId;
  1457. ctx.body = await singleForumView(await forumModel.getForumById(forumId), await forumModel.getMessagesByForumId(forumId), ctx.query.filter, isReply ? ctx.params.forumId : null);
  1458. })
  1459. .get('/legacy', async (ctx) => {
  1460. if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules');
  1461. try { ctx.body = await legacyView(); } catch (error) { ctx.body = { error: error.message }; }
  1462. })
  1463. .get('/bookmarks', async (ctx) => {
  1464. if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules');
  1465. const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', viewerId = getViewerId();
  1466. const favs = await mediaFavorites.getFavoriteSet("bookmarks");
  1467. let bookmarks = (await bookmarksModel.listAll({ viewerId, filter: filter === "favorites" ? "all" : filter, q, sort })).map(b => ({ ...b, isFavorite: favs.has(String(b.rootId || b.id)) }));
  1468. if (filter === "favorites") bookmarks = bookmarks.filter(b => b.isFavorite);
  1469. await enrichWithComments(bookmarks, 'rootId');
  1470. ctx.body = await bookmarkView(bookmarks, filter, null, { q, sort });
  1471. })
  1472. .get("/bookmarks/edit/:id", async (ctx) => {
  1473. if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules');
  1474. const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await mediaFavorites.getFavoritesSet("bookmarks");
  1475. ctx.body = await bookmarkView([{ ...bookmark, isFav: favs.has(String(bookmark.rootId || bookmark.id)) }], "edit", bookmark.id, { returnTo: ctx.query.returnTo || "" });
  1476. })
  1477. .get('/bookmarks/:bookmarkId', async (ctx) => {
  1478. if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules');
  1479. const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', favs = await mediaFavorites.getFavoriteSet("bookmarks");
  1480. const bookmark = await bookmarksModel.getBookmarkById(ctx.params.bookmarkId), root = bookmark.rootId || bookmark.id, comments = await getVoteComments(root);
  1481. 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']) });
  1482. })
  1483. .get('/tasks', async ctx => {
  1484. const filter = qf(ctx), tasks = await enrichWithComments(await tasksModel.listAll());
  1485. ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo);
  1486. })
  1487. .get('/tasks/edit/:id', async ctx => {
  1488. const id = ctx.params.id;
  1489. const task = await tasksModel.getTaskById(id);
  1490. ctx.body = await taskView(task, 'edit', id, ctx.query.returnTo);
  1491. })
  1492. .get('/tasks/:taskId', async ctx => {
  1493. const { taskId } = ctx.params, filter = qf(ctx), task = await tasksModel.getTaskById(taskId);
  1494. const comments = await getVoteComments(taskId);
  1495. ctx.body = await singleTaskView(withCount(task, comments), filter, comments);
  1496. })
  1497. .get('/events', async (ctx) => {
  1498. if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
  1499. const filter = qf(ctx), events = await enrichWithComments(await eventsModel.listAll(null, filter));
  1500. ctx.body = await eventView(events, filter, null, ctx.query.returnTo);
  1501. })
  1502. .get('/events/edit/:id', async (ctx) => {
  1503. if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
  1504. const eventId = ctx.params.id;
  1505. const event = await eventsModel.getEventById(eventId);
  1506. ctx.body = await eventView([event], 'edit', eventId, ctx.query.returnTo);
  1507. })
  1508. .get('/events/:eventId', async ctx => {
  1509. const { eventId } = ctx.params, filter = qf(ctx), event = await eventsModel.getEventById(eventId);
  1510. const comments = await getVoteComments(eventId);
  1511. ctx.body = await singleEventView(withCount(event, comments), filter, comments);
  1512. })
  1513. .get('/votes', async ctx => {
  1514. const filter = qf(ctx), voteList = await enrichWithComments(await votesModel.listAll(filter));
  1515. ctx.body = await voteView(voteList, filter, null, [], filter);
  1516. })
  1517. .get('/votes/edit/:id', async ctx => {
  1518. const id = ctx.params.id;
  1519. const activeFilter = (ctx.query.filter || 'mine');
  1520. const voteData = await votesModel.getVoteById(id);
  1521. ctx.body = await voteView([voteData], 'edit', id, [], activeFilter);
  1522. })
  1523. .get('/votes/:voteId', async ctx => {
  1524. const { voteId } = ctx.params, filter = qf(ctx), voteData = await votesModel.getVoteById(voteId);
  1525. const comments = await getVoteComments(voteId);
  1526. ctx.body = await voteView([withCount(voteData, comments)], 'detail', voteId, comments, filter);
  1527. })
  1528. .get("/market", async (ctx) => {
  1529. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  1530. const filter = qf(ctx), q = ctx.query.q || "", minPrice = ctx.query.minPrice ?? "", maxPrice = ctx.query.maxPrice ?? "", sort = ctx.query.sort || "recent";
  1531. let marketItems = await marketModel.listAllItems("all");
  1532. await marketModel.checkAuctionItemsStatus(marketItems);
  1533. marketItems = await marketModel.listAllItems("all");
  1534. await enrichWithComments(marketItems);
  1535. ctx.body = await marketView(marketItems, filter, null, { q, minPrice, maxPrice, sort });
  1536. })
  1537. .get("/market/edit/:id", async (ctx) => {
  1538. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  1539. const id = ctx.params.id
  1540. let marketItem = await marketModel.getItemById(id)
  1541. if (!marketItem) ctx.throw(404, "Item not found")
  1542. await marketModel.checkAuctionItemsStatus([marketItem])
  1543. marketItem = await marketModel.getItemById(id)
  1544. if (!marketItem) ctx.throw(404, "Item not found")
  1545. ctx.body = await marketView([marketItem], "edit", marketItem, { q: "", minPrice: "", maxPrice: "", sort: "recent" })
  1546. })
  1547. .get("/market/:itemId", async (ctx) => {
  1548. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  1549. const { itemId } = ctx.params, filter = qf(ctx), q = ctx.query.q || "", minPrice = ctx.query.minPrice ?? "", maxPrice = ctx.query.maxPrice ?? "", sort = ctx.query.sort || "recent";
  1550. let item = await marketModel.getItemById(itemId)
  1551. if (!item) ctx.throw(404, "Item not found")
  1552. await marketModel.checkAuctionItemsStatus([item])
  1553. item = await marketModel.getItemById(itemId)
  1554. if (!item) ctx.throw(404, "Item not found")
  1555. const comments = await getVoteComments(itemId)
  1556. const returnTo = (() => {
  1557. const params = []
  1558. if (filter) params.push(`filter=${encodeURIComponent(filter)}`)
  1559. if (q) params.push(`q=${encodeURIComponent(q)}`)
  1560. if (minPrice !== "" && minPrice != null) params.push(`minPrice=${encodeURIComponent(String(minPrice))}`)
  1561. if (maxPrice !== "" && maxPrice != null) params.push(`maxPrice=${encodeURIComponent(String(maxPrice))}`)
  1562. if (sort) params.push(`sort=${encodeURIComponent(sort)}`)
  1563. return `/market${params.length ? `?${params.join("&")}` : ""}`
  1564. })()
  1565. ctx.body = await singleMarketView(withCount(item, comments), filter, comments, { q, minPrice, maxPrice, sort, returnTo })
  1566. })
  1567. .get('/jobs', async (ctx) => {
  1568. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  1569. let filter = String(ctx.query.filter || 'ALL').toUpperCase()
  1570. if (filter === 'FAVS' || filter === 'NEEDS') filter = 'ALL'
  1571. const query = {
  1572. search: ctx.query.search || '',
  1573. minSalary: ctx.query.minSalary ?? '',
  1574. maxSalary: ctx.query.maxSalary ?? '',
  1575. sort: ctx.query.sort || 'recent'
  1576. }
  1577. if (filter === 'CREATE') {
  1578. ctx.body = await jobsView([], 'CREATE', query)
  1579. return
  1580. }
  1581. if (filter === 'CV') {
  1582. query.location = ctx.query.location || ''
  1583. query.language = ctx.query.language || ''
  1584. query.skills = ctx.query.skills || ''
  1585. const inhabitants = await inhabitantsModel.listInhabitants({
  1586. filter: 'CVs',
  1587. ...query
  1588. })
  1589. ctx.body = await jobsView(inhabitants, filter, query)
  1590. return
  1591. }
  1592. const viewerId = getViewerId()
  1593. const jobs = await jobsModel.listJobs(filter, viewerId, query)
  1594. await enrichWithComments(jobs)
  1595. ctx.body = await jobsView(jobs, filter, query)
  1596. })
  1597. .get('/jobs/edit/:id', async (ctx) => {
  1598. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  1599. const id = ctx.params.id
  1600. const viewerId = getViewerId()
  1601. const job = await jobsModel.getJobById(id, viewerId)
  1602. ctx.body = await jobsView([job], 'EDIT', {})
  1603. })
  1604. .get('/jobs/:jobId', async (ctx) => {
  1605. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  1606. const jobId = ctx.params.jobId
  1607. let filter = String(ctx.query.filter || 'ALL').toUpperCase()
  1608. if (filter === 'FAVS' || filter === 'NEEDS') filter = 'ALL'
  1609. const viewerId = getViewerId()
  1610. const params = {
  1611. search: ctx.query.search || '',
  1612. minSalary: ctx.query.minSalary ?? '',
  1613. maxSalary: ctx.query.maxSalary ?? '',
  1614. sort: ctx.query.sort || 'recent',
  1615. returnTo: safeReturnTo(ctx, `/jobs?filter=${encodeURIComponent(filter)}`, ['/jobs'])
  1616. }
  1617. const job = await jobsModel.getJobById(jobId, viewerId)
  1618. const comments = await getVoteComments(jobId)
  1619. ctx.body = await singleJobsView(withCount(job, comments), filter, comments, params)
  1620. })
  1621. .get("/projects", async (ctx) => {
  1622. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  1623. const filter = String(ctx.query.filter || "ALL").toUpperCase()
  1624. if (filter === "CREATE") {
  1625. ctx.body = await projectsView([], "CREATE")
  1626. return
  1627. }
  1628. const modelFilter = filter === "BACKERS" ? "ALL" : filter
  1629. const projects = await projectsModel.listProjects(modelFilter)
  1630. await enrichWithComments(projects)
  1631. ctx.body = await projectsView(projects, filter)
  1632. })
  1633. .get("/projects/edit/:id", async (ctx) => {
  1634. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  1635. const id = ctx.params.id
  1636. const pr = await projectsModel.getProjectById(id)
  1637. ctx.body = await projectsView([pr], "EDIT")
  1638. })
  1639. .get("/projects/:projectId", async (ctx) => {
  1640. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  1641. const projectId = ctx.params.projectId
  1642. const filter = String(ctx.query.filter || "ALL").toUpperCase()
  1643. const project = await projectsModel.getProjectById(projectId)
  1644. const comments = await getVoteComments(projectId)
  1645. ctx.body = await singleProjectView(withCount(project, comments), filter, comments)
  1646. })
  1647. .get("/banking", async (ctx) => {
  1648. if (!checkMod(ctx, 'bankingMod')) { ctx.redirect('/modules'); return; }
  1649. const userId = getViewerId();
  1650. const query = ctx.query;
  1651. const filter = (query.filter || 'overview').toLowerCase();
  1652. const q = (query.q || '').trim();
  1653. const msg = (query.msg || '').trim();
  1654. await bankingModel.ensureSelfAddressPublished();
  1655. const data = await bankingModel.listBanking(filter, userId);
  1656. if (filter === 'addresses' && q) {
  1657. data.addresses = (data.addresses || []).filter(x =>
  1658. String(x.id).toLowerCase().includes(q.toLowerCase()) ||
  1659. String(x.address).toLowerCase().includes(q.toLowerCase())
  1660. );
  1661. data.search = q;
  1662. }
  1663. data.flash = msg || '';
  1664. const { ecoValue, inflationFactor, ecoInHours, currentSupply, isSynced } = await bankingModel.calculateEcoinValue();
  1665. data.exchange = {
  1666. ecoValue: ecoValue,
  1667. inflationFactor,
  1668. ecoInHours,
  1669. currentSupply: currentSupply,
  1670. totalSupply: 25500000,
  1671. isSynced: isSynced
  1672. };
  1673. ctx.body = renderBankingView(data, filter, userId);
  1674. })
  1675. .get("/banking/allocation/:id", async (ctx) => {
  1676. const userId = getViewerId();
  1677. const allocation = await bankingModel.getAllocationById(ctx.params.id);
  1678. ctx.body = renderSingleAllocationView(allocation, userId);
  1679. })
  1680. .get("/banking/epoch/:id", async (ctx) => {
  1681. const epoch = await bankingModel.getEpochById(ctx.params.id);
  1682. const allocations = await bankingModel.listEpochAllocations(ctx.params.id);
  1683. ctx.body = renderEpochView(epoch, allocations);
  1684. })
  1685. .get("/favorites", async (ctx) => {
  1686. const filter = qf(ctx), data = await favoritesModel.listAll({ filter });
  1687. ctx.body = await favoritesView(data.items, filter, data.counts);
  1688. })
  1689. .get('/cipher', async (ctx) => {
  1690. if (!checkMod(ctx, 'cipherMod')) { ctx.redirect('/modules'); return; }
  1691. try {
  1692. ctx.body = await cipherView();
  1693. } catch (error) {
  1694. ctx.body = { error: error.message };
  1695. }
  1696. })
  1697. .get("/thread/:message", async (ctx) => {
  1698. const { message } = ctx.params;
  1699. const thread = async (message) => {
  1700. const messages = await post.fromThread(message);
  1701. return threadView({ messages });
  1702. };
  1703. ctx.body = await thread(message);
  1704. })
  1705. .get("/subtopic/:message", async (ctx) => {
  1706. const { message } = ctx.params;
  1707. const rootMessage = await post.get(message);
  1708. const myFeedId = await meta.myFeedId();
  1709. debug("%O", rootMessage);
  1710. const messages = [rootMessage];
  1711. ctx.body = await subtopicView({ messages, myFeedId });
  1712. })
  1713. .get("/publish", async (ctx) => {
  1714. ctx.body = await publishView();
  1715. })
  1716. .get("/comment/:message", async (ctx) => {
  1717. const { messages, myFeedId, parentMessage } =
  1718. await resolveCommentComponents(ctx);
  1719. ctx.body = await commentView({ messages, myFeedId, parentMessage });
  1720. })
  1721. .get("/wallet", async (ctx) => {
  1722. const { url, user, pass } = getConfig().wallet;
  1723. if (!checkMod(ctx, 'walletMod')) { ctx.redirect('/modules'); return; }
  1724. try {
  1725. const balance = await walletModel.getBalance(url, user, pass);
  1726. const address = await walletModel.getAddress(url, user, pass);
  1727. const userId = getViewerId();
  1728. if (address && typeof address === "string") {
  1729. const map = readAddrMap();
  1730. const was = map[userId];
  1731. if (was !== address) {
  1732. map[userId] = address;
  1733. writeAddrMap(map);
  1734. try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
  1735. }
  1736. }
  1737. ctx.body = await walletView(balance, address);
  1738. } catch (error) {
  1739. ctx.body = await walletErrorView(error);
  1740. }
  1741. })
  1742. .get("/wallet/history", async (ctx) => {
  1743. const { url, user, pass } = getConfig().wallet;
  1744. try {
  1745. const balance = await walletModel.getBalance(url, user, pass);
  1746. const transactions = await walletModel.listTransactions(url, user, pass);
  1747. const address = await walletModel.getAddress(url, user, pass);
  1748. const userId = getViewerId();
  1749. if (address && typeof address === "string") {
  1750. const map = readAddrMap();
  1751. const was = map[userId];
  1752. if (was !== address) {
  1753. map[userId] = address;
  1754. writeAddrMap(map);
  1755. try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
  1756. }
  1757. }
  1758. ctx.body = await walletHistoryView(balance, transactions, address);
  1759. } catch (error) {
  1760. ctx.body = await walletErrorView(error);
  1761. }
  1762. })
  1763. .get("/wallet/receive", async (ctx) => {
  1764. const { url, user, pass } = getConfig().wallet;
  1765. try {
  1766. const balance = await walletModel.getBalance(url, user, pass);
  1767. const address = await walletModel.getAddress(url, user, pass);
  1768. const userId = getViewerId();
  1769. if (address && typeof address === "string") {
  1770. const map = readAddrMap();
  1771. const was = map[userId];
  1772. if (was !== address) {
  1773. map[userId] = address;
  1774. writeAddrMap(map);
  1775. try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
  1776. }
  1777. }
  1778. ctx.body = await walletReceiveView(balance, address);
  1779. } catch (error) {
  1780. ctx.body = await walletErrorView(error);
  1781. }
  1782. })
  1783. .get("/wallet/send", async (ctx) => {
  1784. const { url, user, pass, fee } = getConfig().wallet;
  1785. try {
  1786. const balance = await walletModel.getBalance(url, user, pass);
  1787. const address = await walletModel.getAddress(url, user, pass);
  1788. const userId = getViewerId();
  1789. if (address && typeof address === "string") {
  1790. const map = readAddrMap();
  1791. const was = map[userId];
  1792. if (was !== address) {
  1793. map[userId] = address;
  1794. writeAddrMap(map);
  1795. try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
  1796. }
  1797. }
  1798. ctx.body = await walletSendFormView(balance, null, null, fee, null, address);
  1799. } catch (error) {
  1800. ctx.body = await walletErrorView(error);
  1801. }
  1802. })
  1803. .get('/transfers', async ctx => {
  1804. if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
  1805. let filter = ctx.query.filter || 'all'; if (filter === 'favs') filter = 'all';
  1806. const list = await transfersModel.listAll(filter, getViewerId());
  1807. ctx.body = await transferView(list, filter, null, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent' });
  1808. })
  1809. .get('/transfers/edit/:id', async ctx => {
  1810. if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
  1811. const tr = await transfersModel.getTransferById(ctx.params.id, getViewerId());
  1812. ctx.body = await transferView([tr], 'edit', ctx.params.id, {});
  1813. })
  1814. .get('/transfers/:transferId', async ctx => {
  1815. if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
  1816. let filter = ctx.query.filter || 'all'; if (filter === 'favs') filter = 'all';
  1817. const transfer = await transfersModel.getTransferById(ctx.params.transferId, getViewerId());
  1818. 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']) });
  1819. })
  1820. .post('/ai', koaBody(), async (ctx) => {
  1821. const { input } = ctx.request.body;
  1822. if (!input) {
  1823. ctx.status = 400;
  1824. ctx.body = { error: 'No input provided' };
  1825. return;
  1826. }
  1827. startAI();
  1828. const i18nAll = require('../client/assets/translations/i18n');
  1829. const lang = ctx.cookies.get('language') || getConfig().language || 'en';
  1830. const translations = i18nAll[lang] || i18nAll['en'];
  1831. const { setLanguage } = require('../views/main_views');
  1832. setLanguage(lang);
  1833. const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
  1834. let chatHistory = [];
  1835. try {
  1836. const fileData = fs.readFileSync(historyPath, 'utf-8');
  1837. chatHistory = JSON.parse(fileData);
  1838. } catch {
  1839. chatHistory = [];
  1840. }
  1841. const config = getConfig();
  1842. const userPrompt = config.ai?.prompt?.trim() || 'Provide an informative and precise response.';
  1843. try {
  1844. let aiResponse = '';
  1845. let snippets = [];
  1846. const trained = await getBestTrainedAnswer(input);
  1847. if (trained && trained.answer) {
  1848. aiResponse = trained.answer;
  1849. snippets = Array.isArray(trained.ctx) ? trained.ctx : [];
  1850. } else {
  1851. const response = await axios.post('http://localhost:4001/ai', { input });
  1852. aiResponse = response.data.answer;
  1853. snippets = Array.isArray(response.data.snippets) ? response.data.snippets : [];
  1854. }
  1855. chatHistory.unshift({
  1856. prompt: userPrompt,
  1857. question: input,
  1858. answer: aiResponse,
  1859. timestamp: Date.now(),
  1860. trainStatus: 'pending',
  1861. snippets
  1862. });
  1863. } catch (e) {
  1864. chatHistory.unshift({
  1865. prompt: userPrompt,
  1866. question: input,
  1867. answer: translations.aiServerError || 'The AI could not answer. Please try again.',
  1868. timestamp: Date.now(),
  1869. trainStatus: 'rejected',
  1870. snippets: []
  1871. });
  1872. }
  1873. chatHistory = chatHistory.slice(0, 20);
  1874. fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
  1875. ctx.body = aiView(chatHistory, userPrompt);
  1876. })
  1877. .post('/ai/approve', koaBody(), async (ctx) => {
  1878. const ts = String(ctx.request.body.ts || '');
  1879. const custom = String(ctx.request.body.custom || '').trim();
  1880. const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
  1881. let chatHistory = [];
  1882. try {
  1883. const fileData = fs.readFileSync(historyPath, 'utf-8');
  1884. chatHistory = JSON.parse(fileData);
  1885. } catch {
  1886. chatHistory = [];
  1887. }
  1888. const item = chatHistory.find(e => String(e.timestamp) === ts);
  1889. if (item) {
  1890. try {
  1891. if (custom) item.answer = stripDangerousTags(custom);
  1892. item.type = 'aiExchange';
  1893. let snippets = fieldsForSnippet('aiExchange', item);
  1894. if (snippets.length === 0) {
  1895. const context = await buildContext();
  1896. snippets = [context];
  1897. } else {
  1898. snippets = snippets.map(snippet => clip(snippet, 200));
  1899. }
  1900. await publishExchange({
  1901. q: item.question,
  1902. a: item.answer,
  1903. ctx: snippets,
  1904. tokens: {}
  1905. });
  1906. item.trainStatus = 'approved';
  1907. } catch {
  1908. item.trainStatus = 'failed';
  1909. }
  1910. fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
  1911. }
  1912. const config = getConfig();
  1913. const userPrompt = config.ai?.prompt?.trim() || '';
  1914. ctx.body = aiView(chatHistory, userPrompt);
  1915. })
  1916. .post('/ai/reject', koaBody(), async (ctx) => {
  1917. const i18nAll = require('../client/assets/translations/i18n');
  1918. const lang = ctx.cookies.get('language') || getConfig().language || 'en';
  1919. const { setLanguage } = require('../views/main_views');
  1920. setLanguage(lang);
  1921. const ts = String(ctx.request.body.ts || '');
  1922. const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
  1923. let chatHistory = [];
  1924. try {
  1925. const fileData = fs.readFileSync(historyPath, 'utf-8');
  1926. chatHistory = JSON.parse(fileData);
  1927. } catch {
  1928. chatHistory = [];
  1929. }
  1930. const item = chatHistory.find(e => String(e.timestamp) === ts);
  1931. if (item) {
  1932. item.trainStatus = 'rejected';
  1933. fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
  1934. }
  1935. const config = getConfig();
  1936. const userPrompt = config.ai?.prompt?.trim() || '';
  1937. ctx.body = aiView(chatHistory, userPrompt);
  1938. })
  1939. .post('/ai/clear', async (ctx) => {
  1940. const i18nAll = require('../client/assets/translations/i18n');
  1941. const lang = ctx.cookies.get('language') || getConfig().language || 'en';
  1942. const { setLanguage } = require('../views/main_views');
  1943. setLanguage(lang);
  1944. const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
  1945. fs.writeFileSync(historyPath, '[]', 'utf-8');
  1946. const config = getConfig();
  1947. const userPrompt = config.ai?.prompt?.trim() || '';
  1948. ctx.body = aiView([], userPrompt);
  1949. })
  1950. .post('/pixelia/paint', koaBody(), async (ctx) => {
  1951. const x = Number(ctx.request.body.x), y = Number(ctx.request.body.y), color = ctx.request.body.color;
  1952. if (!Number.isFinite(x) || !Number.isFinite(y) || x < 1 || x > 50 || y < 1 || y > 200) {
  1953. const errorMessage = 'Coordinates are wrong!';
  1954. const pixelArt = await pixeliaModel.listPixels();
  1955. ctx.body = pixeliaView(pixelArt, errorMessage);
  1956. return;
  1957. }
  1958. await pixeliaModel.paintPixel(x, y, color);
  1959. ctx.redirect('/pixelia');
  1960. })
  1961. .post('/pm', koaBody(), async ctx => {
  1962. const { recipients, subject, text } = ctx.request.body;
  1963. const recipientsArr = (recipients || '').split(',').map(s => s.trim()).filter(Boolean);
  1964. await pmModel.sendMessage(recipientsArr, subject, text);
  1965. await refreshInboxCount();
  1966. ctx.redirect('/inbox?filter=sent');
  1967. })
  1968. .post('/pm/preview', koaBody(), async ctx => {
  1969. const { recipients = '', subject = '', text = '' } = ctx.request.body;
  1970. ctx.body = await pmView(recipients, subject, text, true);
  1971. })
  1972. .post('/inbox/delete/:id', koaBody(), async ctx => {
  1973. await pmModel.deleteMessageById(ctx.params.id);
  1974. await refreshInboxCount();
  1975. ctx.redirect('/inbox');
  1976. })
  1977. .post("/search", koaBody(), async (ctx) => {
  1978. const b = ctx.request.body, query = b.query || "";
  1979. let types = b.type || [];
  1980. if (typeof types === "string") types = [types];
  1981. if (!Array.isArray(types)) types = [];
  1982. if (!query) return ctx.body = await searchView({ messages: [], query, types });
  1983. const results = await searchModel.search({ query, types });
  1984. ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
  1985. acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
  1986. return acc;
  1987. }, {}), query, types });
  1988. })
  1989. .post("/subtopic/preview/:message",
  1990. koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }),
  1991. async (ctx) => {
  1992. const { message } = ctx.params;
  1993. const rootMessage = await post.get(message);
  1994. const myFeedId = await meta.myFeedId();
  1995. const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning).trim());
  1996. const contentWarning =
  1997. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  1998. const messages = [rootMessage];
  1999. const previewData = await preparePreview(ctx);
  2000. ctx.body = await previewSubtopicView({
  2001. messages,
  2002. myFeedId,
  2003. previewData,
  2004. contentWarning,
  2005. });
  2006. }
  2007. )
  2008. .post("/subtopic/:message", koaBody(), async (ctx) => {
  2009. const { message } = ctx.params;
  2010. const text = stripDangerousTags(String(ctx.request.body.text));
  2011. const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning).trim());
  2012. const contentWarning =
  2013. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  2014. const publishSubtopic = async ({ message, text }) => {
  2015. const mentions = extractMentions(text);
  2016. const parent = await post.get(message);
  2017. return post.subtopic({
  2018. parent,
  2019. message: { text, mentions, contentWarning },
  2020. });
  2021. };
  2022. ctx.body = await publishSubtopic({ message, text });
  2023. ctx.redirect(`/thread/${encodeURIComponent(message)}`);
  2024. })
  2025. .post("/comment/preview/:message", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2026. const { messages, contentWarning, myFeedId, parentMessage } = await resolveCommentComponents(ctx);
  2027. const previewData = await preparePreview(ctx);
  2028. ctx.body = await previewCommentView({
  2029. messages,
  2030. myFeedId,
  2031. contentWarning,
  2032. previewData,
  2033. parentMessage,
  2034. });
  2035. })
  2036. .post("/comment/:message", koaBody(), async (ctx) => {
  2037. let decodedMessage;
  2038. try {
  2039. decodedMessage = decodeURIComponent(ctx.params.message);
  2040. } catch {
  2041. decodedMessage = ctx.params.message;
  2042. }
  2043. const text = stripDangerousTags(String(ctx.request.body.text));
  2044. const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning));
  2045. const contentWarning =
  2046. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  2047. let mentions = extractMentions(text);
  2048. if (!Array.isArray(mentions)) mentions = [];
  2049. const parent = await meta.get(decodedMessage);
  2050. ctx.body = await post.comment({
  2051. parent,
  2052. message: {
  2053. text,
  2054. mentions,
  2055. contentWarning
  2056. },
  2057. });
  2058. ctx.redirect(`/thread/${encodeURIComponent(parent.key)}`);
  2059. })
  2060. .post("/publish/preview", koaBody({multipart: true, formidable: { multiples: false, maxFileSize: maxSize }, urlencoded: true }), async (ctx) => {
  2061. const cw = stripDangerousTags(ctx.request.body.contentWarning?.toString().trim() || "");
  2062. ctx.body = await previewView({ previewData: await preparePreview(ctx), contentWarning: cw.length > 0 ? cw : undefined });
  2063. })
  2064. .post("/publish", koaBody({ multipart: true, urlencoded: true, formidable: { multiples: false, maxFileSize: maxSize } }), async (ctx) => {
  2065. const b = ctx.request.body, text = stripDangerousTags(b.text?.toString().trim() || ""), cw = stripDangerousTags(b.contentWarning?.toString().trim() || "");
  2066. let mentions = [];
  2067. try { mentions = JSON.parse(b.mentions || "[]"); } catch { mentions = await extractMentions(text); }
  2068. await post.root({ text, mentions, contentWarning: cw.length > 0 ? cw : undefined });
  2069. ctx.redirect("/public/latest");
  2070. })
  2071. .post("/publish/custom", koaBody(), async (ctx) => {
  2072. const text = String(ctx.request.body.text);
  2073. const obj = JSON.parse(text);
  2074. ctx.body = await post.publishCustom(obj);
  2075. ctx.redirect(`/public/latest`);
  2076. })
  2077. .post("/follow/:feed", koaBody(), async (ctx) => {
  2078. ctx.body = await friend.follow(ctx.params.feed);
  2079. ctx.redirect(new URL(ctx.request.header.referer).href);
  2080. })
  2081. .post("/unfollow/:feed", koaBody(), async (ctx) => {
  2082. ctx.body = await friend.unfollow(ctx.params.feed);
  2083. ctx.redirect(new URL(ctx.request.header.referer).href);
  2084. })
  2085. .post("/block/:feed", koaBody(), async (ctx) => {
  2086. ctx.body = await friend.block(ctx.params.feed);
  2087. ctx.redirect(new URL(ctx.request.header.referer).href);
  2088. })
  2089. .post("/unblock/:feed", koaBody(), async (ctx) => {
  2090. ctx.body = await friend.unblock(ctx.params.feed);
  2091. ctx.redirect(new URL(ctx.request.header.referer).href);
  2092. })
  2093. .post("/like/:message", koaBody(), async (ctx) => {
  2094. const { message } = ctx.params, voteValue = Number(ctx.request.body.voteValue);
  2095. const referer = new URL(ctx.request.header.referer);
  2096. referer.hash = `centered-footer-${encodeURIComponent(message)}`;
  2097. const msgData = await post.get(message);
  2098. const isPrivate = msgData.value.meta.private === true;
  2099. const normalized = (isPrivate ? msgData.value.content.recps : []).map(r => typeof r === 'string' ? r : r?.link).filter(Boolean);
  2100. ctx.body = await vote.publish({ messageKey: message, value: voteValue, recps: normalized.length ? normalized : undefined });
  2101. ctx.redirect(referer.href);
  2102. })
  2103. .post('/forum/create', koaBody(), async ctx => {
  2104. const { category, title, text } = ctx.request.body;
  2105. await forumModel.createForum(category, stripDangerousTags(title), stripDangerousTags(text));
  2106. ctx.redirect('/forum');
  2107. })
  2108. .post('/forum/:id/message', koaBody(), async ctx => {
  2109. const { message, parentId } = ctx.request.body;
  2110. const cleanedMsg = stripDangerousTags(message);
  2111. const mentions = await extractMentions(cleanedMsg);
  2112. await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined }, parentId);
  2113. ctx.redirect(`/forum/${encodeURIComponent(ctx.params.id)}`);
  2114. })
  2115. .post('/forum/:forumId/vote', koaBody(), async ctx => {
  2116. await forumModel.voteContent(ctx.request.body.target, parseInt(ctx.request.body.value, 10));
  2117. ctx.redirect(ctx.get('referer') || `/forum/${encodeURIComponent(ctx.params.forumId)}`);
  2118. })
  2119. .post('/forum/delete/:id', koaBody(), async ctx => {
  2120. await forumModel.deleteForumById(ctx.params.id);
  2121. ctx.redirect('/forum');
  2122. })
  2123. .post('/legacy/export', koaBody(), async (ctx) => {
  2124. const pw = ctx.request.body.password;
  2125. if (!pw || pw.length < 32) return ctx.redirect('/legacy');
  2126. try {
  2127. ctx.body = { message: 'Data exported successfully!', file: await legacyModel.exportData({ password: pw }) };
  2128. ctx.redirect('/legacy');
  2129. } catch (error) { ctx.status = 500; ctx.body = { error: `Error: ${error.message}` }; ctx.redirect('/legacy'); }
  2130. })
  2131. .post('/legacy/import', koaBody({
  2132. multipart: true,
  2133. formidable: {
  2134. keepExtensions: true,
  2135. uploadDir: '/tmp',
  2136. }
  2137. }), async (ctx) => {
  2138. const uploadedFile = ctx.request.files?.uploadedFile, pw = ctx.request.body.importPassword;
  2139. if (!uploadedFile) { ctx.body = { error: 'No file uploaded' }; return ctx.redirect('/legacy'); }
  2140. if (!pw || pw.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/legacy'); }
  2141. try {
  2142. await legacyModel.importData({ filePath: uploadedFile.filepath, password: pw });
  2143. ctx.body = { message: 'Data imported successfully!' };
  2144. ctx.redirect('/legacy');
  2145. } catch (error) { ctx.body = { error: error.message }; ctx.redirect('/legacy'); }
  2146. })
  2147. .post('/trending/:contentId/:category', async (ctx) => {
  2148. const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id;
  2149. if ((await trendingModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) {
  2150. ctx.flash = { message: 'You have already opined.' }; return ctx.redirect('/trending');
  2151. }
  2152. await trendingModel.createVote(contentId, category); ctx.redirect('/trending');
  2153. })
  2154. .post('/opinions/:contentId/:category', async (ctx) => {
  2155. const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id;
  2156. if ((await opinionsModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) {
  2157. ctx.flash = { message: 'You have already opined.' }; return ctx.redirect('/opinions');
  2158. }
  2159. await opinionsModel.createVote(contentId, category); ctx.redirect('/opinions');
  2160. })
  2161. .post('/agenda/discard/:itemId', async (ctx) => {
  2162. await agendaModel.discardItem(ctx.params.itemId); ctx.redirect('/agenda');
  2163. })
  2164. .post('/agenda/restore/:itemId', async (ctx) => {
  2165. await agendaModel.restoreItem(ctx.params.itemId); ctx.redirect('/agenda?filter=discarded');
  2166. })
  2167. .post("/feed/create", koaBody(), async (ctx) => {
  2168. const text = ctx.request.body?.text != null ? stripDangerousTags(String(ctx.request.body.text)) : "";
  2169. const mentions = await extractMentions(text);
  2170. await feedModel.createFeed(text, mentions);
  2171. ctx.redirect("/feed?filter=ALL&msg=feedPublished");
  2172. })
  2173. .post("/feed/opinions/:feedId/:category", async (ctx) => {
  2174. const { feedId, category } = ctx.params;
  2175. try {
  2176. await feedModel.addOpinion(feedId, category);
  2177. } catch { /* already voted or invalid — ignore */ }
  2178. ctx.redirect(ctx.get("Referer") || "/feed");
  2179. })
  2180. .post("/feed/refeed/:id", koaBody(), async (ctx) => {
  2181. await feedModel.createRefeed(ctx.params.id);
  2182. ctx.redirect(ctx.get("Referer") || "/feed");
  2183. })
  2184. .post("/feed/:feedId/comments", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2185. const text = ctx.request.body?.text != null ? stripDangerousTags(String(ctx.request.body.text)) : "";
  2186. const imageMarkdown = ctx.request.files?.blob ? await handleBlobUpload(ctx, 'blob') : null;
  2187. const fullText = imageMarkdown ? (text ? text + '\n' : '') + imageMarkdown : text;
  2188. await feedModel.addComment(ctx.params.feedId, fullText);
  2189. ctx.redirect(`/feed/${encodeURIComponent(ctx.params.feedId)}`);
  2190. })
  2191. .post("/bookmarks/create", koaBody(), async (ctx) => {
  2192. if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; }
  2193. const b = ctx.request.body;
  2194. await bookmarksModel.createBookmark(stripDangerousTags(b.url), b.tags, stripDangerousTags(b.description), b.category, b.lastVisit);
  2195. ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=all', ['/bookmarks']));
  2196. })
  2197. .post("/bookmarks/update/:id", koaBody(), async (ctx) => {
  2198. if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; }
  2199. const b = ctx.request.body;
  2200. await bookmarksModel.updateBookmarkById(ctx.params.id, { url: stripDangerousTags(b.url), tags: b.tags, description: stripDangerousTags(b.description), category: b.category, lastVisit: b.lastVisit });
  2201. ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=mine', ['/bookmarks']));
  2202. })
  2203. .post("/bookmarks/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'bookmarks'))
  2204. .post("/bookmarks/opinions/:bookmarkId/:category", koaBody(), async ctx => opinionAction(ctx, 'bookmarks', 'bookmarkId'))
  2205. .post("/bookmarks/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'bookmarks', 'add'))
  2206. .post("/bookmarks/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'bookmarks', 'remove'))
  2207. .post("/bookmarks/:bookmarkId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'bookmarks', 'bookmarkId'))
  2208. .post("/images/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2209. if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
  2210. const blob = await handleBlobUpload(ctx, 'image'), b = ctx.request.body;
  2211. await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme));
  2212. ctx.redirect(safeReturnTo(ctx, '/images?filter=all', ['/images']));
  2213. })
  2214. .post("/images/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2215. if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
  2216. const b = ctx.request.body, blob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
  2217. await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme));
  2218. ctx.redirect(safeReturnTo(ctx, '/images?filter=mine', ['/images']));
  2219. })
  2220. .post("/images/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'images'))
  2221. .post("/images/opinions/:imageId/:category", koaBody(), async ctx => opinionAction(ctx, 'images', 'imageId'))
  2222. .post("/images/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'images', 'add'))
  2223. .post("/images/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'images', 'remove'))
  2224. .post("/images/:imageId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'images', 'imageId'))
  2225. .post("/audios/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaCreateAction(ctx, 'audios'))
  2226. .post("/audios/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaUpdateAction(ctx, 'audios'))
  2227. .post("/audios/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'audios'))
  2228. .post("/audios/opinions/:audioId/:category", koaBody(), async ctx => opinionAction(ctx, 'audios', 'audioId'))
  2229. .post("/audios/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'audios', 'add'))
  2230. .post("/audios/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'audios', 'remove'))
  2231. .post("/audios/:audioId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'audios', 'audioId'))
  2232. .post("/videos/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaCreateAction(ctx, 'videos'))
  2233. .post("/videos/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaUpdateAction(ctx, 'videos'))
  2234. .post("/videos/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'videos'))
  2235. .post("/videos/opinions/:videoId/:category", koaBody(), async ctx => opinionAction(ctx, 'videos', 'videoId'))
  2236. .post("/videos/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'videos', 'add'))
  2237. .post("/videos/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'videos', 'remove'))
  2238. .post("/videos/:videoId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'videos', 'videoId'))
  2239. .post("/documents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2240. const docBlob = await handleBlobUpload(ctx, "document"), b = ctx.request.body;
  2241. await documentsModel.createDocument(docBlob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description));
  2242. ctx.redirect(safeReturnTo(ctx, "/documents?filter=all", ["/documents"]));
  2243. })
  2244. .post("/documents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2245. const b = ctx.request.body, blob = ctx.request.files?.document ? await handleBlobUpload(ctx, "document") : null;
  2246. await documentsModel.updateDocumentById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description));
  2247. ctx.redirect(safeReturnTo(ctx, "/documents?filter=mine", ["/documents"]));
  2248. })
  2249. .post("/documents/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'documents'))
  2250. .post("/documents/opinions/:documentId/:category", koaBody(), async ctx => opinionAction(ctx, 'documents', 'documentId'))
  2251. .post("/documents/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'documents', 'add'))
  2252. .post("/documents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'documents', 'remove'))
  2253. .post("/documents/:documentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'documents', 'documentId'))
  2254. .post('/cv/upload', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2255. const photoUrl = await handleBlobUpload(ctx, 'image')
  2256. await cvModel.createCV(ctx.request.body, photoUrl)
  2257. ctx.redirect('/cv')
  2258. })
  2259. .post('/cv/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2260. const photoUrl = await handleBlobUpload(ctx, 'image')
  2261. await cvModel.updateCV(ctx.params.id, ctx.request.body, photoUrl)
  2262. ctx.redirect('/cv')
  2263. })
  2264. .post('/cv/delete/:id', async ctx => {
  2265. await cvModel.deleteCVById(ctx.params.id)
  2266. ctx.redirect('/cv')
  2267. })
  2268. .post('/cipher/encrypt', koaBody(), async (ctx) => {
  2269. const { text, password } = ctx.request.body;
  2270. if (password.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/cipher'); }
  2271. const { encryptedText, iv } = cipherModel.encryptData(text, password);
  2272. ctx.body = await cipherView(encryptedText, "", iv, password);
  2273. })
  2274. .post('/cipher/decrypt', koaBody(), async (ctx) => {
  2275. const { encryptedText, password } = ctx.request.body;
  2276. if (password.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/cipher'); }
  2277. ctx.body = await cipherView("", cipherModel.decryptData(encryptedText, password), "", password);
  2278. })
  2279. .post('/tribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2280. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2281. const b = ctx.request.body;
  2282. if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2283. if (!['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; }
  2284. const image = await handleBlobUpload(ctx, 'image');
  2285. await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode);
  2286. ctx.redirect('/tribes');
  2287. })
  2288. .post('/tribe/:id/subtribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2289. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2290. const parentTribe = await tribesModel.getTribeById(ctx.params.id);
  2291. const viewerId = getViewerId();
  2292. const canCreate = parentTribe.inviteMode === 'open'
  2293. ? parentTribe.members.includes(viewerId)
  2294. : parentTribe.author === viewerId;
  2295. if (!canCreate) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`); return; }
  2296. const b = ctx.request.body;
  2297. if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2298. const image = await handleBlobUpload(ctx, 'image');
  2299. 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);
  2300. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`);
  2301. })
  2302. .post('/tribes/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2303. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2304. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2305. if (tribe.author !== getViewerId()) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2306. const b = ctx.request.body;
  2307. if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2308. if (b.inviteMode && !['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; }
  2309. const tags = b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
  2310. 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' });
  2311. ctx.redirect('/tribes?filter=mine');
  2312. })
  2313. .post('/tribes/delete/:id', async ctx => {
  2314. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2315. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2316. if (tribe.author !== getViewerId()) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2317. await tribesModel.deleteTribeById(ctx.params.id)
  2318. ctx.redirect('/tribes?filter=mine')
  2319. })
  2320. .post('/tribes/generate-invite', koaBody(), async ctx => {
  2321. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2322. ctx.body = await renderInvitePage(await tribesModel.generateInvite(ctx.request.body.tribeId));
  2323. })
  2324. .post('/tribes/join-code', koaBody(), async ctx => {
  2325. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2326. await tribesModel.joinByInvite(ctx.request.body.inviteCode)
  2327. ctx.redirect('/tribes?filter=membership')
  2328. })
  2329. .post('/tribes/leave/:id', koaBody(), async ctx => {
  2330. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2331. await tribesModel.leaveTribe(ctx.params.id)
  2332. ctx.redirect('/tribes?filter=membership')
  2333. })
  2334. .post('/tribe/:id/message', koaBody(), async ctx => {
  2335. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2336. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2337. const uid = getViewerId();
  2338. if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2339. if (tooLong(ctx, ctx.request.body.message, MAX_TEXT_LENGTH, 'Text')) return;
  2340. const message = stripDangerousTags((ctx.request.body.message || '').trim());
  2341. if (!message || message.length === 0 || message.length > 280) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed`); return; }
  2342. await tribesContentModel.create(tribe.id, 'feed', { description: await resolveMentionText(message) });
  2343. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed&sent=1`);
  2344. })
  2345. .post('/tribe/:id/refeed/:msgId', koaBody(), async ctx => {
  2346. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2347. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2348. const uid = getViewerId();
  2349. if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2350. await tribesContentModel.toggleRefeed(ctx.params.msgId);
  2351. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed`);
  2352. })
  2353. .post('/tribe/:id/events/create', koaBody(), async ctx => {
  2354. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2355. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2356. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2357. const b = ctx.request.body;
  2358. if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2359. if (b.date && b.date < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events&action=create`); return; }
  2360. 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()] });
  2361. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events`);
  2362. })
  2363. .post('/tribe/:id/events/attend/:eventId', koaBody(), async ctx => {
  2364. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2365. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2366. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2367. await tribesContentModel.toggleAttendee(ctx.params.eventId);
  2368. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events`);
  2369. })
  2370. .post('/tribe/:id/tasks/create', koaBody(), async ctx => {
  2371. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2372. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2373. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2374. const b = ctx.request.body;
  2375. if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2376. if (b.deadline && b.deadline < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks&action=create`); return; }
  2377. await tribesContentModel.create(tribe.id, 'task', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), priority: b.priority, deadline: b.deadline, assignees: [] });
  2378. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`);
  2379. })
  2380. .post('/tribe/:id/tasks/assign/:taskId', koaBody(), async ctx => {
  2381. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2382. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2383. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2384. await tribesContentModel.toggleAssignee(ctx.params.taskId);
  2385. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`);
  2386. })
  2387. .post('/tribe/:id/tasks/status/:taskId', koaBody(), async ctx => {
  2388. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2389. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2390. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2391. const item = await tribesContentModel.getById(ctx.params.taskId);
  2392. if (!item || item.author !== getViewerId()) { ctx.status = 403; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`); return; }
  2393. await tribesContentModel.updateStatus(ctx.params.taskId, ctx.request.body.status);
  2394. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`);
  2395. })
  2396. .post('/tribe/:id/votations/create', koaBody(), async ctx => {
  2397. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2398. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2399. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2400. const b = ctx.request.body;
  2401. if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2402. if (b.deadline && b.deadline < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations&action=create`); return; }
  2403. const options = [b.option1, b.option2, b.option3, b.option4].filter(Boolean).map(o => stripDangerousTags(o));
  2404. await tribesContentModel.create(tribe.id, 'votation', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), deadline: b.deadline, options, votes: {} });
  2405. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`);
  2406. })
  2407. .post('/tribe/:id/votations/:voteId/vote', koaBody(), async ctx => {
  2408. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2409. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2410. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2411. await tribesContentModel.castVote(ctx.params.voteId, parseInt(ctx.request.body.optionIndex, 10));
  2412. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`);
  2413. })
  2414. .post('/tribe/:id/votations/close/:voteId', koaBody(), async ctx => {
  2415. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2416. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2417. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2418. const votation = await tribesContentModel.getById(ctx.params.voteId);
  2419. if (!votation || votation.author !== getViewerId()) { ctx.status = 403; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`); return; }
  2420. await tribesContentModel.updateStatus(ctx.params.voteId, 'CLOSED');
  2421. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`);
  2422. })
  2423. .post('/tribe/:id/forum/create', koaBody(), async ctx => {
  2424. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2425. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2426. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2427. const b = ctx.request.body;
  2428. if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2429. await tribesContentModel.create(tribe.id, 'forum', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), category: b.category });
  2430. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum`);
  2431. })
  2432. .post('/tribe/:id/forum/:forumId/reply', koaBody(), async ctx => {
  2433. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2434. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2435. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2436. const b = ctx.request.body;
  2437. if (tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2438. await tribesContentModel.create(tribe.id, 'forum-reply', { description: await resolveMentionText(stripDangerousTags(b.description)), parentId: ctx.params.forumId });
  2439. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum&thread=${encodeURIComponent(ctx.params.forumId)}`);
  2440. })
  2441. .post('/tribe/:id/forum/:forumId/refeed', koaBody(), async ctx => {
  2442. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2443. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2444. const uid = getViewerId();
  2445. if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2446. await tribesContentModel.toggleRefeed(ctx.params.forumId);
  2447. const thread = ctx.query.thread || '';
  2448. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum${thread ? '&thread=' + encodeURIComponent(thread) : ''}`);
  2449. })
  2450. .post('/tribe/:id/media/upload', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2451. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2452. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2453. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2454. const b = ctx.request.body;
  2455. if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
  2456. const returnSection = b.returnSection || 'media';
  2457. const mediaType = b.mediaType || 'image';
  2458. let blobRef = null;
  2459. if (mediaType === 'bookmark') {
  2460. const url = stripDangerousTags(b.url || '');
  2461. await tribesContentModel.create(tribe.id, 'media', { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), mediaType: 'bookmark', url });
  2462. } else {
  2463. const blobMarkdownMedia = await handleBlobUpload(ctx, 'media');
  2464. blobRef = blobMarkdownMedia ? ((blobMarkdownMedia.match(/\((&[^)]+)\)/) || [])[1] || blobMarkdownMedia) : null;
  2465. await tribesContentModel.create(tribe.id, 'media', { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), mediaType, image: blobRef });
  2466. }
  2467. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=${returnSection}`);
  2468. })
  2469. .post('/tribe/:id/content/delete/:contentId', koaBody(), async ctx => {
  2470. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2471. const tribeRedirect = `/tribe/${encodeURIComponent(ctx.params.id)}`;
  2472. const item = await tribesContentModel.getById(ctx.params.contentId);
  2473. if (!item || item.author !== getViewerId() || item.tribeId !== ctx.params.id) { ctx.status = 403; ctx.redirect(tribeRedirect); return; }
  2474. await tribesContentModel.deleteById(ctx.params.contentId);
  2475. ctx.redirect(tribeRedirect);
  2476. })
  2477. .post('/tribe/:id/content/:contentId/opinion/:category', koaBody(), async ctx => {
  2478. if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
  2479. const tribe = await tribesModel.getTribeById(ctx.params.id);
  2480. if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
  2481. const item = await tribesContentModel.getById(ctx.params.contentId);
  2482. if (!item || item.tribeId !== ctx.params.id) { ctx.status = 404; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=opinions`); return; }
  2483. try {
  2484. await tribesContentModel.castOpinion(ctx.params.contentId, ctx.params.category);
  2485. } catch (_) {}
  2486. ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=opinions`);
  2487. })
  2488. .post('/panic/remove', koaBody(), async (ctx) => {
  2489. const { exec } = require('child_process');
  2490. try {
  2491. await panicmodeModel.removeSSB();
  2492. ctx.body = { message: 'Your blockchain has been succesfully deleted!' };
  2493. exec('pkill -f "node SSB_server.js start"');
  2494. setTimeout(() => process.exit(0), 1000);
  2495. } catch (error) { ctx.body = { error: 'Error deleting your blockchain: ' + error.message }; }
  2496. })
  2497. .post('/export/create', async (ctx) => {
  2498. try {
  2499. const outputPath = path.join(os.homedir(), 'ssb_exported.zip');
  2500. await exportmodeModel.exportSSB(outputPath);
  2501. ctx.set('Content-Type', 'application/zip');
  2502. ctx.set('Content-Disposition', `attachment; filename=ssb_exported.zip`);
  2503. ctx.body = fs.createReadStream(outputPath);
  2504. ctx.res.on('finish', () => fs.unlinkSync(outputPath));
  2505. } catch (error) { ctx.body = { error: 'Error exporting your blockchain: ' + error.message }; }
  2506. })
  2507. .post('/tasks/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2508. const b = ctx.request.body;
  2509. const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
  2510. let desc = stripDangerousTags(b.description);
  2511. if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
  2512. await tasksModel.createTask(stripDangerousTags(b.title), desc, b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic);
  2513. ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
  2514. })
  2515. .post('/tasks/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2516. 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) : []);
  2517. const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
  2518. let desc = stripDangerousTags(b.description);
  2519. if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
  2520. 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 });
  2521. ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
  2522. })
  2523. .post('/tasks/assign/:id', koaBody(), async ctx => {
  2524. await tasksModel.toggleAssignee(ctx.params.id);
  2525. ctx.redirect(safeReturnTo(ctx, '/tasks', ['/tasks']));
  2526. })
  2527. .post('/tasks/delete/:id', koaBody(), async ctx => {
  2528. await tasksModel.deleteTaskById(ctx.params.id);
  2529. ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
  2530. })
  2531. .post('/tasks/status/:id', koaBody(), async ctx => {
  2532. await tasksModel.updateTaskStatus(ctx.params.id, ctx.request.body.status);
  2533. ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks']));
  2534. })
  2535. .post('/tasks/:taskId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'tasks', 'taskId'))
  2536. .post('/reports/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2537. const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image');
  2538. await reportsModel.createReport(stripDangerousTags(b.title), stripDangerousTags(b.description), b.category, image, b.tags, b.severity, {
  2539. stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate,
  2540. problemStatement: stripDangerousTags(b.problemStatement), userStory: stripDangerousTags(b.userStory), acceptanceCriteria: stripDangerousTags(b.acceptanceCriteria),
  2541. whatHappened: stripDangerousTags(b.whatHappened), reportedUser: b.reportedUser, evidenceLinks: stripDangerousTags(b.evidenceLinks),
  2542. contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate), requestedAction: stripDangerousTags(b.requestedAction)
  2543. });
  2544. ctx.redirect('/reports');
  2545. })
  2546. .post('/reports/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
  2547. const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image');
  2548. await reportsModel.updateReportById(ctx.params.id, {
  2549. title: b.title, description: b.description, category: b.category, image, tags: b.tags, severity: b.severity,
  2550. template: {
  2551. stepsToReproduce: b.stepsToReproduce, expectedBehavior: b.expectedBehavior, actualBehavior: b.actualBehavior, environment: b.environment, reproduceRate: b.reproduceRate,
  2552. problemStatement: b.problemStatement, userStory: b.userStory, acceptanceCriteria: b.acceptanceCriteria,
  2553. whatHappened: b.whatHappened, reportedUser: b.reportedUser, evidenceLinks: b.evidenceLinks,
  2554. contentLocation: b.contentLocation, whyInappropriate: b.whyInappropriate, requestedAction: b.requestedAction
  2555. }
  2556. });
  2557. ctx.redirect('/reports?filter=mine');
  2558. })
  2559. .post('/reports/delete/:id', async ctx => {
  2560. await reportsModel.deleteReportById(ctx.params.id);
  2561. ctx.redirect('/reports?filter=mine');
  2562. })
  2563. .post('/reports/confirm/:id', async ctx => {
  2564. await reportsModel.confirmReportById(ctx.params.id);
  2565. ctx.redirect('/reports');
  2566. })
  2567. .post('/reports/status/:id', koaBody(), async ctx => {
  2568. await reportsModel.updateReportById(ctx.params.id, { status: ctx.request.body.status });
  2569. ctx.redirect('/reports?filter=mine');
  2570. })
  2571. .post('/reports/:reportId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'reports', 'reportId'))
  2572. .post('/events/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2573. const b = ctx.request.body;
  2574. const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
  2575. let desc = stripDangerousTags(b.description);
  2576. if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
  2577. await eventsModel.createEvent(stripDangerousTags(b.title), desc, b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic);
  2578. ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events']));
  2579. })
  2580. .post('/events/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2581. const b = ctx.request.body, existing = await eventsModel.getEventById(ctx.params.id);
  2582. const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
  2583. let desc = stripDangerousTags(b.description);
  2584. if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown;
  2585. 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 });
  2586. ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events']));
  2587. })
  2588. .post('/events/attend/:id', koaBody(), async ctx => {
  2589. await eventsModel.toggleAttendee(ctx.params.id);
  2590. ctx.redirect(safeReturnTo(ctx, '/events', ['/events']));
  2591. })
  2592. .post('/events/delete/:id', koaBody(), async ctx => {
  2593. await eventsModel.deleteEventById(ctx.params.id);
  2594. ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events']));
  2595. })
  2596. .post('/events/:eventId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'events', 'eventId'))
  2597. .post('/votes/create', koaBody(), async ctx => {
  2598. const b = ctx.request.body, defaultOptions = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
  2599. const parsedOptions = b.options ? b.options.split(',').map(o => o.trim()).filter(Boolean) : defaultOptions;
  2600. await votesModel.createVote(stripDangerousTags(b.question), b.deadline, parsedOptions, String(b.tags || '').split(',').map(t => t.trim()).filter(Boolean));
  2601. ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes']));
  2602. })
  2603. .post('/votes/update/:id', koaBody(), async ctx => {
  2604. const b = ctx.request.body, parsedOptions = b.options ? b.options.split(',').map(o => o.trim()).filter(Boolean) : undefined;
  2605. 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) : [] });
  2606. ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes']));
  2607. })
  2608. .post('/votes/delete/:id', koaBody(), async ctx => {
  2609. await votesModel.deleteVoteById(ctx.params.id);
  2610. ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes']));
  2611. })
  2612. .post('/votes/vote/:id', koaBody(), async ctx => {
  2613. await votesModel.voteOnVote(ctx.params.id, ctx.request.body.choice);
  2614. ctx.redirect(safeReturnTo(ctx, '/votes?filter=open', ['/votes']));
  2615. })
  2616. .post('/votes/opinions/:voteId/:category', koaBody(), async ctx => {
  2617. try { await votesModel.createOpinion(ctx.params.voteId, ctx.params.category); }
  2618. catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; }
  2619. ctx.redirect(safeReturnTo(ctx, '/votes', ['/votes']));
  2620. })
  2621. .post('/votes/:voteId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'votes', 'voteId'))
  2622. .post('/parliament/candidatures/propose', koaBody(), async (ctx) => {
  2623. const b = ctx.request.body || {}, id = String(b.candidateId || '').trim(), m = String(b.method || '').trim().toUpperCase();
  2624. if (!id) ctx.throw(400, 'Candidate is required.');
  2625. if (!new Set(['DEMOCRACY','MAJORITY','MINORITY','DICTATORSHIP','KARMATOCRACY']).has(m)) ctx.throw(400, 'Invalid method.');
  2626. await parliamentModel.proposeCandidature({ candidateId: id, method: m }).catch(e => ctx.throw(400, String(e?.message || e)));
  2627. ctx.redirect('/parliament?filter=candidatures');
  2628. })
  2629. .post('/parliament/candidatures/:id/vote', koaBody(), async (ctx) => {
  2630. await parliamentModel.voteCandidature(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
  2631. ctx.redirect('/parliament?filter=candidatures');
  2632. })
  2633. .post('/parliament/proposals/create', koaBody(), async (ctx) => {
  2634. const b = ctx.request.body || {}, t = String(b.title || '').trim(), d = String(b.description || '').trim();
  2635. if (!t) ctx.throw(400, 'Title is required.');
  2636. if (d.length > 1000) ctx.throw(400, 'Description must be ≤ 1000 chars.');
  2637. await parliamentModel.createProposal({ title: stripDangerousTags(t), description: stripDangerousTags(d) }).catch(e => ctx.throw(400, String(e?.message || e)));
  2638. ctx.redirect('/parliament?filter=proposals');
  2639. })
  2640. .post('/parliament/proposals/close/:id', koaBody(), async (ctx) => {
  2641. await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
  2642. ctx.redirect('/parliament?filter=proposals');
  2643. })
  2644. .post('/parliament/resolve', koaBody(), async (ctx) => {
  2645. await ensureTerm();
  2646. ctx.redirect('/parliament?filter=government');
  2647. })
  2648. .post('/parliament/revocations/create', koaBody(), async (ctx) => {
  2649. const b = ctx.request.body || {}, rawLawId = Array.isArray(b.lawId) ? b.lawId[0] : (b.lawId ?? b['lawId[]'] ?? b.law_id ?? '');
  2650. const lawId = String(rawLawId || '').trim();
  2651. if (!lawId) ctx.throw(400, 'Law required');
  2652. await parliamentModel.createRevocation({ lawId, title: b.title, reasons: b.reasons });
  2653. ctx.redirect('/parliament?filter=revocations');
  2654. })
  2655. .post('/courts/cases/create', koaBody(), async (ctx) => {
  2656. const b = ctx.request.body || {}, titleSuffix = String(b.titleSuffix || '').trim(), titlePreset = String(b.titlePreset || '').trim();
  2657. const respondent = String(b.respondentId || '').trim(), method = String(b.method || '').trim().toUpperCase();
  2658. if (!titleSuffix && !titlePreset) { ctx.flash = { message: 'Title is required.' }; return ctx.redirect('/courts?filter=cases'); }
  2659. if (!respondent) { ctx.flash = { message: 'Accused / Respondent is required.' }; return ctx.redirect('/courts?filter=cases'); }
  2660. 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'); }
  2661. if (!new Set(['JUDGE','DICTATOR','POPULAR','MEDIATION','KARMATOCRACY']).has(method)) { ctx.flash = { message: 'Invalid resolution method.' }; return ctx.redirect('/courts?filter=cases'); }
  2662. try { await courtsModel.openCase({ titleBase: [titlePreset, titleSuffix].filter(Boolean).join(' - '), respondentInput: respondent, method }); }
  2663. catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2664. ctx.redirect('/courts?filter=mycases');
  2665. })
  2666. .post('/courts/cases/:id/evidence/add', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2667. const caseId = ctx.params.id, b = ctx.request.body || {};
  2668. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2669. try { await courtsModel.addEvidence({ caseId, text: stripDangerousTags(String(b.text || '')), link: String(b.link || ''), imageMarkdown: ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null }); }
  2670. catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2671. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2672. })
  2673. .post('/courts/cases/:id/answer', koaBody(), async (ctx) => {
  2674. const caseId = ctx.params.id, b = ctx.request.body || {}, answer = String(b.answer || ''), stance = String(b.stance || '').toUpperCase();
  2675. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2676. if (!answer) { ctx.flash = { message: 'Response brief is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2677. if (!new Set(['DENY','ADMIT','PARTIAL']).has(stance)) { ctx.flash = { message: 'Invalid stance.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2678. try { await courtsModel.answerCase({ caseId, stance, text: stripDangerousTags(answer) }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2679. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2680. })
  2681. .post('/courts/cases/:id/decide', koaBody(), async (ctx) => {
  2682. const caseId = ctx.params.id, b = ctx.request.body || {}, result = String(b.outcome || '').trim(), orders = String(b.orders || '');
  2683. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2684. if (!result) { ctx.flash = { message: 'Result is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2685. try { await courtsModel.issueVerdict({ caseId, result, orders }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2686. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2687. })
  2688. .post('/courts/cases/:id/settlements/propose', koaBody(), async (ctx) => {
  2689. const caseId = ctx.params.id, terms = String(ctx.request.body?.terms || '');
  2690. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2691. if (!terms) { ctx.flash = { message: 'Terms are required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2692. try { await courtsModel.proposeSettlement({ caseId, terms }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2693. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2694. })
  2695. .post('/courts/cases/:id/settlements/accept', koaBody(), async (ctx) => {
  2696. const caseId = ctx.params.id;
  2697. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2698. try { await courtsModel.acceptSettlement({ caseId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2699. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2700. })
  2701. .post('/courts/cases/:id/mediators/accuser', koaBody(), async (ctx) => {
  2702. const caseId = ctx.params.id, mediators = String(ctx.request.body?.mediators || '').split(',').map(s => s.trim()).filter(Boolean);
  2703. const uid = ctx.state?.user?.id;
  2704. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2705. if (!mediators.length) { ctx.flash = { message: 'At least one mediator is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2706. if (uid && mediators.includes(uid)) { ctx.flash = { message: 'You cannot appoint yourself as mediator.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2707. try { await courtsModel.setMediators({ caseId, side: 'accuser', mediators }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2708. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2709. })
  2710. .post('/courts/cases/:id/mediators/respondent', koaBody(), async (ctx) => {
  2711. const caseId = ctx.params.id, mediators = String(ctx.request.body?.mediators || '').split(',').map(s => s.trim()).filter(Boolean);
  2712. const uid = ctx.state?.user?.id;
  2713. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2714. if (!mediators.length) { ctx.flash = { message: 'At least one mediator is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2715. if (uid && mediators.includes(uid)) { ctx.flash = { message: 'You cannot appoint yourself as mediator.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2716. try { await courtsModel.setMediators({ caseId, side: 'respondent', mediators }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2717. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2718. })
  2719. .post('/courts/cases/:id/judge', koaBody(), async (ctx) => {
  2720. const caseId = ctx.params.id, judgeId = String(ctx.request.body?.judgeId || '').trim(), uid = ctx.state?.user?.id;
  2721. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2722. if (!judgeId) { ctx.flash = { message: 'Judge is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2723. if (uid && judgeId === uid) { ctx.flash = { message: 'You cannot assign yourself as judge.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2724. try { await courtsModel.assignJudge({ caseId, judgeId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2725. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2726. })
  2727. .post('/courts/cases/:id/public', koaBody(), async (ctx) => {
  2728. const caseId = ctx.params.id, pref = String(ctx.request.body?.preference || '').toUpperCase();
  2729. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2730. if (pref !== 'YES' && pref !== 'NO') { ctx.flash = { message: 'Invalid visibility preference.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); }
  2731. try { await courtsModel.setPublicPreference({ caseId, preference: pref === 'YES' }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2732. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2733. })
  2734. .post('/courts/cases/:id/openVote', koaBody(), async (ctx) => {
  2735. const caseId = ctx.params.id;
  2736. if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); }
  2737. try { await courtsModel.openPopularVote({ caseId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2738. ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`);
  2739. })
  2740. .post('/courts/judges/nominate', koaBody(), async (ctx) => {
  2741. const judgeId = String(ctx.request.body?.judgeId || '').trim();
  2742. if (!judgeId) { ctx.flash = { message: 'Judge is required.' }; return ctx.redirect('/courts?filter=judges'); }
  2743. try { await courtsModel.nominateJudge({ judgeId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2744. ctx.redirect('/courts?filter=judges');
  2745. })
  2746. .post('/courts/judges/:id/vote', koaBody(), async (ctx) => {
  2747. if (!ctx.params.id) { ctx.flash = { message: 'Nomination not found.' }; return ctx.redirect('/courts?filter=judges'); }
  2748. try { await courtsModel.voteNomination(ctx.params.id); } catch (e) { ctx.flash = { message: String(e?.message || e) }; }
  2749. ctx.redirect('/courts?filter=judges');
  2750. })
  2751. .post("/market/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2752. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  2753. const b = ctx.request.body, image = await handleBlobUpload(ctx, "image"), parsedStock = parseInt(String(b.stock || "0"), 10);
  2754. if (!parsedStock || parsedStock <= 0) ctx.throw(400, "Stock must be a positive number.");
  2755. const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping);
  2756. 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);
  2757. ctx.redirect(safeReturnTo(ctx, "/market", ["/market"]));
  2758. })
  2759. .post("/market/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2760. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  2761. const b = ctx.request.body, parsedStock = parseInt(String(b.stock || "0"), 10);
  2762. if (parsedStock < 0) ctx.throw(400, "Stock cannot be negative.");
  2763. const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping);
  2764. 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 };
  2765. const image = await handleBlobUpload(ctx, "image");
  2766. if (image) updatedData.image = image;
  2767. await marketModel.updateItemById(ctx.params.id, updatedData);
  2768. ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"]));
  2769. })
  2770. .post("/market/delete/:id", koaBody(), async ctx => {
  2771. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  2772. await marketModel.deleteItemById(ctx.params.id)
  2773. ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"]))
  2774. })
  2775. .post("/market/sold/:id", koaBody(), async (ctx) => {
  2776. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  2777. const item = await marketModel.getItemById(ctx.params.id);
  2778. if (!item) ctx.throw(404, "Item not found");
  2779. if (Number(item.stock || 0) <= 0) ctx.throw(400, "No stock left to mark as sold.");
  2780. if (item.status !== "SOLD") { await marketModel.setItemAsSold(ctx.params.id); await marketModel.decrementStock(ctx.params.id); }
  2781. ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"]));
  2782. })
  2783. .post("/market/buy/:id", koaBody(), async (ctx) => {
  2784. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  2785. const item = await marketModel.getItemById(ctx.params.id);
  2786. if (!item) ctx.throw(404, "Item not found");
  2787. if (item.item_type === "exchange" && item.status !== "SOLD") {
  2788. await pmModel.sendMessage([item.seller], "MARKET_SOLD", `item "${item.title}" has been sold -> /market/${ctx.params.id} OASIS ID: ${getViewerId()} for: ${item.price} ECO`);
  2789. await marketModel.setItemAsSold(ctx.params.id);
  2790. } else await marketModel.decrementStock(ctx.params.id);
  2791. ctx.redirect(safeReturnTo(ctx, "/inbox?filter=sent", ["/inbox", "/market"]));
  2792. })
  2793. .post("/market/status/:id", koaBody(), async (ctx) => {
  2794. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  2795. const desired = String(ctx.request.body.status || "").toUpperCase().replace(/_/g, " ").replace(/\s+/g, " ").trim();
  2796. if (!["FOR SALE", "SOLD", "DISCARDED"].includes(desired)) ctx.throw(400, "Invalid status.");
  2797. const item = await marketModel.getItemById(ctx.params.id);
  2798. if (!item) ctx.throw(404, "Item not found");
  2799. const cur = String(item.status || "").toUpperCase().replace(/\s+/g, " ").trim();
  2800. if (cur !== "SOLD" && cur !== "DISCARDED" && desired !== cur && desired !== "FOR SALE") {
  2801. if (desired === "SOLD") {
  2802. if (Number(item.stock || 0) <= 0) ctx.throw(400, "No stock left to mark as sold.");
  2803. await marketModel.setItemAsSold(ctx.params.id); await marketModel.decrementStock(ctx.params.id);
  2804. } else if (desired === "DISCARDED") await marketModel.updateItemById(ctx.params.id, { status: "DISCARDED", stock: 0 });
  2805. }
  2806. ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"]));
  2807. })
  2808. .post("/market/bid/:id", koaBody(), async ctx => {
  2809. if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; }
  2810. await marketModel.addBidToAuction(ctx.params.id, getViewerId(), ctx.request.body.bidAmount)
  2811. ctx.redirect(safeReturnTo(ctx, "/market?filter=auctions", ["/market"]))
  2812. })
  2813. .post("/market/:itemId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'market', 'itemId'))
  2814. .post('/jobs/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2815. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  2816. const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
  2817. 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 });
  2818. ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs']));
  2819. })
  2820. .post('/jobs/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2821. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  2822. const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined;
  2823. 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 };
  2824. if (b.vacants !== undefined && b.vacants !== '') patch.vacants = parseInt(b.vacants, 10);
  2825. if (b.salary !== undefined && b.salary !== '') patch.salary = parseFloat(String(b.salary).replace(',', '.'));
  2826. if (imageBlob !== undefined) patch.image = imageBlob;
  2827. await jobsModel.updateJob(ctx.params.id, patch);
  2828. ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs']));
  2829. })
  2830. .post('/jobs/delete/:id', koaBody(), async ctx => {
  2831. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  2832. await jobsModel.deleteJob(ctx.params.id)
  2833. ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs']))
  2834. })
  2835. .post('/jobs/status/:id', koaBody(), async ctx => {
  2836. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  2837. await jobsModel.updateJobStatus(ctx.params.id, String(ctx.request.body.status).toUpperCase())
  2838. ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs']))
  2839. })
  2840. .post('/jobs/subscribe/:id', koaBody(), async (ctx) => {
  2841. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  2842. const userId = getViewerId(), job = await jobsModel.getJobById(ctx.params.id, userId);
  2843. await jobsModel.subscribeToJob(ctx.params.id, userId);
  2844. await pmModel.sendMessage([job.author], 'JOB_SUBSCRIBED', `has subscribed to your job offer "${job.title || ''}" -> /jobs/${encodeURIComponent(job.id)}`);
  2845. ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs']));
  2846. })
  2847. .post('/jobs/unsubscribe/:id', koaBody(), async (ctx) => {
  2848. if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; }
  2849. const userId = getViewerId(), job = await jobsModel.getJobById(ctx.params.id, userId);
  2850. await jobsModel.unsubscribeFromJob(ctx.params.id, userId);
  2851. await pmModel.sendMessage([job.author], 'JOB_UNSUBSCRIBED', `has unsubscribed from your job offer "${job.title || ''}" -> /jobs/${encodeURIComponent(job.id)}`);
  2852. ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs']));
  2853. })
  2854. .post('/jobs/:jobId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'jobs', 'jobId'))
  2855. .post("/projects/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2856. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2857. const b = ctx.request.body || {}, image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null;
  2858. 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 }; }) : [];
  2859. 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 });
  2860. ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"]));
  2861. })
  2862. .post("/projects/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
  2863. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2864. const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {};
  2865. const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : undefined;
  2866. 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;
  2867. 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 });
  2868. ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"]));
  2869. })
  2870. .post("/projects/delete/:id", koaBody(), async (ctx) => {
  2871. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2872. await projectsModel.deleteProject(await projectsModel.getProjectTipId(ctx.params.id));
  2873. ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"]));
  2874. })
  2875. .post("/projects/status/:id", koaBody(), async (ctx) => {
  2876. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2877. const id = await projectsModel.getProjectTipId(ctx.params.id);
  2878. await projectsModel.updateProjectStatus(id, String(ctx.request.body?.status || "").toUpperCase());
  2879. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2880. })
  2881. .post("/projects/progress/:id", koaBody(), async (ctx) => {
  2882. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2883. const id = await projectsModel.getProjectTipId(ctx.params.id);
  2884. await projectsModel.updateProjectProgress(id, ctx.request.body?.progress);
  2885. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2886. })
  2887. .post("/projects/pledge/:id", koaBody(), async (ctx) => {
  2888. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2889. const latestId = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {};
  2890. const pledgeAmount = parseFloat(b.amount), uid = getViewerId();
  2891. if (isNaN(pledgeAmount) || pledgeAmount <= 0) ctx.throw(400, "Invalid amount");
  2892. const project = await projectsModel.getProjectById(latestId);
  2893. if (String(project.status || "ACTIVE").toUpperCase() !== "ACTIVE") ctx.throw(400, "Project is not active");
  2894. if (project.deadline && moment(project.deadline).isValid() && moment(project.deadline).isBefore(moment())) ctx.throw(400, "Project deadline passed");
  2895. if (project.author === uid) ctx.throw(403, "Authors cannot pledge to their own project");
  2896. let milestoneIndex = null, bountyIndex = null, mob = b.milestoneOrBounty || "";
  2897. if (String(mob).startsWith("milestone:")) milestoneIndex = parseInt(String(mob).split(":")[1], 10);
  2898. else if (String(mob).startsWith("bounty:")) bountyIndex = parseInt(String(mob).split(":")[1], 10);
  2899. const transfer = await transfersModel.createTransfer(project.author, "Project Pledge", pledgeAmount, moment().add(14, "days").toISOString(), ["backer-pledge", `project:${latestId}`]);
  2900. const backers = [...(project.backers || []), { userId: uid, amount: pledgeAmount, at: new Date().toISOString(), transferId: transfer.key || transfer.id, confirmed: false, milestoneIndex, bountyIndex }];
  2901. const pledged = (parseFloat(project.pledged || 0) || 0) + pledgeAmount;
  2902. await projectsModel.updateProject(latestId, { backers, pledged, progress: project.goal ? (pledged / parseFloat(project.goal)) * 100 : 0 });
  2903. await pmModel.sendMessage([project.author], "PROJECT_PLEDGE", `has pledged ${pledgeAmount} ECO to your project "${project.title || ''}" -> /projects/${latestId}`);
  2904. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(latestId)}`, ["/projects"]));
  2905. })
  2906. .post("/projects/confirm-transfer/:id", koaBody(), async (ctx) => {
  2907. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2908. const uid = getViewerId(), transfer = await transfersModel.getTransferById(ctx.params.id);
  2909. if (transfer.to !== uid) ctx.throw(403, "Unauthorized action");
  2910. const tagProject = (Array.isArray(transfer.tags) ? transfer.tags : []).find(t => String(t).startsWith("project:"));
  2911. if (!tagProject) ctx.throw(400, "Missing project tag on transfer");
  2912. const projectId = String(tagProject).split(":")[1];
  2913. await transfersModel.confirmTransferById(ctx.params.id);
  2914. const project = await projectsModel.getProjectById(projectId), backers = [...(project.backers || [])];
  2915. const idx = backers.findIndex(b => b?.transferId === ctx.params.id);
  2916. if (idx !== -1) backers[idx].confirmed = true;
  2917. await projectsModel.updateProject(projectId, { backers, progress: project.goal ? (parseFloat(project.pledged || 0) / parseFloat(project.goal)) * 100 : 0 });
  2918. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(projectId)}`, ["/projects", "/transfers"]));
  2919. })
  2920. .post("/projects/follow/:id", koaBody(), async (ctx) => {
  2921. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2922. const latestId = await projectsModel.getProjectTipId(ctx.params.id), project = await projectsModel.getProjectById(latestId);
  2923. await projectsModel.followProject(ctx.params.id, getViewerId());
  2924. await pmModel.sendMessage([project.author], "PROJECT_FOLLOWED", `has followed your project "${project.title || ''}" -> /projects/${latestId}`);
  2925. ctx.redirect(safeReturnTo(ctx, "/projects", ["/projects"]));
  2926. })
  2927. .post("/projects/unfollow/:id", koaBody(), async (ctx) => {
  2928. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2929. const latestId = await projectsModel.getProjectTipId(ctx.params.id), project = await projectsModel.getProjectById(latestId);
  2930. await projectsModel.unfollowProject(ctx.params.id, getViewerId());
  2931. await pmModel.sendMessage([project.author], "PROJECT_UNFOLLOWED", `has unfollowed your project "${project.title || ''}" -> /projects/${latestId}`);
  2932. ctx.redirect(safeReturnTo(ctx, "/projects", ["/projects"]));
  2933. })
  2934. .post("/projects/milestones/add/:id", koaBody(), async (ctx) => {
  2935. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2936. const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {};
  2937. 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 });
  2938. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2939. })
  2940. .post("/projects/milestones/update/:id/:index", koaBody(), async (ctx) => {
  2941. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2942. const id = await projectsModel.getProjectTipId(ctx.params.id), idx = parseInt(ctx.params.index, 10), b = ctx.request.body || {};
  2943. 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 } : {}) };
  2944. await projectsModel.updateMilestone(id, idx, patch);
  2945. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2946. })
  2947. .post("/projects/milestones/complete/:id/:index", koaBody(), async (ctx) => {
  2948. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2949. const id = await projectsModel.getProjectTipId(ctx.params.id);
  2950. await projectsModel.completeMilestone(id, parseInt(ctx.params.index, 10), getViewerId());
  2951. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2952. })
  2953. .post("/projects/bounties/add/:id", koaBody(), async (ctx) => {
  2954. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2955. const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {};
  2956. await projectsModel.addBounty(id, { title: b.title, amount: b.amount, description: b.description, milestoneIndex: b.milestoneIndex === "" || b.milestoneIndex === undefined ? null : parseInt(b.milestoneIndex, 10) });
  2957. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2958. })
  2959. .post("/projects/bounties/update/:id/:index", koaBody(), async (ctx) => {
  2960. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2961. const id = await projectsModel.getProjectTipId(ctx.params.id), idx = parseInt(ctx.params.index, 10), b = ctx.request.body || {};
  2962. 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 } : {}) };
  2963. await projectsModel.updateBounty(id, idx, patch);
  2964. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2965. })
  2966. .post("/projects/bounties/claim/:id/:index", koaBody(), async (ctx) => {
  2967. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2968. const id = await projectsModel.getProjectTipId(ctx.params.id);
  2969. await projectsModel.claimBounty(id, parseInt(ctx.params.index, 10), getViewerId());
  2970. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2971. })
  2972. .post("/projects/bounties/complete/:id/:index", koaBody(), async (ctx) => {
  2973. if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; }
  2974. const id = await projectsModel.getProjectTipId(ctx.params.id);
  2975. await projectsModel.completeBounty(id, parseInt(ctx.params.index, 10), getViewerId());
  2976. ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"]));
  2977. })
  2978. .post("/projects/:projectId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'projects', 'projectId'))
  2979. .post("/banking/claim/:id", koaBody(), async (ctx) => {
  2980. const userId = getViewerId(), allocation = await bankingModel.getAllocationById(ctx.params.id);
  2981. if (!allocation) { ctx.body = { error: i18n.errorNoAllocation }; return; }
  2982. if (allocation.to !== userId || allocation.status !== "UNCONFIRMED") { ctx.body = { error: i18n.errorInvalidClaim }; return; }
  2983. const { url, user, pass } = getConfig().walletPub;
  2984. const { txid } = await bankingModel.claimAllocation({ transferId: ctx.params.id, claimerId: userId, pubWalletUrl: url, pubWalletUser: user, pubWalletPass: pass });
  2985. await bankingModel.updateAllocationStatus(ctx.params.id, "CLOSED", txid);
  2986. await bankingModel.publishBankClaim({ amount: allocation.amount, epochId: allocation.epochId, allocationId: allocation.id, txid });
  2987. ctx.redirect(`/banking?claimed=${encodeURIComponent(txid)}`);
  2988. })
  2989. .post("/banking/simulate", koaBody(), async (ctx) => {
  2990. const { epochId, rules } = ctx.request.body || {};
  2991. ctx.body = await bankingModel.computeEpoch({ epochId, rules });
  2992. })
  2993. .post("/banking/run", koaBody(), async (ctx) => {
  2994. const { epochId, rules } = ctx.request.body || {};
  2995. ctx.body = await bankingModel.executeEpoch({ epochId, rules });
  2996. })
  2997. .post("/banking/addresses", koaBody(), async (ctx) => {
  2998. const b = ctx.request.body || {}, res = await bankingModel.addAddress({ userId: (b.userId || "").trim(), address: (b.address || "").trim() });
  2999. ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
  3000. })
  3001. .post("/banking/addresses/delete", koaBody(), async (ctx) => {
  3002. const res = await bankingModel.removeAddress({ userId: ((ctx.request.body?.userId) || "").trim() });
  3003. ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
  3004. })
  3005. .post("/favorites/remove/:kind/:id", koaBody(), async (ctx) => {
  3006. await favoritesModel.removeFavorite(ctx.params.kind, ctx.params.id);
  3007. const fallback = `/favorites?filter=${encodeURIComponent(ctx.query.filter || "all")}`;
  3008. ctx.redirect(safeReturnTo(ctx, fallback, ["/favorites"]));
  3009. })
  3010. .post("/update", koaBody(), async (ctx) => {
  3011. const exec = require("node:util").promisify(require("node:child_process").exec);
  3012. const { stdout, stderr } = await exec("git reset --hard && git pull");
  3013. console.log("oasis@version: updating Oasis...", stdout, stderr);
  3014. const { stdout: shOut, stderr: shErr } = await exec("sh install.sh");
  3015. console.log("oasis@version: running install.sh...", shOut, shErr);
  3016. ctx.redirect(new URL(ctx.request.header.referer).href);
  3017. })
  3018. .post("/settings/theme", koaBody(), async (ctx) => {
  3019. const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig();
  3020. cfg.themes.current = theme || "Dark-SNH";
  3021. fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
  3022. ctx.cookies.set("theme", cfg.themes.current, { httpOnly: true, sameSite: 'strict' });
  3023. ctx.redirect("/settings");
  3024. })
  3025. .post("/language", koaBody(), async (ctx) => {
  3026. const lang = String(ctx.request.body.language || "en");
  3027. const cfg = getConfig();
  3028. cfg.language = lang;
  3029. fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
  3030. ctx.cookies.set("language", lang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'strict' });
  3031. ctx.redirect(new URL(ctx.request.header.referer).href);
  3032. })
  3033. .post("/settings/conn/start", koaBody(), async ctx => { await meta.connStart(); ctx.redirect("/peers"); })
  3034. .post("/settings/conn/stop", koaBody(), async ctx => { await meta.connStop(); ctx.redirect("/peers"); })
  3035. .post("/settings/conn/sync", koaBody(), async ctx => { await meta.sync(); ctx.redirect("/peers"); })
  3036. .post("/settings/conn/restart", koaBody(), async ctx => { await meta.connRestart(); ctx.redirect("/peers"); })
  3037. .post("/settings/invite/accept", koaBody(), async ctx => { await meta.acceptInvite(String(ctx.request.body.invite)); ctx.redirect("/invites"); })
  3038. .post("/settings/invite/unfollow", koaBody(), async (ctx) => {
  3039. const { key } = ctx.request.body || {};
  3040. if (!key) return ctx.redirect("/invites");
  3041. const pubs = readJSON(gossipPath), kcanon = canonicalKey(key);
  3042. const idx = pubs.findIndex(x => x && canonicalKey(x.key) === kcanon);
  3043. const removed = idx >= 0 ? (pubs.splice(idx, 1)[0], writeJSON(gossipPath, pubs), pubs[idx-1] !== undefined ? pubs.splice(idx,1)[0] : null) : null;
  3044. const ssb = await cooler.open(), addr = removed?.host ? msAddrFrom(removed.host, removed.port, removed.key) : null;
  3045. if (addr) { try { await new Promise(res => ssb.conn.disconnect(addr, res)); } catch {} try { ssb.conn.forget(addr); } catch {} }
  3046. try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, following: false, blocking: true }, e => e ? rej(e) : res())); } catch {}
  3047. const unf = readJSON(unfollowedPath);
  3048. if (!unf.find(x => x && canonicalKey(x.key) === kcanon)) { unf.push(removed || { key: kcanon }); writeJSON(unfollowedPath, unf); }
  3049. ctx.redirect("/invites");
  3050. })
  3051. .post("/settings/invite/follow", koaBody(), async (ctx) => {
  3052. const { key, host, port } = ctx.request.body || {};
  3053. if (!key || !host) return ctx.redirect("/invites");
  3054. const pubs = readJSON(gossipPath), kcanon = canonicalKey(key);
  3055. if (pubs.find(p => p.host === host)?.error) return ctx.redirect("/invites");
  3056. const ssb = await cooler.open(), unf = readJSON(unfollowedPath);
  3057. const rec = unf.find(x => x && canonicalKey(x.key) === kcanon) || { host, port: Number(port) || 8008, key: kcanon };
  3058. if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) { pubs.push({ host: rec.host, port: Number(rec.port) || 8008, key: kcanon }); writeJSON(gossipPath, pubs); }
  3059. const addr = msAddrFrom(rec.host, rec.port, kcanon);
  3060. try { ssb.conn.remember(addr, { type: "pub", autoconnect: true, key: kcanon }); } catch {}
  3061. try { await new Promise(res => ssb.conn.connect(addr, { type: "pub" }, res)); } catch {}
  3062. try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, blocking: false }, e => e ? rej(e) : res())); } catch {}
  3063. writeJSON(unfollowedPath, unf.filter(x => !(x && canonicalKey(x.key) === kcanon)));
  3064. ctx.redirect("/invites");
  3065. })
  3066. .post("/peers/connect", koaBody(), async (ctx) => {
  3067. const { key, host, port } = ctx.request.body || {};
  3068. if (!key || !host) return ctx.redirect("/peers?err=missing");
  3069. const hostStr = String(host).trim().toLowerCase();
  3070. const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostStr);
  3071. const isHostname = /^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$/.test(hostStr);
  3072. if ((!isIPv4 && !isHostname) || hostStr.length > 253) return ctx.redirect("/peers?err=invalidHost");
  3073. if (isIPv4 && hostStr.split('.').some(o => Number(o) > 255)) return ctx.redirect("/peers?err=invalidHost");
  3074. const prt = Number(port) || 8008;
  3075. if (!Number.isInteger(prt) || prt < 1 || prt > 65535) return ctx.redirect("/peers?err=invalidPort");
  3076. const keyStr = String(key).trim();
  3077. if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(keyStr)) return ctx.redirect("/peers?err=invalidKey");
  3078. const kcanon = canonicalKey(keyStr);
  3079. const pubs = readJSON(gossipPath);
  3080. if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) {
  3081. pubs.push({ host: hostStr, port: prt, key: kcanon });
  3082. writeJSON(gossipPath, pubs);
  3083. }
  3084. const ssb = await cooler.open();
  3085. const addr = msAddrFrom(hostStr, prt, kcanon);
  3086. try { ssb.conn.remember(addr, { type: "peer", autoconnect: true, key: kcanon }); } catch {}
  3087. try { await new Promise(res => ssb.conn.connect(addr, { type: "peer" }, res)); } catch {}
  3088. try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, following: true }, e => e ? rej(e) : res())); } catch {}
  3089. const unf = readJSON(unfollowedPath);
  3090. writeJSON(unfollowedPath, unf.filter(x => !(x && canonicalKey(x.key) === kcanon)));
  3091. ctx.redirect("/peers");
  3092. })
  3093. .post("/settings/ssb-logstream", koaBody(), async (ctx) => {
  3094. const logLimit = parseInt(ctx.request.body.ssb_log_limit, 10);
  3095. if (!isNaN(logLimit) && logLimit > 0 && logLimit <= 100000) {
  3096. const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
  3097. config.ssbLogStream = { ...(config.ssbLogStream || {}), limit: logLimit };
  3098. fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
  3099. }
  3100. ctx.redirect("/settings");
  3101. })
  3102. .post("/settings/home-page", koaBody(), async (ctx) => {
  3103. const cfg = getConfig();
  3104. cfg.homePage = String(ctx.request.body.homePage || "").trim() || "activity";
  3105. saveConfig(cfg);
  3106. ctx.redirect("/settings");
  3107. })
  3108. .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); })
  3109. .post("/modules/preset", koaBody(), async (ctx) => {
  3110. 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'];
  3111. const PRESETS = {
  3112. minimal: ['feed', 'forum', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
  3113. 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'],
  3114. 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'],
  3115. full: ALL_MODULES
  3116. };
  3117. const preset = String(ctx.request.body.preset || '');
  3118. const enabledMods = PRESETS[preset];
  3119. if (!enabledMods) { ctx.redirect('/modules'); return; }
  3120. const cfg = getConfig();
  3121. ALL_MODULES.forEach(mod => cfg.modules[`${mod}Mod`] = enabledMods.includes(mod) ? 'on' : 'off');
  3122. saveConfig(cfg);
  3123. ctx.redirect('/modules');
  3124. })
  3125. .post("/save-modules", koaBody(), async (ctx) => {
  3126. 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'];
  3127. const cfg = getConfig();
  3128. modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off');
  3129. saveConfig(cfg);
  3130. ctx.redirect(`/modules`);
  3131. })
  3132. .post("/settings/ai", koaBody(), async (ctx) => {
  3133. const aiPrompt = String(ctx.request.body.ai_prompt || "").trim();
  3134. if (aiPrompt.length > 128) { ctx.status = 400; ctx.body = "Prompt too long. Must be 128 characters or fewer."; return; }
  3135. const cfg = getConfig();
  3136. cfg.ai = { ...(cfg.ai || {}), prompt: aiPrompt };
  3137. saveConfig(cfg);
  3138. ctx.redirect("/settings");
  3139. })
  3140. .post("/settings/pub-wallet", koaBody(), async (ctx) => {
  3141. const b = ctx.request.body, cfg = getConfig();
  3142. cfg.walletPub = { url: String(b.wallet_url || "").trim(), user: String(b.wallet_user || "").trim(), pass: String(b.wallet_pass || "").trim() };
  3143. fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
  3144. ctx.redirect("/settings");
  3145. })
  3146. .post('/transfers/create', koaBody(), async ctx => {
  3147. if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
  3148. const b = ctx.request.body;
  3149. await transfersModel.createTransfer(b.to, b.concept, b.amount, b.deadline, b.tags);
  3150. ctx.redirect(safeReturnTo(ctx, '/transfers?filter=all', ['/transfers']));
  3151. })
  3152. .post('/transfers/update/:id', koaBody(), async ctx => {
  3153. if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
  3154. const b = ctx.request.body;
  3155. await transfersModel.updateTransferById(ctx.params.id, b.to, b.concept, b.amount, b.deadline, b.tags);
  3156. ctx.redirect(safeReturnTo(ctx, '/transfers?filter=mine', ['/transfers']));
  3157. })
  3158. .post('/transfers/confirm/:id', koaBody(), async ctx => {
  3159. if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
  3160. await transfersModel.confirmTransferById(ctx.params.id);
  3161. ctx.redirect(safeReturnTo(ctx, '/transfers', ['/transfers']));
  3162. })
  3163. .post('/transfers/delete/:id', koaBody(), async ctx => {
  3164. if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
  3165. await transfersModel.deleteTransferById(ctx.params.id);
  3166. ctx.redirect(safeReturnTo(ctx, '/transfers?filter=mine', ['/transfers']));
  3167. })
  3168. .post('/transfers/opinions/:transferId/:category', koaBody(), async ctx => {
  3169. if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; }
  3170. await transfersModel.createOpinion(ctx.params.transferId, ctx.params.category);
  3171. ctx.redirect(safeReturnTo(ctx, '/transfers', ['/transfers']));
  3172. })
  3173. .post("/settings/wallet", koaBody(), async (ctx) => {
  3174. const b = ctx.request.body, cfg = getConfig();
  3175. if (b.wallet_url) cfg.wallet.url = String(b.wallet_url);
  3176. if (b.wallet_user) cfg.wallet.user = String(b.wallet_user);
  3177. if (b.wallet_pass) cfg.wallet.pass = String(b.wallet_pass);
  3178. if (b.wallet_fee) cfg.wallet.fee = String(b.wallet_fee);
  3179. saveConfig(cfg);
  3180. const res = await bankingModel.ensureSelfAddressPublished();
  3181. ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
  3182. })
  3183. .post("/wallet/send", koaBody(), async (ctx) => {
  3184. const b = ctx.request.body, action = String(b.action), dest = String(b.destination), amt = Number(b.amount), fee = Number(b.fee);
  3185. const { url, user, pass } = getConfig().wallet;
  3186. let balance = null;
  3187. try { balance = await walletModel.getBalance(url, user, pass); } catch (error) { ctx.body = await walletErrorView(error); return; }
  3188. if (action === 'confirm') {
  3189. const v = await walletModel.validateSend(url, user, pass, dest, amt, fee);
  3190. 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 }); }
  3191. catch (error) { ctx.body = await walletErrorView(error); }
  3192. } else if (action === 'send') {
  3193. try { ctx.body = await walletSendResultView(balance, dest, amt, await walletModel.sendToAddress(url, user, pass, dest, amt)); }
  3194. catch (error) { ctx.body = await walletErrorView(error); }
  3195. }
  3196. });
  3197. const routes = router.routes();
  3198. const middleware = [
  3199. async (ctx, next) => {
  3200. 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.");
  3201. await next();
  3202. },
  3203. async (ctx, next) => { setLanguage(ctx.cookies.get("language") || getConfig().language || "en"); await next(); },
  3204. async (ctx, next) => {
  3205. const ssb = await cooler.open(), status = await ssb.status(), values = Object.values(status.sync.plugins);
  3206. const totalCurrent = values.reduce((acc, cur) => acc + cur, 0), totalTarget = status.sync.since * values.length;
  3207. if (totalTarget - totalCurrent > 1024 * 1024) ctx.response.body = indexingView({ percent: Math.floor((totalCurrent / totalTarget) * 1000) / 10 });
  3208. else { try { await next(); } catch (err) {
  3209. if (err.name === 'FileTooLargeError' || (err.message && err.message.includes('maxFileSize'))) {
  3210. const { template, i18n } = require('../views/main_views');
  3211. const referer = ctx.get('referer') || '/';
  3212. ctx.status = 413;
  3213. ctx.body = template(
  3214. i18n.fileTooLargeTitle,
  3215. section(
  3216. div({ class: 'tags-header' },
  3217. h2(i18n.fileTooLargeTitle),
  3218. p(i18n.fileTooLargeMessage),
  3219. p(a({ href: referer, class: 'filter-btn', style: 'display:inline-block;text-decoration:none;margin-top:16px;' }, i18n.goBack))
  3220. )
  3221. )
  3222. );
  3223. } else {
  3224. ctx.status = err.status || 500; ctx.body = { message: err.message || 'Internal Server Error' };
  3225. }
  3226. } }
  3227. },
  3228. async (ctx, next) => {
  3229. if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/')) {
  3230. const now = Date.now();
  3231. if (now - sharedState.getLastRefresh() > 60000) {
  3232. sharedState.setLastRefresh(now);
  3233. try {
  3234. const stats = await statsModel.getStats('ALL');
  3235. const totalMB = parseSizeMB(stats.statsBlobsSize) + parseSizeMB(stats.statsBlockchainSize);
  3236. const hcT = parseFloat((totalMB * 0.0002 * 475).toFixed(2));
  3237. const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
  3238. const hcH = inhabitants > 0 ? parseFloat((hcT / inhabitants).toFixed(2)) : 0;
  3239. sharedState.setCarbonHcT(hcT);
  3240. sharedState.setCarbonHcH(hcH);
  3241. } catch (_) {}
  3242. try { await refreshInboxCount(); } catch (_) {}
  3243. }
  3244. }
  3245. await next();
  3246. },
  3247. routes,
  3248. ];
  3249. const app = http({ host, port, middleware, allowHost: config.allowHost });
  3250. app._close = () => { nameWarmup.close(); cooler.close(); };
  3251. module.exports = app;
  3252. if (config.open === true) open(url);