#!/usr/bin/env node "use strict"; // Koa application to provide HTTP interface. const cli = require("./cli"); const config = cli(); if (config.debug) { process.env.DEBUG = "oasis,oasis:*"; } // HACK: We must get the CLI config and then delete environment variables. // This hides arguments from other upstream modules who might parse them. // // Unfortunately some modules think that our CLI options are meant for them, // and since there's no way to disable that behavior (!) we have to hide them // manually by setting the args property to an empty array. process.argv = []; const http = require("./http"); const debug = require("debug")("oasis"); const fs = require("fs").promises; const koaBody = require("koa-body"); const { nav, ul, li, a } = require("hyperaxe"); const open = require("open"); const path = require("path"); const pull = require("pull-stream"); const requireStyle = require("require-style"); const router = require("koa-router")(); const ssbMentions = require("ssb-mentions"); const ssbRef = require("ssb-ref"); const { themeNames } = require("@fraction/base16-css"); const ssb = require("./ssb"); // Create "cooler"-style interface from SSB connection. // This handle is passed to the models for their convenience. const cooler = ssb({ offline: config.offline }); const { about, blob, friend, meta, post, vote } = require("./models")(cooler); const { authorView, commentView, listView, markdownView, metaView, publicView, replyView, searchView } = require("./views"); let sharp; try { sharp = require("sharp"); } catch (e) { // Optional dependency } const defaultTheme = "atelier-sulphurPool-light".toLowerCase(); // TODO: refactor const start = async () => { const filePath = path.join(__dirname, "..", "README.md"); config.readme = await fs.readFile(filePath, "utf8"); }; start(); router .param("imageSize", (imageSize, ctx, next) => { const size = Number(imageSize); const isInteger = size % 1 === 0; const overMinSize = size > 2; const underMaxSize = size <= 256; ctx.assert(isInteger && overMinSize && underMaxSize, "Invalid image size"); return next(); }) .param("blobId", (blobId, ctx, next) => { ctx.assert(ssbRef.isBlob(blobId), 400, "Invalid blob link"); return next(); }) .param("message", (message, ctx, next) => { ctx.assert(ssbRef.isMsg(message), 400, "Invalid message link"); return next(); }) .param("feed", (message, ctx, next) => { ctx.assert(ssbRef.isFeedId(message), 400, "Invalid feed link"); return next(); }) .get("/", async ctx => { ctx.redirect("/public/popular/day"); }) .get("/public/popular/:period", async ctx => { const { period } = ctx.params; const publicPopular = async ({ period }) => { const messages = await post.popular({ period }); const option = somePeriod => li( period === somePeriod ? a({ class: "current", href: `./${somePeriod}` }, somePeriod) : a({ href: `./${somePeriod}` }, somePeriod) ); const prefix = nav( ul(option("day"), option("week"), option("month"), option("year")) ); return publicView({ messages, prefix }); }; ctx.body = await publicPopular({ period }); }) .get("/public/latest", async ctx => { const publicLatest = async () => { const messages = await post.latest(); return publicView({ messages }); }; ctx.body = await publicLatest(); }) .get("/author/:feed", async ctx => { const { feed } = ctx.params; const author = async feedId => { const description = await about.description(feedId); const name = await about.name(feedId); const image = await about.image(feedId); const messages = await post.fromFeed(feedId); const relationship = await friend.getRelationship(feedId); const avatarUrl = `/image/256/${encodeURIComponent(image)}`; return authorView({ feedId, messages, name, description, avatarUrl, relationship }); }; ctx.body = await author(feed); }) .get("/search/", async ctx => { const { query } = ctx.query; const search = async ({ query }) => { if (typeof query === "string") { // https://github.com/ssbc/ssb-search/issues/7 query = query.toLowerCase(); } const messages = await post.search({ query }); return searchView({ messages, query }); }; ctx.body = await search({ query }); }) .get("/inbox", async ctx => { const inbox = async () => { const messages = await post.inbox(); return listView({ messages }); }; ctx.body = await inbox(); }) .get("/hashtag/:channel", async ctx => { const { channel } = ctx.params; const hashtag = async channel => { const messages = await post.fromHashtag(channel); return listView({ messages }); }; ctx.body = await hashtag(channel); }) .get("/theme.css", ctx => { const theme = ctx.cookies.get("theme") || defaultTheme; const packageName = "@fraction/base16-css"; const filePath = `${packageName}/src/base16-${theme}.css`; ctx.type = "text/css"; ctx.body = requireStyle(filePath); }) .get("/profile/", async ctx => { const profile = async () => { const myFeedId = await meta.myFeedId(); const description = await about.description(myFeedId); const name = await about.name(myFeedId); const image = await about.image(myFeedId); const messages = await post.fromFeed(myFeedId); const avatarUrl = `/image/256/${encodeURIComponent(image)}`; return authorView({ feedId: myFeedId, messages, name, description, avatarUrl, relationship: null }); }; ctx.body = await profile(); }) .get("/json/:message", async ctx => { const { message } = ctx.params; ctx.type = "application/json"; const json = async message => { const json = await meta.get(message); return JSON.stringify(json, null, 2); }; ctx.body = await json(message); }) .get("/blob/:blobId", async ctx => { const { blobId } = ctx.params; const getBlob = async ({ blobId }) => { const bufferSource = await blob.get({ blobId }); debug("got buffer source"); return new Promise(resolve => { pull( bufferSource, pull.collect(async (err, bufferArray) => { if (err) { await blob.want({ blobId }); resolve(Buffer.alloc(0)); } else { const buffer = Buffer.concat(bufferArray); resolve(buffer); } }) ); }); }; ctx.body = await getBlob({ blobId }); if (ctx.body.length === 0) { ctx.response.status = 404; } else { ctx.set("Cache-Control", "public,max-age=31536000,immutable"); } // This prevents an auto-download when visiting the URL. ctx.attachment(blobId, { type: "inline" }); }) .get("/image/:imageSize/:blobId", async ctx => { const { blobId, imageSize } = ctx.params; ctx.type = "image/png"; const fakePixel = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64" ); const fakeImage = imageSize => sharp ? sharp({ create: { width: imageSize, height: imageSize, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0.5 } } }) : new Promise(resolve => resolve(fakePixel)); const image = async ({ blobId, imageSize }) => { const bufferSource = await blob.get({ blobId }); const fakeId = "&0000000000000000000000000000000000000000000=.sha256"; debug("got buffer source"); return new Promise(resolve => { if (blobId === fakeId) { debug("fake image"); fakeImage(imageSize).then(result => resolve(result)); } else { debug("not fake image"); pull( bufferSource, pull.collect(async (err, bufferArray) => { if (err) { await blob.want({ blobId }); const result = fakeImage(imageSize); debug({ result }); resolve(result); } else { const buffer = Buffer.concat(bufferArray); if (sharp) { sharp(buffer) .resize(imageSize, imageSize) .png() .toBuffer() .then(data => { resolve(data); }); } else { resolve(buffer); } } }) ); } }); }; ctx.body = await image({ blobId, imageSize: Number(imageSize) }); }) .get("/meta/", async ctx => { const theme = ctx.cookies.get("theme") || defaultTheme; const getMeta = async ({ theme }) => { const status = await meta.status(); const peers = await meta.peers(); return metaView({ status, peers, theme, themeNames }); }; ctx.body = await getMeta({ theme }); }) .get("/likes/:feed", async ctx => { const { feed } = ctx.params; const likes = async ({ feed }) => { const messages = await post.likes({ feed }); return listView({ messages }); }; ctx.body = await likes({ feed }); }) .get("/meta/readme/", async ctx => { const status = async text => { return markdownView({ text }); }; ctx.body = await status(config.readme); }) .get("/mentions/", async ctx => { const mentions = async () => { const messages = await post.mentionsMe(); return listView({ messages }); }; ctx.body = await mentions(); }) .get("/thread/:message", async ctx => { const { message } = ctx.params; const thread = async message => { const messages = await post.fromThread(message); debug("got %i messages", messages.length); return listView({ messages }); }; ctx.body = await thread(message); }) .get("/reply/:message", async ctx => { const { message } = ctx.params; const reply = async parentId => { const rootMessage = await post.get(parentId); const myFeedId = await meta.myFeedId(); debug("%O", rootMessage); const messages = [rootMessage]; return replyView({ messages, myFeedId }); }; ctx.body = await reply(message); }) .get("/comment/:message", async ctx => { const { message } = ctx.params; const comment = async parentId => { const parentMessage = await post.get(parentId); const myFeedId = await meta.myFeedId(); const hasRoot = typeof parentMessage.value.content.root === "string" && ssbRef.isMsg(parentMessage.value.content.root); const hasFork = typeof parentMessage.value.content.fork === "string" && ssbRef.isMsg(parentMessage.value.content.fork); const rootMessage = hasRoot ? hasFork ? parentMessage : await post.get(parentMessage.value.content.root) : parentMessage; const messages = await post.threadReplies(rootMessage.key); messages.push(rootMessage); return commentView({ messages, myFeedId, parentMessage }); }; ctx.body = await comment(message); }) .post("/reply/:message", koaBody(), async ctx => { const { message } = ctx.params; const text = String(ctx.request.body.text); const publishReply = async ({ message, text }) => { // TODO: rename `message` to `parent` or `ancestor` or similar const mentions = ssbMentions(text).filter(mention => mention != null) || undefined; const parent = await post.get(message); return post.reply({ parent, message: { text, mentions } }); }; ctx.body = await publishReply({ message, text }); ctx.redirect(`/thread/${encodeURIComponent(message)}`); }) .post("/comment/:message", koaBody(), async ctx => { const { message } = ctx.params; const text = String(ctx.request.body.text); const publishComment = async ({ message, text }) => { // TODO: rename `message` to `parent` or `ancestor` or similar const mentions = ssbMentions(text).filter(mention => mention != null) || undefined; const parent = await meta.get(message); return post.comment({ parent, message: { text, mentions } }); }; ctx.body = await publishComment({ message, text }); ctx.redirect(`/thread/${encodeURIComponent(message)}`); }) .post("/publish/", koaBody(), async ctx => { const text = String(ctx.request.body.text); const publish = async ({ text }) => { const mentions = ssbMentions(text).filter(mention => mention != null) || undefined; return post.root({ text, mentions }); }; ctx.body = await publish({ text }); ctx.redirect("/"); }) .post("/follow/:feed", koaBody(), async ctx => { const { feed } = ctx.params; const referer = new URL(ctx.request.header.referer); ctx.body = await friend.follow(feed); ctx.redirect(referer); }) .post("/unfollow/:feed", koaBody(), async ctx => { const { feed } = ctx.params; const referer = new URL(ctx.request.header.referer); ctx.body = await friend.unfollow(feed); ctx.redirect(referer); }) .post("/like/:message", koaBody(), async ctx => { const { message } = ctx.params; // TODO: convert all so `message` is full message and `messageKey` is key const messageKey = message; const voteValue = Number(ctx.request.body.voteValue); const encoded = { message: encodeURIComponent(message) }; const referer = new URL(ctx.request.header.referer); referer.hash = `centered-footer-${encoded.message}`; const like = async ({ messageKey, voteValue }) => { const value = Number(voteValue); const message = await post.get(messageKey); const isPrivate = message.value.meta.private === true; const messageRecipients = isPrivate ? message.value.content.recps : []; const normalized = messageRecipients.map(recipient => { if (typeof recipient === "string") { return recipient; } if (typeof recipient.link === "string") { return recipient.link; } return null; }); const recipients = normalized.length > 0 ? normalized : undefined; return vote.publish({ messageKey, value, recps: recipients }); }; ctx.body = await like({ messageKey, voteValue }); ctx.redirect(referer); }) .post("/theme.css", koaBody(), async ctx => { const theme = String(ctx.request.body.theme); ctx.cookies.set("theme", theme); const referer = new URL(ctx.request.header.referer); ctx.redirect(referer); }); const { host } = config; const { port } = config; const routes = router.routes(); http({ host, port, routes }); const uri = `http://${host}:${port}/`; const isDebugEnabled = debug.enabled; debug.enabled = true; debug(`Listening on ${uri}`); debug.enabled = isDebugEnabled; if (config.open === true) { open(uri); }