index.js 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177
  1. #!/usr/bin/env node
  2. "use strict";
  3. // Minimum required to get config
  4. const path = require("path");
  5. const envPaths = require("env-paths");
  6. const {cli} = require("./cli");
  7. const fs = require("fs");
  8. const promisesFs = require("fs").promises;
  9. const supports = require("./supports.js").supporting;
  10. const blocks = require("./supports.js").blocking;
  11. const recommends = require("./supports.js").recommending;
  12. const defaultConfig = {};
  13. const defaultConfigFile = path.join(
  14. envPaths("oasis", { suffix: "" }).config,
  15. "/default.json"
  16. );
  17. let haveConfig;
  18. try {
  19. const defaultConfigOverride = fs.readFileSync(defaultConfigFile, "utf8");
  20. Object.entries(JSON.parse(defaultConfigOverride)).forEach(([key, value]) => {
  21. defaultConfig[key] = value;
  22. });
  23. haveConfig = true;
  24. } catch (e) {
  25. if (e.code === "ENOENT") {
  26. haveConfig = false;
  27. } else {
  28. console.log(`There was a problem loading ${defaultConfigFile}`);
  29. throw e;
  30. }
  31. }
  32. const config = cli(defaultConfig, defaultConfigFile);
  33. if (config.debug) {
  34. process.env.DEBUG = "oasis,oasis:*";
  35. }
  36. const customStyleFile = path.join(
  37. envPaths("oasis", { suffix: "" }).config,
  38. "/custom-style.css"
  39. );
  40. let haveCustomStyle;
  41. try {
  42. fs.readFileSync(customStyleFile, "utf8");
  43. haveCustomStyle = true;
  44. } catch (e) {
  45. if (e.code === "ENOENT") {
  46. haveCustomStyle = false;
  47. } else {
  48. console.log(`There was a problem loading ${customStyleFile}`);
  49. throw e;
  50. }
  51. }
  52. const { get } = require("node:http");
  53. const debug = require("debug")("oasis");
  54. const log = (formatter, ...args) => {
  55. const isDebugEnabled = debug.enabled;
  56. debug.enabled = true;
  57. debug(formatter, ...args);
  58. debug.enabled = isDebugEnabled;
  59. };
  60. delete config._;
  61. delete config.$0;
  62. const { host } = config;
  63. const { port } = config;
  64. const url = `http://${host}:${port}`;
  65. if (haveConfig) {
  66. log(`Configuration read defaults from ${defaultConfigFile}`);
  67. } else {
  68. log(
  69. `No configuration file found at ${defaultConfigFile}, using built-in default values.`
  70. );
  71. }
  72. if (!haveCustomStyle) {
  73. log(
  74. `No custom style file found at ${customStyleFile}, ignoring this stylesheet.`
  75. );
  76. }
  77. debug("Current configuration: %O", config);
  78. debug(`You can save the above to ${defaultConfigFile} to make \
  79. these settings the default. See the readme for details.`);
  80. const oasisCheckPath = "/.well-known/oasis";
  81. process.on("uncaughtException", function (err) {
  82. // This isn't `err.code` because TypeScript doesn't like that.
  83. if (err["code"] === "EADDRINUSE") {
  84. get(url + oasisCheckPath, (res) => {
  85. let rawData = "";
  86. res.on("data", (chunk) => {
  87. rawData += chunk;
  88. });
  89. res.on("end", () => {
  90. log(rawData);
  91. if (rawData === "oasis") {
  92. log(`Oasis is already running on host ${host} and port ${port}`);
  93. if (config.open === true) {
  94. log("Opening link to existing instance of Oasis");
  95. open(url);
  96. } else {
  97. log(
  98. "Not opening your browser because opening is disabled by your config"
  99. );
  100. }
  101. process.exit(0);
  102. } else {
  103. throw new Error(`Another server is already running at ${url}.
  104. It might be another copy of Oasis or another program on your computer.
  105. You can run Oasis on a different port number with this option:
  106. oasis --port ${config.port + 1}
  107. Alternatively, you can set the default port in ${defaultConfigFile} with:
  108. {
  109. "port": ${config.port + 1}
  110. }
  111. `);
  112. }
  113. });
  114. });
  115. } else {
  116. throw err;
  117. }
  118. });
  119. // HACK: We must get the CLI config and then delete environment variables.
  120. // This hides arguments from other upstream modules who might parse them.
  121. //
  122. // Unfortunately some modules think that our CLI options are meant for them,
  123. // and since there's no way to disable that behavior (!) we have to hide them
  124. // manually by setting the args property to an empty array.
  125. process.argv = [];
  126. const http = require("./http");
  127. const {koaBody} = require("koa-body");
  128. const { nav, ul, li, a } = require("hyperaxe");
  129. const open = require("open");
  130. const pull = require("pull-stream");
  131. const koaRouter = require("@koa/router");
  132. const ssbMentions = require("ssb-mentions");
  133. const isSvg = require('is-svg');
  134. const { themeNames } = require("@fraction/base16-css");
  135. const { isFeed, isMsg, isBlob } = require("ssb-ref");
  136. const ssb = require("./ssb");
  137. const router = new koaRouter();
  138. // Create "cooler"-style interface from SSB connection.
  139. // This handle is passed to the models for their convenience.
  140. const cooler = ssb({ offline: config.offline });
  141. const models = require("./models");
  142. const { about, blob, friend, meta, post, vote } = models({
  143. cooler,
  144. isPublic: config.public,
  145. });
  146. const nameWarmup = about._startNameWarmup();
  147. // enhance the users' input text by expanding @name to [@name](@feedPub.key)
  148. // and slurps up blob uploads and appends a markdown link for it to the text (see handleBlobUpload)
  149. const preparePreview = async function (ctx) {
  150. let text = String(ctx.request.body.text);
  151. // find all the @mentions that are not inside a link already
  152. // stores name:[matches...]
  153. // TODO: sort by relationship
  154. const mentions = {};
  155. // This matches for @string followed by a space or other punctuations like ! , or .
  156. // The idea here is to match a plain @name but not [@name](...)
  157. // also: re.exec has state => regex is consumed and thus needs to be re-instantiated for each call
  158. //
  159. // Change this link when the regex changes: https://regex101.com/r/j5rzSv/2
  160. const rex = /(^|\s)(?!\[)@([a-zA-Z0-9-]+)([\s.,!?)~]{1}|$)/g;
  161. // ^ sentence ^
  162. // delimiters
  163. // find @mentions using rex and use about.named() to get the info for them
  164. let m;
  165. while ((m = rex.exec(text)) !== null) {
  166. const name = m[2];
  167. let matches = about.named(name);
  168. for (const feed of matches) {
  169. let found = mentions[name] || [];
  170. found.push(feed);
  171. mentions[name] = found;
  172. }
  173. }
  174. // filter the matches depending on the follow relation
  175. Object.keys(mentions).forEach((name) => {
  176. let matches = mentions[name];
  177. // if we find mention matches for a name, and we follow them / they follow us,
  178. // then use those matches as suggestions
  179. const meaningfulMatches = matches.filter((m) => {
  180. return (m.rel.followsMe || m.rel.following) && m.rel.blocking === false;
  181. });
  182. if (meaningfulMatches.length > 0) {
  183. matches = meaningfulMatches;
  184. }
  185. mentions[name] = matches;
  186. });
  187. // replace the text with a markdown link if we have unambiguous match
  188. const replacer = (match, name, sign) => {
  189. let matches = mentions[name];
  190. if (matches && matches.length === 1) {
  191. // we found an exact match, don't send it to frontend as a suggestion
  192. delete mentions[name];
  193. // format markdown link and put the correct sign back at the end
  194. return `[@${matches[0].name}](${matches[0].feed})${sign ? sign : ""}`;
  195. }
  196. return match;
  197. };
  198. text = text.replace(rex, replacer);
  199. // add blob new blob to the end of the document.
  200. text += await handleBlobUpload(ctx);
  201. // author metadata for the preview-post
  202. const ssb = await cooler.open();
  203. const authorMeta = {
  204. id: ssb.id,
  205. name: await about.name(ssb.id),
  206. image: await about.image(ssb.id),
  207. };
  208. return { authorMeta, text, mentions };
  209. };
  210. // handleBlobUpload ingests an uploaded form file.
  211. // it takes care of maximum blob size (5meg), exif stripping and mime detection.
  212. // finally it returns the correct markdown link for the blob depending on the mime-type.
  213. // it supports plain, image and also audio: and video: as understood by ssbMarkdown.
  214. const handleBlobUpload = async function (ctx) {
  215. if (!ctx.request.files) return "";
  216. const ssb = await cooler.open();
  217. const blobUpload = ctx.request.files.blob;
  218. if (typeof blobUpload === "undefined") {
  219. return "";
  220. }
  221. let data = await promisesFs.readFile(blobUpload.filepath);
  222. if (data.length == 0) {
  223. return "";
  224. }
  225. // 5 MiB check
  226. const mebibyte = Math.pow(2, 20);
  227. const maxSize = 5 * mebibyte;
  228. if (data.length > maxSize) {
  229. throw new Error("Blob file is too big, maximum size is 5 mebibytes");
  230. }
  231. try {
  232. const removeExif = (fileData) => {
  233. const exifOrientation = load(fileData);
  234. const orientation = exifOrientation["0th"][ImageIFD.Orientation];
  235. const clean = remove(fileData);
  236. if (orientation !== undefined) {
  237. // preserve img orientation
  238. const exifData = { "0th": {} };
  239. exifData["0th"][ImageIFD.Orientation] = orientation;
  240. const exifStr = dump(exifData);
  241. return insert(exifStr, clean);
  242. } else {
  243. return clean;
  244. }
  245. };
  246. const dataString = data.toString("binary");
  247. // implementation borrowed from ssb-blob-files
  248. // (which operates on a slightly different data structure, sadly)
  249. // https://github.com/ssbc/ssb-blob-files/blob/master/async/image-process.js
  250. data = Buffer.from(removeExif(dataString), "binary");
  251. } catch (e) {
  252. // blob was likely not a jpeg -- no exif data to remove. proceeding with blob upload
  253. }
  254. const addBlob = new Promise((resolve, reject) => {
  255. pull(
  256. pull.values([data]),
  257. ssb.blobs.add((err, hashedBlobRef) => {
  258. if (err) return reject(err);
  259. resolve(hashedBlobRef);
  260. })
  261. );
  262. });
  263. let blob = {
  264. id: await addBlob,
  265. name: blobUpload.name,
  266. };
  267. // determine encoding to add the correct markdown link
  268. const FileType = require("file-type");
  269. try {
  270. let fileType = await FileType.fromBuffer(data);
  271. blob.mime = fileType.mime;
  272. } catch (error) {
  273. console.warn(error);
  274. blob.mime = "application/octet-stream";
  275. }
  276. // append uploaded blob as markdown to the end of the input text
  277. if (blob.mime.startsWith("image/")) {
  278. return `\n![${blob.name}](${blob.id})`;
  279. } else if (blob.mime.startsWith("audio/")) {
  280. return `\n![audio:${blob.name}](${blob.id})`;
  281. } else if (blob.mime.startsWith("video/")) {
  282. return `\n![video:${blob.name}](${blob.id})`;
  283. } else {
  284. return `\n[${blob.name}](${blob.id})`;
  285. }
  286. };
  287. const resolveCommentComponents = async function (ctx) {
  288. const { message } = ctx.params;
  289. const parentId = message;
  290. const parentMessage = await post.get(parentId);
  291. const myFeedId = await meta.myFeedId();
  292. const hasRoot =
  293. typeof parentMessage.value.content.root === "string" &&
  294. ssbRef.isMsg(parentMessage.value.content.root);
  295. const hasFork =
  296. typeof parentMessage.value.content.fork === "string" &&
  297. ssbRef.isMsg(parentMessage.value.content.fork);
  298. const rootMessage = hasRoot
  299. ? hasFork
  300. ? parentMessage
  301. : await post.get(parentMessage.value.content.root)
  302. : parentMessage;
  303. const messages = await post.topicComments(rootMessage.key);
  304. messages.push(rootMessage);
  305. let contentWarning;
  306. if (ctx.request.body) {
  307. const rawContentWarning = String(ctx.request.body.contentWarning).trim();
  308. contentWarning =
  309. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  310. }
  311. return { messages, myFeedId, parentMessage, contentWarning };
  312. };
  313. const {
  314. authorView,
  315. previewCommentView,
  316. commentView,
  317. editProfileView,
  318. indexingView,
  319. extendedView,
  320. latestView,
  321. likesView,
  322. threadView,
  323. hashtagView,
  324. mentionsView,
  325. popularView,
  326. previewView,
  327. privateView,
  328. publishCustomView,
  329. publishView,
  330. previewSubtopicView,
  331. subtopicView,
  332. searchView,
  333. imageSearchView,
  334. setLanguage,
  335. settingsView,
  336. peersView,
  337. invitesView,
  338. topicsView,
  339. summaryView,
  340. threadsView,
  341. } = require("./views/index.js");
  342. const ssbRef = require("ssb-ref");
  343. const markdownView = require("./views/markdown.js");
  344. let sharp;
  345. try {
  346. sharp = require("sharp");
  347. } catch (e) {
  348. // Optional dependency
  349. }
  350. const readmePath = path.join(__dirname, "..", "README.md");
  351. const packagePath = path.join(__dirname, "..", "package.json");
  352. const readme = fs.readFileSync(readmePath, "utf8");
  353. const version = JSON.parse(fs.readFileSync(packagePath, "utf8")).version;
  354. router
  355. .param("imageSize", (imageSize, ctx, next) => {
  356. const size = Number(imageSize);
  357. const isInteger = size % 1 === 0;
  358. const overMinSize = size > 2;
  359. const underMaxSize = size <= 256;
  360. ctx.assert(
  361. isInteger && overMinSize && underMaxSize,
  362. 400,
  363. "Invalid image size"
  364. );
  365. return next();
  366. })
  367. .param("blobId", (blobId, ctx, next) => {
  368. ctx.assert(ssbRef.isBlob(blobId), 400, "Invalid blob link");
  369. return next();
  370. })
  371. .param("message", (message, ctx, next) => {
  372. ctx.assert(ssbRef.isMsg(message), 400, "Invalid message link");
  373. return next();
  374. })
  375. .param("feed", (message, ctx, next) => {
  376. ctx.assert(ssbRef.isFeedId(message), 400, "Invalid feed link");
  377. return next();
  378. })
  379. .get("/", async (ctx) => {
  380. ctx.redirect("/mentions");
  381. })
  382. .get("/robots.txt", (ctx) => {
  383. ctx.body = "User-agent: *\nDisallow: /";
  384. })
  385. .get(oasisCheckPath, (ctx) => {
  386. ctx.body = "oasis";
  387. })
  388. .get("/public/popular/:period", async (ctx) => {
  389. const { period } = ctx.params;
  390. const publicPopular = async ({ period }) => {
  391. const messages = await post.popular({ period });
  392. const selectedLanguage = ctx.cookies.get("language") || "en";
  393. const i18nBase = require("./views/i18n");
  394. let i18n = i18nBase[selectedLanguage];
  395. exports.setLanguage = (language) => {
  396. selectedLanguage = language;
  397. i18n = Object.assign({}, i18nBase.en, i18nBase[language]);
  398. };
  399. const prefix = nav(
  400. ul(
  401. a({ href: "./day" }, i18n.day),
  402. a({ href: "./week" }, i18n.week),
  403. a({ href: "./month" }, i18n.month),
  404. a({ href: "./year" }, i18n.year)
  405. )
  406. );
  407. return popularView({
  408. messages,
  409. prefix,
  410. });
  411. };
  412. ctx.body = await publicPopular({ period });
  413. })
  414. .get("/public/latest", async (ctx) => {
  415. const messages = await post.latest();
  416. ctx.body = await latestView({ messages });
  417. })
  418. .get("/public/latest/extended", async (ctx) => {
  419. const messages = await post.latestExtended();
  420. ctx.body = await extendedView({ messages });
  421. })
  422. .get("/public/latest/topics", async (ctx) => {
  423. const messages = await post.latestTopics();
  424. const channels = await post.channels();
  425. const list = channels.map((c) => {
  426. return li(a({ href: `/hashtag/${c}` }, `#${c}`));
  427. });
  428. const prefix = nav(ul(list));
  429. ctx.body = await topicsView({ messages, prefix });
  430. })
  431. .get("/public/latest/summaries", async (ctx) => {
  432. const messages = await post.latestSummaries();
  433. ctx.body = await summaryView({ messages });
  434. })
  435. .get("/public/latest/threads", async (ctx) => {
  436. const messages = await post.latestThreads();
  437. ctx.body = await threadsView({ messages });
  438. })
  439. .get("/author/:feed", async (ctx) => {
  440. const { feed } = ctx.params;
  441. const gt = Number(ctx.request.query["gt"] || -1);
  442. const lt = Number(ctx.request.query["lt"] || -1);
  443. if (lt > 0 && gt > 0 && gt >= lt)
  444. throw new Error("Given search range is empty");
  445. const author = async (feedId) => {
  446. const description = await about.description(feedId);
  447. const name = await about.name(feedId);
  448. const image = await about.image(feedId);
  449. const messages = await post.fromPublicFeed(feedId, gt, lt);
  450. const firstPost = await post.firstBy(feedId);
  451. const lastPost = await post.latestBy(feedId);
  452. const relationship = await friend.getRelationship(feedId);
  453. const avatarUrl = `/image/256/${encodeURIComponent(image)}`;
  454. return authorView({
  455. feedId,
  456. messages,
  457. firstPost,
  458. lastPost,
  459. name,
  460. description,
  461. avatarUrl,
  462. relationship,
  463. });
  464. };
  465. ctx.body = await author(feed);
  466. })
  467. .get("/search", async (ctx) => {
  468. let { query } = ctx.query;
  469. if (isMsg(query)) {
  470. return ctx.redirect(`/thread/${encodeURIComponent(query)}`);
  471. }
  472. if (isFeed(query)) {
  473. return ctx.redirect(`/author/${encodeURIComponent(query)}`);
  474. }
  475. if (isBlob(query)) {
  476. return ctx.redirect(`/blob/${encodeURIComponent(query)}`);
  477. }
  478. if (typeof query === "string") {
  479. // https://github.com/ssbc/ssb-search/issues/7
  480. query = query.toLowerCase();
  481. if (query.length > 1 && query.startsWith("#")) {
  482. const hashtag = query.slice(1);
  483. return ctx.redirect(`/hashtag/${encodeURIComponent(hashtag)}`);
  484. }
  485. }
  486. const messages = await post.search({ query });
  487. ctx.body = await searchView({ messages, query });
  488. })
  489. .get("/imageSearch", async (ctx) => {
  490. const { query } = ctx.query;
  491. const blobs = query ? await blob.search({ query }) : {};
  492. ctx.body = await imageSearchView({ blobs, query });
  493. })
  494. .get("/inbox", async (ctx) => {
  495. const inbox = async () => {
  496. const messages = await post.inbox();
  497. return privateView({ messages });
  498. };
  499. ctx.body = await inbox();
  500. })
  501. .get("/hashtag/:hashtag", async (ctx) => {
  502. const { hashtag } = ctx.params;
  503. const messages = await post.fromHashtag(hashtag);
  504. ctx.body = await hashtagView({ hashtag, messages });
  505. })
  506. .get("/theme.css", async (ctx) => {
  507. const theme = ctx.cookies.get("theme") || config.theme;
  508. const packageName = "@fraction/base16-css";
  509. const filePath = path.resolve(
  510. "node_modules",
  511. packageName,
  512. "src",
  513. `base16-${theme}.css`
  514. );
  515. try {
  516. // await the css content
  517. const cssContent = await promisesFs.readFile(filePath, { encoding: "utf8" });
  518. ctx.type = "text/css"; // Set the Content-Type header
  519. ctx.body = cssContent; // Serve the CSS content
  520. } catch (err) {
  521. console.error("Error reading CSS file:", err.message);
  522. ctx.status = 404; // Return a 404 status if the file is not found
  523. ctx.body = "Theme not found.";
  524. }
  525. })
  526. .get("/custom-style.css", async (ctx) => {
  527. ctx.type = "text/css";
  528. try {
  529. // Read the CSS file
  530. const cssContent = await fs.readFileSync(customStyleFile, "utf8");
  531. ctx.type = "text/css"; // Set the Content-Type header
  532. ctx.body = cssContent; // Serve the CSS content
  533. } catch (err) {
  534. console.error("Error reading custom style file:", err.message);
  535. ctx.status = 404; // Return a 404 status if the file is not found
  536. ctx.body = "Custom style not found.";
  537. }
  538. })
  539. .get("/profile", async (ctx) => {
  540. const myFeedId = await meta.myFeedId();
  541. const gt = Number(ctx.request.query["gt"] || -1);
  542. const lt = Number(ctx.request.query["lt"] || -1);
  543. if (lt > 0 && gt > 0 && gt >= lt)
  544. throw new Error("Given search range is empty");
  545. const description = await about.description(myFeedId);
  546. const name = await about.name(myFeedId);
  547. const image = await about.image(myFeedId);
  548. const messages = await post.fromPublicFeed(myFeedId, gt, lt);
  549. const firstPost = await post.firstBy(myFeedId);
  550. const lastPost = await post.latestBy(myFeedId);
  551. const avatarUrl = `/image/256/${encodeURIComponent(image)}`;
  552. ctx.body = await authorView({
  553. feedId: myFeedId,
  554. messages,
  555. firstPost,
  556. lastPost,
  557. name,
  558. description,
  559. avatarUrl,
  560. relationship: { me: true },
  561. });
  562. })
  563. .get("/profile/edit", async (ctx) => {
  564. const myFeedId = await meta.myFeedId();
  565. const description = await about.description(myFeedId);
  566. const name = await about.name(myFeedId);
  567. ctx.body = await editProfileView({
  568. name,
  569. description,
  570. });
  571. })
  572. .post("/profile/edit", koaBody({ multipart: true }), async (ctx) => {
  573. const name = String(ctx.request.body.name);
  574. const description = String(ctx.request.body.description);
  575. const image = await promisesFs.readFile(ctx.request.files.image.filepath);
  576. ctx.body = await post.publishProfileEdit({
  577. name,
  578. description,
  579. image,
  580. });
  581. ctx.redirect("/profile");
  582. })
  583. .get("/publish/custom", async (ctx) => {
  584. ctx.body = await publishCustomView();
  585. })
  586. .get("/json/:message", async (ctx) => {
  587. if (config.public) {
  588. throw new Error(
  589. "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again."
  590. );
  591. }
  592. const { message } = ctx.params;
  593. ctx.type = "application/json";
  594. const json = async (message) => {
  595. const json = await meta.get(message);
  596. return JSON.stringify(json, null, 2);
  597. };
  598. ctx.body = await json(message);
  599. })
  600. .get("/blob/:blobId", async (ctx) => {
  601. const { blobId } = ctx.params;
  602. const buffer = await blob.getResolved({ blobId });
  603. ctx.body = buffer;
  604. if (ctx.body.length === 0) {
  605. ctx.response.status = 404;
  606. } else {
  607. ctx.set("Cache-Control", "public,max-age=31536000,immutable");
  608. }
  609. // This prevents an auto-download when visiting the URL.
  610. ctx.attachment(blobId, { type: "inline" });
  611. // If we don't do this explicitly the browser downloads the SVG and thinks
  612. // that it's plain XML, so it doesn't render SVG files correctly. Note that
  613. // this library is **not a full SVG parser**, and may cause false positives
  614. // in the case of malformed XML like `<svg><div></svg>`.
  615. if (isSvg(buffer)) {
  616. ctx.type = "image/svg+xml";
  617. }
  618. })
  619. .get("/image/:imageSize/:blobId", async (ctx) => {
  620. const { blobId, imageSize } = ctx.params;
  621. if (sharp) {
  622. ctx.type = "image/png";
  623. }
  624. const fakePixel = Buffer.from(
  625. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
  626. "base64"
  627. );
  628. const fakeImage = (imageSize) =>
  629. sharp
  630. ? sharp({
  631. create: {
  632. width: imageSize,
  633. height: imageSize,
  634. channels: 4,
  635. background: {
  636. r: 0,
  637. g: 0,
  638. b: 0,
  639. alpha: 0.5,
  640. },
  641. },
  642. })
  643. .png()
  644. .toBuffer()
  645. : new Promise((resolve) => resolve(fakePixel));
  646. const image = async ({ blobId, imageSize }) => {
  647. const bufferSource = await blob.get({ blobId });
  648. const fakeId = "&0000000000000000000000000000000000000000000=.sha256";
  649. debug("got buffer source");
  650. return new Promise((resolve) => {
  651. if (blobId === fakeId) {
  652. debug("fake image");
  653. fakeImage(imageSize).then((result) => resolve(result));
  654. } else {
  655. debug("not fake image");
  656. pull(
  657. bufferSource,
  658. pull.collect(async (err, bufferArray) => {
  659. if (err) {
  660. await blob.want({ blobId });
  661. const result = fakeImage(imageSize);
  662. debug({ result });
  663. resolve(result);
  664. } else {
  665. const buffer = Buffer.concat(bufferArray);
  666. if (sharp) {
  667. sharp(buffer)
  668. .resize(imageSize, imageSize)
  669. .png()
  670. .toBuffer()
  671. .then((data) => {
  672. resolve(data);
  673. });
  674. } else {
  675. resolve(buffer);
  676. }
  677. }
  678. })
  679. );
  680. }
  681. });
  682. };
  683. ctx.body = await image({ blobId, imageSize: Number(imageSize) });
  684. })
  685. .get("/settings", async (ctx) => {
  686. const theme = ctx.cookies.get("theme") || config.theme;
  687. const getMeta = async ({ theme }) => {
  688. return settingsView({
  689. theme,
  690. themeNames,
  691. version: version.toString(),
  692. });
  693. };
  694. ctx.body = await getMeta({ theme });
  695. })
  696. .get("/peers", async (ctx) => {
  697. const theme = ctx.cookies.get("theme") || config.theme;
  698. const getMeta = async ({ theme }) => {
  699. const peers = await meta.connectedPeers();
  700. const peersWithNames = await Promise.all(
  701. peers.map(async ([key, value]) => {
  702. value.name = await about.name(value.key);
  703. return [key, value];
  704. })
  705. );
  706. return peersView({
  707. peers: peersWithNames,
  708. supports: supports,
  709. blocks: blocks,
  710. recommends: recommends,
  711. });
  712. };
  713. ctx.body = await getMeta({ theme });
  714. })
  715. .get("/invites", async (ctx) => {
  716. const theme = ctx.cookies.get("theme") || config.theme;
  717. const getMeta = async ({ theme }) => {
  718. return invitesView({});
  719. };
  720. ctx.body = await getMeta({ theme });
  721. })
  722. .get("/likes/:feed", async (ctx) => {
  723. const { feed } = ctx.params;
  724. const likes = async ({ feed }) => {
  725. const pendingMessages = post.likes({ feed });
  726. const pendingName = about.name(feed);
  727. return likesView({
  728. messages: await pendingMessages,
  729. feed,
  730. name: await pendingName,
  731. });
  732. };
  733. ctx.body = await likes({ feed });
  734. })
  735. .get("/settings/readme", async (ctx) => {
  736. const status = async (text) => {
  737. return markdownView({ text });
  738. };
  739. ctx.body = await status(readme);
  740. })
  741. .get("/mentions", async (ctx) => {
  742. const mentions = async () => {
  743. const messages = await post.mentionsMe();
  744. return mentionsView({ messages });
  745. };
  746. ctx.body = await mentions();
  747. })
  748. .get("/thread/:message", async (ctx) => {
  749. const { message } = ctx.params;
  750. const thread = async (message) => {
  751. const messages = await post.fromThread(message);
  752. debug("got %i messages", messages.length);
  753. return threadView({ messages });
  754. };
  755. ctx.body = await thread(message);
  756. })
  757. .get("/subtopic/:message", async (ctx) => {
  758. const { message } = ctx.params;
  759. const rootMessage = await post.get(message);
  760. const myFeedId = await meta.myFeedId();
  761. debug("%O", rootMessage);
  762. const messages = [rootMessage];
  763. ctx.body = await subtopicView({ messages, myFeedId });
  764. })
  765. .get("/publish", async (ctx) => {
  766. ctx.body = await publishView();
  767. })
  768. .get("/comment/:message", async (ctx) => {
  769. const { messages, myFeedId, parentMessage } =
  770. await resolveCommentComponents(ctx);
  771. ctx.body = await commentView({ messages, myFeedId, parentMessage });
  772. })
  773. .post(
  774. "/subtopic/preview/:message",
  775. koaBody({ multipart: true }),
  776. async (ctx) => {
  777. const { message } = ctx.params;
  778. const rootMessage = await post.get(message);
  779. const myFeedId = await meta.myFeedId();
  780. const rawContentWarning = String(ctx.request.body.contentWarning).trim();
  781. const contentWarning =
  782. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  783. const messages = [rootMessage];
  784. const previewData = await preparePreview(ctx);
  785. ctx.body = await previewSubtopicView({
  786. messages,
  787. myFeedId,
  788. previewData,
  789. contentWarning,
  790. });
  791. }
  792. )
  793. .post("/subtopic/:message", koaBody(), async (ctx) => {
  794. const { message } = ctx.params;
  795. const text = String(ctx.request.body.text);
  796. const rawContentWarning = String(ctx.request.body.contentWarning).trim();
  797. const contentWarning =
  798. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  799. const publishSubtopic = async ({ message, text }) => {
  800. // TODO: rename `message` to `parent` or `ancestor` or similar
  801. const mentions = ssbMentions(text) || undefined;
  802. const parent = await post.get(message);
  803. return post.subtopic({
  804. parent,
  805. message: { text, mentions, contentWarning },
  806. });
  807. };
  808. ctx.body = await publishSubtopic({ message, text });
  809. ctx.redirect(`/thread/${encodeURIComponent(message)}`);
  810. })
  811. .post(
  812. "/comment/preview/:message",
  813. koaBody({ multipart: true }),
  814. async (ctx) => {
  815. const { messages, contentWarning, myFeedId, parentMessage } =
  816. await resolveCommentComponents(ctx);
  817. const previewData = await preparePreview(ctx);
  818. ctx.body = await previewCommentView({
  819. messages,
  820. myFeedId,
  821. contentWarning,
  822. parentMessage,
  823. previewData,
  824. });
  825. }
  826. )
  827. .post("/comment/:message", koaBody(), async (ctx) => {
  828. const { message } = ctx.params;
  829. const text = String(ctx.request.body.text);
  830. const rawContentWarning = String(ctx.request.body.contentWarning);
  831. const contentWarning =
  832. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  833. const publishComment = async ({ message, text }) => {
  834. // TODO: rename `message` to `parent` or `ancestor` or similar
  835. const mentions = ssbMentions(text) || undefined;
  836. const parent = await meta.get(message);
  837. return post.comment({
  838. parent,
  839. message: { text, mentions, contentWarning },
  840. });
  841. };
  842. ctx.body = await publishComment({ message, text });
  843. ctx.redirect(`/thread/${encodeURIComponent(message)}`);
  844. })
  845. .post("/publish/preview", koaBody({ multipart: true }), async (ctx) => {
  846. const rawContentWarning = String(ctx.request.body.contentWarning).trim();
  847. // Only submit content warning if it's a string with non-zero length.
  848. const contentWarning =
  849. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  850. const previewData = await preparePreview(ctx);
  851. ctx.body = await previewView({ previewData, contentWarning });
  852. })
  853. .post("/publish", koaBody(), async (ctx) => {
  854. const text = String(ctx.request.body.text);
  855. const rawContentWarning = String(ctx.request.body.contentWarning);
  856. // Only submit content warning if it's a string with non-zero length.
  857. const contentWarning =
  858. rawContentWarning.length > 0 ? rawContentWarning : undefined;
  859. const publish = async ({ text, contentWarning }) => {
  860. const mentions = ssbMentions(text) || undefined;
  861. return post.root({
  862. text,
  863. mentions,
  864. contentWarning,
  865. });
  866. };
  867. ctx.body = await publish({ text, contentWarning });
  868. ctx.redirect("/public/latest");
  869. })
  870. .post("/publish/custom", koaBody(), async (ctx) => {
  871. const text = String(ctx.request.body.text);
  872. const obj = JSON.parse(text);
  873. ctx.body = await post.publishCustom(obj);
  874. ctx.redirect(`/public/latest`);
  875. })
  876. .post("/follow/:feed", koaBody(), async (ctx) => {
  877. const { feed } = ctx.params;
  878. const referer = new URL(ctx.request.header.referer);
  879. ctx.body = await friend.follow(feed);
  880. ctx.redirect(referer.href);
  881. })
  882. .post("/unfollow/:feed", koaBody(), async (ctx) => {
  883. const { feed } = ctx.params;
  884. const referer = new URL(ctx.request.header.referer);
  885. ctx.body = await friend.unfollow(feed);
  886. ctx.redirect(referer.href);
  887. })
  888. .post("/block/:feed", koaBody(), async (ctx) => {
  889. const { feed } = ctx.params;
  890. const referer = new URL(ctx.request.header.referer);
  891. ctx.body = await friend.block(feed);
  892. ctx.redirect(referer.href);
  893. })
  894. .post("/unblock/:feed", koaBody(), async (ctx) => {
  895. const { feed } = ctx.params;
  896. const referer = new URL(ctx.request.header.referer);
  897. ctx.body = await friend.unblock(feed);
  898. ctx.redirect(referer.href);
  899. })
  900. .post("/like/:message", koaBody(), async (ctx) => {
  901. const { message } = ctx.params;
  902. // TODO: convert all so `message` is full message and `messageKey` is key
  903. const messageKey = message;
  904. const voteValue = Number(ctx.request.body.voteValue);
  905. const encoded = {
  906. message: encodeURIComponent(message),
  907. };
  908. const referer = new URL(ctx.request.header.referer);
  909. referer.hash = `centered-footer-${encoded.message}`;
  910. const like = async ({ messageKey, voteValue }) => {
  911. const value = Number(voteValue);
  912. const message = await post.get(messageKey);
  913. const isPrivate = message.value.meta.private === true;
  914. const messageRecipients = isPrivate ? message.value.content.recps : [];
  915. const normalized = messageRecipients.map((recipient) => {
  916. if (typeof recipient === "string") {
  917. return recipient;
  918. }
  919. if (typeof recipient.link === "string") {
  920. return recipient.link;
  921. }
  922. return null;
  923. });
  924. const recipients = normalized.length > 0 ? normalized : undefined;
  925. return vote.publish({ messageKey, value, recps: recipients });
  926. };
  927. ctx.body = await like({ messageKey, voteValue });
  928. ctx.redirect(referer.href);
  929. })
  930. .post("/update", koaBody(), async (ctx) => {
  931. const util = require("node:util");
  932. const exec = util.promisify(require("node:child_process").exec);
  933. async function updateTool() {
  934. const { stdout, stderr } = await exec(
  935. "git reset --hard && git pull && npm install ."
  936. );
  937. console.log("updating Oasis");
  938. console.log(stdout);
  939. console.log(stderr);
  940. }
  941. updateTool();
  942. const referer = new URL(ctx.request.header.referer);
  943. ctx.redirect(referer.href);
  944. })
  945. .post("/theme.css", koaBody(), async (ctx) => {
  946. const theme = String(ctx.request.body.theme);
  947. ctx.cookies.set("theme", theme);
  948. const referer = new URL(ctx.request.header.referer);
  949. ctx.redirect(referer.href);
  950. })
  951. .post("/language", koaBody(), async (ctx) => {
  952. const language = String(ctx.request.body.language);
  953. ctx.cookies.set("language", language);
  954. const referer = new URL(ctx.request.header.referer);
  955. ctx.redirect(referer.href);
  956. })
  957. .post("/settings/conn/start", koaBody(), async (ctx) => {
  958. await meta.connStart();
  959. ctx.redirect("/peers");
  960. })
  961. .post("/settings/conn/stop", koaBody(), async (ctx) => {
  962. await meta.connStop();
  963. ctx.redirect("/peers");
  964. })
  965. .post("/settings/conn/sync", koaBody(), async (ctx) => {
  966. await meta.sync();
  967. ctx.redirect("/peers");
  968. })
  969. .post("/settings/conn/restart", koaBody(), async (ctx) => {
  970. await meta.connRestart();
  971. ctx.redirect("/peers");
  972. })
  973. .post("/settings/invite/accept", koaBody(), async (ctx) => {
  974. try {
  975. const invite = String(ctx.request.body.invite);
  976. await meta.acceptInvite(invite);
  977. } catch (e) {
  978. // Just in case it's an invalid invite code. :(
  979. debug(e);
  980. }
  981. ctx.redirect("/invites");
  982. })
  983. .post("/settings/rebuild", async (ctx) => {
  984. // Do not wait for rebuild to finish.
  985. meta.rebuild();
  986. ctx.redirect("/settings");
  987. });
  988. const routes = router.routes();
  989. const middleware = [
  990. async (ctx, next) => {
  991. if (config.public && ctx.method !== "GET") {
  992. throw new Error(
  993. "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again."
  994. );
  995. }
  996. await next();
  997. },
  998. async (ctx, next) => {
  999. const selectedLanguage = ctx.cookies.get("language") || "en";
  1000. setLanguage(selectedLanguage);
  1001. await next();
  1002. },
  1003. async (ctx, next) => {
  1004. const ssb = await cooler.open();
  1005. const status = await ssb.status();
  1006. const values = Object.values(status.sync.plugins);
  1007. const totalCurrent = Object.values(status.sync.plugins).reduce(
  1008. (acc, cur) => acc + cur,
  1009. 0
  1010. );
  1011. const totalTarget = status.sync.since * values.length;
  1012. const left = totalTarget - totalCurrent;
  1013. // Weird trick to get percentage with 1 decimal place (e.g. 78.9)
  1014. const percent = Math.floor((totalCurrent / totalTarget) * 1000) / 10;
  1015. const mebibyte = 1024 * 1024;
  1016. if (left > mebibyte) {
  1017. ctx.response.body = indexingView({ percent });
  1018. } else {
  1019. await next();
  1020. }
  1021. },
  1022. routes,
  1023. ];
  1024. const { allowHost } = config;
  1025. const app = http({ host, port, middleware, allowHost });
  1026. // HACK: This lets us close the database once tests finish.
  1027. // If we close the database after each test it throws lots of really fun "parent
  1028. // stream closing" errors everywhere and breaks the tests. :/
  1029. app._close = () => {
  1030. nameWarmup.close();
  1031. cooler.close();
  1032. };
  1033. module.exports = app;
  1034. log(`Listening on ${url}`);
  1035. if (config.open === true) {
  1036. open(url);
  1037. }