523 lines
15 KiB
JavaScript
Executable File
523 lines
15 KiB
JavaScript
Executable File
#!/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);
|
|
}
|