backend.js 150 KB

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