index.js 34 KB

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