index.js 34 KB

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