Experiment with common-good module

This commit is contained in:
Christian Bundy 2020-01-21 16:22:19 -08:00
parent cb4a6ef971
commit b34b04c2c2
22 changed files with 2133 additions and 3641 deletions

16
.eslintrc.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
env: {
commonjs: true,
es6: true,
node: true
},
extends: "eslint:recommended",
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly"
},
parserOptions: {
ecmaVersion: 2018
},
rules: {}
};

View File

@ -1,4 +1,3 @@
**What's the problem you want to solved?** **What's the problem you want to solved?**
**Is there a solution you'd like to recommend?** **Is there a solution you'd like to recommend?**

View File

@ -1,4 +1,3 @@
**What's the problem you solved?** **What's the problem you solved?**
**What solution are you recommending?** **What solution are you recommending?**

View File

@ -1,3 +1,3 @@
{ {
"extends": "stylelint-config-standard" "extends": "stylelint-config-recommended"
} }

View File

@ -2,7 +2,7 @@
language: node_js language: node_js
# Both latest Node.js and latest LTS. # Both latest Node.js and latest LTS.
node_js: node_js:
- lts/* - lts/*
- node - node

View File

@ -21,7 +21,7 @@ Options:
[boolean] [default: false] [boolean] [default: false]
--host Hostname for web app to listen on [string] [default: "localhost"] --host Hostname for web app to listen on [string] [default: "localhost"]
--port Port for web app to listen on [number] [default: 3000] --port Port for web app to listen on [number] [default: 3000]
--debug Use verbose output for debugging [boolean] [default: false] --debug Use verbose output for debugging [boolean] [default: false]
``` ```
## Installation ## Installation

View File

@ -1,36 +1,36 @@
const fs = require('fs') const fs = require("fs");
const path = require('path') const path = require("path");
const mkdirp = require('mkdirp') const mkdirp = require("mkdirp");
const { execSync } = require('child_process') const { execSync } = require("child_process");
const open = require('open') const open = require("open");
let xdgConfigHome = process.env.XDG_CONFIG_HOME let xdgConfigHome = process.env.XDG_CONFIG_HOME;
let systemdUserHome = process.env.SYSTEMD_USER_HOME let systemdUserHome = process.env.SYSTEMD_USER_HOME;
if (xdgConfigHome == null) { if (xdgConfigHome == null) {
// Note: path.join() throws when arguments are null-ish. // Note: path.join() throws when arguments are null-ish.
xdgConfigHome = path.join(process.env.HOME, '.config') xdgConfigHome = path.join(process.env.HOME, ".config");
} }
if (systemdUserHome == null) { if (systemdUserHome == null) {
systemdUserHome = path.join(xdgConfigHome, 'systemd', 'user') systemdUserHome = path.join(xdgConfigHome, "systemd", "user");
} }
const targetPath = path.join(systemdUserHome, 'oasis.service') const targetPath = path.join(systemdUserHome, "oasis.service");
if (fs.existsSync(targetPath)) { if (fs.existsSync(targetPath)) {
console.log('Cowardly refusing to overwrite file:', targetPath) console.log("Cowardly refusing to overwrite file:", targetPath);
} else { } else {
mkdirp(systemdUserHome) mkdirp(systemdUserHome);
const sourcePath = path.join(__dirname, 'oasis.service') const sourcePath = path.join(__dirname, "oasis.service");
fs.copyFileSync(sourcePath, targetPath) fs.copyFileSync(sourcePath, targetPath);
execSync('systemctl --user daemon-reload') execSync("systemctl --user daemon-reload");
console.log('Service configuration has been installed to:', targetPath) console.log("Service configuration has been installed to:", targetPath);
} }
// Since this isn't in a post-install script we can enable, start, and open it. // Since this isn't in a post-install script we can enable, start, and open it.
execSync('systemctl --user enable oasis') execSync("systemctl --user enable oasis");
execSync('systemctl --user start oasis') execSync("systemctl --user start oasis");
open('http://localhost:4515') open("http://localhost:4515");

View File

@ -14,21 +14,21 @@ appearance, race, religion, or sexual identity and orientation.
Examples of behavior that contributes to creating a positive environment Examples of behavior that contributes to creating a positive environment
include: include:
* Using welcoming and inclusive language - Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences - Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism - Gracefully accepting constructive criticism
* Focusing on what is best for the community - Focusing on what is best for the community
* Showing empathy towards other community members - Showing empathy towards other community members
Examples of unacceptable behavior by participants include: Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or - The use of sexualized language or imagery and unwelcome sexual attention or
advances advances
* Trolling, insulting/derogatory comments, and personal or political attacks - Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or electronic - Publishing others' private information, such as a physical or electronic
address, without explicit permission address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Our Responsibilities ## Our Responsibilities
@ -74,4 +74,3 @@ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.ht
For answers to common questions about this code of conduct, see For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq https://www.contributor-covenant.org/faq

View File

@ -4,7 +4,7 @@ Hey, welcome! This project is still very experimental so I won't make any
promises about the future architecture, but today it's pretty simple: promises about the future architecture, but today it's pretty simple:
``` ```
├── assets: static assets like CSS ├── assets: static assets like CSS
├── cli: command-line interface (yargs) ├── cli: command-line interface (yargs)
├── http: HTTP interface (koa) ├── http: HTTP interface (koa)
├── index: mediator that ties everything together ├── index: mediator that ties everything together
@ -32,27 +32,27 @@ I have a hunch that importing modules from parent directories makes it really
easy to create spaghetti code that's difficult to learn and maintain. I don't easy to create spaghetti code that's difficult to learn and maintain. I don't
know whether that's true, but I'm experimenting with a layer-based approach know whether that's true, but I'm experimenting with a layer-based approach
where the only file with relative imports is `index.js` and all imports are in where the only file with relative imports is `index.js` and all imports are in
the style `require('./foo')`. I don't know whether this *actually* has any the style `require('./foo')`. I don't know whether this _actually_ has any
interesting properties, but I'm trying it out to see whether it results in interesting properties, but I'm trying it out to see whether it results in
simpler software architectures. simpler software architectures.
#### Pattern #### Pattern
```javascript ```javascript
require('./foo') // foo.js require("./foo"); // foo.js
require('./bar') // bar/index.js require("./bar"); // bar/index.js
``` ```
#### Anti-pattern #### Anti-pattern
```javascript ```javascript
require('../ancestor') // two-way import require("../ancestor"); // two-way import
require('./some/descendant') // layer violation require("./some/descendant"); // layer violation
require('./foobar/index.js') // excessive specificity require("./foobar/index.js"); // excessive specificity
``` ```
**Note:** I want to make *very* clear that this is an experiment, not a claim **Note:** I want to make _very_ clear that this is an experiment, not a claim
that this is Objectively Better. that this is Objectively Better.
### Any my [hyper]axe ### Any my [hyper]axe
@ -76,7 +76,7 @@ so I'm counting this as another experiment. It looks great and my first day with
it has been really enjoyable, but if something goes horribly wrong then we can it has been really enjoyable, but if something goes horribly wrong then we can
switch to hyperscript-helpers or something. switch to hyperscript-helpers or something.
**Note:** I wasn't aware of hyperscript-helpers until I *after* I refactored **Note:** I wasn't aware of hyperscript-helpers until I _after_ I refactored
the templates to use hyperaxe. Oops. I think hyperaxe has a cooler name anyway. the templates to use hyperaxe. Oops. I think hyperaxe has a cooler name anyway.
[dep-graph]: https://en.wikipedia.org/wiki/Dependency_graph [dep-graph]: https://en.wikipedia.org/wiki/Dependency_graph

View File

@ -1,7 +1,7 @@
# Install # Install
This is a guide on how to download the source code for Oasis so that you can This is a guide on how to download the source code for Oasis so that you can
build and install it on your computer. If you'd like an easier installation build and install it on your computer. If you'd like an easier installation
option, try one of the options in the readme. option, try one of the options in the readme.
If you want to run Oasis in the background, see [`with-systemd.md`](./with-systemd.md). If you want to run Oasis in the background, see [`with-systemd.md`](./with-systemd.md).
@ -47,4 +47,3 @@ oasis --help
If you have problems, read the documentation on [downloading and installing If you have problems, read the documentation on [downloading and installing
packages globally](https://docs.npmjs.com/downloading-and-installing-packages-globally) packages globally](https://docs.npmjs.com/downloading-and-installing-packages-globally)
or [get some help](https://github.com/fraction/oasis/issues/new/choose). or [get some help](https://github.com/fraction/oasis/issues/new/choose).

2570
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,9 @@
}, },
"scripts": { "scripts": {
"dev": "nodemon --inspect src/index.js --debug --no-open", "dev": "nodemon --inspect src/index.js --debug --no-open",
"fix": "standard --fix && stylelint --fix src/assets/*.css", "fix": "common-good fix",
"start": "node src/index.js", "start": "node src/index.js",
"test": "standard && dependency-check ./package.json --unused --no-dev --ignore-module highlight.js --ignore-module @fraction/base16-css && cspell --no-summary '**/*.{js,md}' && stylelint src/assets/*.css && tsc --allowJs --resolveJsonModule --lib dom --checkJs --noEmit --skipLibCheck src/index.js", "test": "dependency_check_suffix='-i husky -i changelog-version -i mkdirp -i nodemon -i stylelint-config-recommended' common-good",
"preversion": "npm test", "preversion": "npm test",
"version": "changelog-version && git add CHANGELOG.md" "version": "changelog-version && git add CHANGELOG.md"
}, },
@ -21,7 +21,7 @@
"@fraction/base16-css": "^1.1.0", "@fraction/base16-css": "^1.1.0",
"@fraction/flotilla": "3.0.0", "@fraction/flotilla": "3.0.0",
"debug": "^4.1.1", "debug": "^4.1.1",
"highlight.js": "^9.16.2", "highlight.js": "^9.18.0",
"hyperaxe": "^1.3.0", "hyperaxe": "^1.3.0",
"koa": "^2.7.0", "koa": "^2.7.0",
"koa-body": "^4.1.0", "koa-body": "^4.1.0",
@ -46,15 +46,11 @@
}, },
"devDependencies": { "devDependencies": {
"changelog-version": "^1.0.1", "changelog-version": "^1.0.1",
"cspell": "^4.0.43", "common-good": "^1.1.0",
"dependency-check": "^4.1.0",
"husky": "^3.0.5", "husky": "^3.0.5",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"nodemon": "^2.0.2", "nodemon": "^2.0.2",
"standard": "^14.3.0", "stylelint-config-recommended": "^3.0.0"
"stylelint": "^12.0.1",
"stylelint-config-standard": "^19.0.0",
"typescript": "^3.7.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"sharp": "^0.23.0" "sharp": "^0.23.0"

View File

@ -59,19 +59,9 @@
html { html {
display: flex; display: flex;
font-family: font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
system-ui, "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
-apple-system, "Helvetica Neue", sans-serif;
BlinkMacSystemFont,
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
justify-content: center; justify-content: center;
font-size: 12pt; font-size: 12pt;
line-height: 1.5; line-height: 1.5;
@ -85,12 +75,24 @@ main {
} }
/* https://www.desmos.com/calculator/3elcf5cwhn */ /* https://www.desmos.com/calculator/3elcf5cwhn */
h1 { font-size: 133%; } /* 4 / 3 */ h1 {
h2 { font-size: 115%; } /* 8 / 7 */ font-size: 133%;
h3 { font-size: 105%; } /* 16 / 15 */ } /* 4 / 3 */
h4 { font-size: 103%; } /* 32 / 31 */ h2 {
h5 { font-size: 101%; } /* 64 / 63 */ font-size: 115%;
h6 { font-size: 100%; } /* 128 / 127 */ } /* 8 / 7 */
h3 {
font-size: 105%;
} /* 16 / 15 */
h4 {
font-size: 103%;
} /* 32 / 31 */
h5 {
font-size: 101%;
} /* 64 / 63 */
h6 {
font-size: 100%;
} /* 128 / 127 */
h1, h1,
h2, h2,

View File

@ -1,37 +1,37 @@
'use strict' "use strict";
const yargs = require('yargs') const yargs = require("yargs");
module.exports = () => module.exports = () =>
yargs yargs
.scriptName('oasis') .scriptName("oasis")
.env('OASIS') .env("OASIS")
.help('h') .help("h")
.alias('h', 'help') .alias("h", "help")
.usage('Usage: $0 [options]') .usage("Usage: $0 [options]")
.options('open', { .options("open", {
describe: 'Automatically open app in web browser. Use --no-open to disable.', describe:
"Automatically open app in web browser. Use --no-open to disable.",
default: true, default: true,
type: 'boolean' type: "boolean"
}) })
.options('offline', { .options("offline", {
describe: "Don't try to connect to scuttlebutt peers or pubs", describe: "Don't try to connect to scuttlebutt peers or pubs",
default: false, default: false,
type: 'boolean' type: "boolean"
}) })
.options('host', { .options("host", {
describe: 'Hostname for web app to listen on', describe: "Hostname for web app to listen on",
default: 'localhost', default: "localhost",
type: 'string' type: "string"
}) })
.options('port', { .options("port", {
describe: 'Port for web app to listen on', describe: "Port for web app to listen on",
default: 3000, default: 3000,
type: 'number' type: "number"
}) })
.options('debug', { .options("debug", {
describe: 'Use verbose output for debugging', describe: "Use verbose output for debugging",
default: false, default: false,
type: 'boolean' type: "boolean"
}) }).argv;
.argv

View File

@ -1,60 +1,62 @@
const Koa = require("koa");
const Koa = require('koa') const koaStatic = require("koa-static");
const koaStatic = require('koa-static') const path = require("path");
const path = require('path') const mount = require("koa-mount");
const mount = require('koa-mount')
module.exports = ({ host, port, routes }) => { module.exports = ({ host, port, routes }) => {
const assets = new Koa() const assets = new Koa();
assets.use(koaStatic(path.join(__dirname, 'assets'))) assets.use(koaStatic(path.join(__dirname, "assets")));
const app = new Koa() const app = new Koa();
module.exports = app module.exports = app;
app.on('error', (e) => { app.on("error", e => {
// Output full error objects // Output full error objects
e.message = e.stack e.message = e.stack;
e.expose = true e.expose = true;
return null return null;
}) });
app.use(mount('/assets', assets)) app.use(mount("/assets", assets));
// headers // headers
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
await next() await next();
const csp = [ const csp = [
'default-src \'none\'', "default-src 'none'",
'img-src \'self\'', "img-src 'self'",
'form-action \'self\'', "form-action 'self'",
'media-src \'self\'', "media-src 'self'",
'style-src \'self\' \'unsafe-inline\'' "style-src 'self' 'unsafe-inline'"
].join('; ') ].join("; ");
// Disallow scripts. // Disallow scripts.
ctx.set('Content-Security-Policy', csp) ctx.set("Content-Security-Policy", csp);
// Disallow <iframe> embeds from other domains. // Disallow <iframe> embeds from other domains.
ctx.set('X-Frame-Options', 'SAMEORIGIN') ctx.set("X-Frame-Options", "SAMEORIGIN");
// Disallow browsers overwriting declared media types. // Disallow browsers overwriting declared media types.
ctx.set('X-Content-Type-Options', 'nosniff') ctx.set("X-Content-Type-Options", "nosniff");
// Disallow sharing referrer with other domains. // Disallow sharing referrer with other domains.
ctx.set('Referrer-Policy', 'same-origin') ctx.set("Referrer-Policy", "same-origin");
// Disallow extra browser features except audio output. // Disallow extra browser features except audio output.
ctx.set('Feature-Policy', 'speaker \'self\'') ctx.set("Feature-Policy", "speaker 'self'");
if (ctx.method !== 'GET') { if (ctx.method !== "GET") {
const referer = ctx.request.header.referer const referer = ctx.request.header.referer;
ctx.assert(referer != null, `HTTP ${ctx.method} must include referer`) ctx.assert(referer != null, `HTTP ${ctx.method} must include referer`);
const refererUrl = new URL(referer) const refererUrl = new URL(referer);
const isBlobUrl = refererUrl.pathname.startsWith('/blob/') const isBlobUrl = refererUrl.pathname.startsWith("/blob/");
ctx.assert(isBlobUrl === false, `HTTP ${ctx.method} from blob URL not allowed`) ctx.assert(
isBlobUrl === false,
`HTTP ${ctx.method} from blob URL not allowed`
);
} }
}) });
app.use(routes) app.use(routes);
app.listen({ host, port }) app.listen({ host, port });
} };

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +1,42 @@
'use strict' "use strict";
const md = require('ssb-markdown') const md = require("ssb-markdown");
const ssbMessages = require('ssb-msgs') const ssbMessages = require("ssb-msgs");
const ssbRef = require('ssb-ref') const ssbRef = require("ssb-ref");
const toUrl = (mentions = []) => { const toUrl = (mentions = []) => {
const mentionNames = {} const mentionNames = {};
ssbMessages.links(mentions, 'feed').forEach((link) => { ssbMessages.links(mentions, "feed").forEach(link => {
if (link.name && typeof link.name === 'string') { if (link.name && typeof link.name === "string") {
const name = (link.name.charAt(0) === '@') ? link.name : `@${link.name}` const name = link.name.charAt(0) === "@" ? link.name : `@${link.name}`;
mentionNames[name] = link.link mentionNames[name] = link.link;
} }
}) });
return (ref) => { return ref => {
// @mentions // @mentions
if (ref in mentionNames) { if (ref in mentionNames) {
return `/author/${encodeURIComponent(mentionNames[ref])}` return `/author/${encodeURIComponent(mentionNames[ref])}`;
} }
if (ssbRef.isFeedId(ref)) { if (ssbRef.isFeedId(ref)) {
return `/author/${encodeURIComponent(ref)}` return `/author/${encodeURIComponent(ref)}`;
} if (ssbRef.isMsgId(ref)) {
return `/thread/${encodeURIComponent(ref)}`
} if (ssbRef.isBlobId(ref)) {
return `/blob/${encodeURIComponent(ref)}`
} if (ref && ref[0] === '#') {
return `/hashtag/${encodeURIComponent(ref.substr(1))}`
} }
return '' if (ssbRef.isMsgId(ref)) {
} return `/thread/${encodeURIComponent(ref)}`;
} }
if (ssbRef.isBlobId(ref)) {
return `/blob/${encodeURIComponent(ref)}`;
}
if (ref && ref[0] === "#") {
return `/hashtag/${encodeURIComponent(ref.substr(1))}`;
}
return "";
};
};
module.exports = (input, mentions) => md.block(input, { module.exports = (input, mentions) =>
toUrl: toUrl(mentions) md.block(input, {
}) toUrl: toUrl(mentions)
});

View File

@ -1,97 +1,107 @@
'use strict' "use strict";
// This module exports a function that connects to SSB and returns a "cooler" // This module exports a function that connects to SSB and returns a "cooler"
// interface. This interface is poorly defined and should be replaced with // interface. This interface is poorly defined and should be replaced with
// native support for Promises in the MuxRPC module and auto-generated manifest // native support for Promises in the MuxRPC module and auto-generated manifest
// files in the SSB-Client module. // files in the SSB-Client module.
const ssbClient = require('ssb-client') const ssbClient = require("ssb-client");
const ssbConfig = require('ssb-config') const ssbConfig = require("ssb-config");
const flotilla = require('@fraction/flotilla') const flotilla = require("@fraction/flotilla");
const debug = require('debug')('oasis') const debug = require("debug")("oasis");
const server = flotilla(ssbConfig) const server = flotilla(ssbConfig);
const log = (...args) => { const log = (...args) => {
const isDebugEnabled = debug.enabled const isDebugEnabled = debug.enabled;
debug.enabled = true debug.enabled = true;
debug(...args) debug(...args);
debug.enabled = isDebugEnabled debug.enabled = isDebugEnabled;
} };
const rawConnect = () => new Promise((resolve, reject) => { const rawConnect = () =>
ssbClient({ new Promise((resolve, reject) => {
manifest: { ssbClient(
about: { {
socialValue: 'async', manifest: {
read: 'source' about: {
socialValue: "async",
read: "source"
},
backlinks: { read: "source" },
blobs: {
get: "source",
ls: "source",
want: "async"
},
conn: {
peers: "source"
},
createUserStream: "source",
createHistoryStream: "source",
get: "sync",
messagesByType: "source",
publish: "async",
status: "async",
tangle: { branch: "async" },
query: { read: "source" },
friends: {
isFollowing: "async",
isBlocking: "async"
},
search: {
query: "source"
}
}
}, },
backlinks: { read: 'source' }, (err, api) => {
blobs: { if (err) {
get: 'source', reject(err);
ls: 'source', } else {
want: 'async' resolve(api);
}, }
conn: {
peers: 'source'
},
createUserStream: 'source',
createHistoryStream: 'source',
get: 'sync',
messagesByType: 'source',
publish: 'async',
status: 'async',
tangle: { branch: 'async' },
query: { read: 'source' },
friends: {
isFollowing: 'async',
isBlocking: 'async'
},
search: {
query: 'source'
} }
} );
}, (err, api) => { });
if (err) {
reject(err)
} else {
resolve(api)
}
})
})
let handle let handle;
const createConnection = (config) => { const createConnection = config => {
handle = new Promise((resolve) => { handle = new Promise(resolve => {
rawConnect().then((ssb) => { rawConnect()
log('Using pre-existing Scuttlebutt server instead of starting one') .then(ssb => {
resolve(ssb) log("Using pre-existing Scuttlebutt server instead of starting one");
}).catch(() => { resolve(ssb);
log('Initial connection attempt failed') })
log('Starting Scuttlebutt server') .catch(() => {
server(config) log("Initial connection attempt failed");
const connectOrRetry = () => { log("Starting Scuttlebutt server");
rawConnect().then((ssb) => { server(config);
log('Retrying connection to own server') const connectOrRetry = () => {
resolve(ssb) rawConnect()
}).catch((e) => { .then(ssb => {
log(e) log("Retrying connection to own server");
connectOrRetry() resolve(ssb);
}) })
} .catch(e => {
log(e);
connectOrRetry();
});
};
connectOrRetry() connectOrRetry();
}) });
}) });
return handle return handle;
} };
module.exports = ({ offline }) => { module.exports = ({ offline }) => {
if (offline) { if (offline) {
log('Offline mode activated - not connecting to scuttlebutt peers or pubs') log("Offline mode activated - not connecting to scuttlebutt peers or pubs");
log('WARNING: offline mode cannot control the behavior of pre-existing servers') log(
"WARNING: offline mode cannot control the behavior of pre-existing servers"
);
} }
const config = { const config = {
@ -101,11 +111,11 @@ module.exports = ({ offline }) => {
ws: { ws: {
http: false http: false
} }
} };
createConnection(config) createConnection(config);
return { return {
connect () { connect() {
// This has interesting behavior that may be unexpected. // This has interesting behavior that may be unexpected.
// //
// If `handle` is already an active [non-closed] connection, return that. // If `handle` is already an active [non-closed] connection, return that.
@ -113,33 +123,33 @@ module.exports = ({ offline }) => {
// If the connection is closed, we need to restart it. It's important to // If the connection is closed, we need to restart it. It's important to
// note that if we're depending on an external service (like Patchwork) and // note that if we're depending on an external service (like Patchwork) and
// that app is closed, then Oasis will seamlessly start its own SSB service. // that app is closed, then Oasis will seamlessly start its own SSB service.
return new Promise((resolve, reject) => { return new Promise(resolve => {
handle.then((ssb) => { handle.then(ssb => {
if (ssb.closed) { if (ssb.closed) {
createConnection() createConnection();
} }
resolve(handle) resolve(handle);
}) });
}) });
}, },
/** /**
* @param {function} method * @param {function} method
*/ */
get (method, ...opts) { get(method, ...opts) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
method(...opts, (err, val) => { method(...opts, (err, val) => {
if (err) { if (err) {
reject(err) reject(err);
} else { } else {
resolve(val) resolve(val);
} }
}) });
}) });
}, },
read (method, ...args) { read(method, ...args) {
return new Promise((resolve) => { return new Promise(resolve => {
resolve(method(...args)) resolve(method(...args));
}) });
} }
} };
} };

View File

@ -1,8 +1,8 @@
'use strict' "use strict";
const debug = require('debug')('oasis') const debug = require("debug")("oasis");
const ssbMarkdown = require('ssb-markdown') const ssbMarkdown = require("ssb-markdown");
const highlightJs = require('highlight.js') const highlightJs = require("highlight.js");
const { const {
a, a,
@ -30,10 +30,10 @@ const {
strong, strong,
textarea, textarea,
ul ul
} = require('hyperaxe') } = require("hyperaxe");
const template = require('./template') const template = require("./template");
const post = require('./post') const post = require("./post");
exports.authorView = ({ exports.authorView = ({
avatarUrl, avatarUrl,
@ -43,273 +43,308 @@ exports.authorView = ({
name, name,
relationship relationship
}) => { }) => {
const mention = `[@${name}](${feedId})` const mention = `[@${name}](${feedId})`;
const markdownMention = highlightJs.highlight('markdown', mention).value const markdownMention = highlightJs.highlight("markdown", mention).value;
const areFollowing = relationship === 'you are following' const areFollowing = relationship === "you are following";
const contactFormType = areFollowing const contactFormType = areFollowing ? "unfollow" : "follow";
? 'unfollow'
: 'follow'
// We're on our own profile! // We're on our own profile!
const contactForm = relationship !== null const contactForm =
? form({ action: `/${contactFormType}/${encodeURIComponent(feedId)}`, method: 'post' }, relationship !== null
button({ ? form(
type: 'submit' {
}, action: `/${contactFormType}/${encodeURIComponent(feedId)}`,
contactFormType)) method: "post"
: null },
button(
{
type: "submit"
},
contactFormType
)
)
: null;
const prefix = section({ class: 'message' }, const prefix = section(
header({ class: 'profile' }, { class: "message" },
img({ class: 'avatar', src: avatarUrl }), header(
h1(name)), { class: "profile" },
img({ class: "avatar", src: avatarUrl }),
h1(name)
),
pre({ pre({
class: 'md-mention', class: "md-mention",
innerHTML: markdownMention innerHTML: markdownMention
}), }),
description !== '<p>null</p>\n' description !== "<p>null</p>\n"
? article({ innerHTML: description }) ? article({ innerHTML: description })
: null, : null,
footer( footer(
a({ href: `/likes/${encodeURIComponent(feedId)}` }, 'view likes'), a({ href: `/likes/${encodeURIComponent(feedId)}` }, "view likes"),
span(relationship), span(relationship),
contactForm contactForm
) )
) );
return template( return template(
prefix, prefix,
messages.map((msg) => post({ msg })) messages.map(msg => post({ msg }))
) );
} };
exports.commentView = async ({ messages, myFeedId, parentMessage }) => { exports.commentView = async ({ messages, myFeedId, parentMessage }) => {
let markdownMention let markdownMention;
const messageElements = await Promise.all( const messageElements = await Promise.all(
messages.reverse().map((message) => { messages.reverse().map(message => {
debug('%O', message) debug("%O", message);
const authorName = message.value.meta.author.name const authorName = message.value.meta.author.name;
const authorFeedId = message.value.author const authorFeedId = message.value.author;
if (authorFeedId !== myFeedId) { if (authorFeedId !== myFeedId) {
if (message.key === parentMessage.key) { if (message.key === parentMessage.key) {
const x = `[@${authorName}](${authorFeedId})\n\n` const x = `[@${authorName}](${authorFeedId})\n\n`;
markdownMention = x markdownMention = x;
} }
} }
return post({ msg: message }) return post({ msg: message });
}) })
) );
const action = `/comment/${encodeURIComponent(messages[0].key)}` const action = `/comment/${encodeURIComponent(messages[0].key)}`;
const method = 'post' const method = "post";
const isPrivate = parentMessage.value.meta.private const isPrivate = parentMessage.value.meta.private;
const publicOrPrivate = isPrivate ? 'private' : 'public' const publicOrPrivate = isPrivate ? "private" : "public";
const maybeReplyText = isPrivate ? null : [ const maybeReplyText = isPrivate
' Messages cannot be edited or deleted. To respond to an individual message, select ', ? null
strong('reply'), : [
' instead.' " Messages cannot be edited or deleted. To respond to an individual message, select ",
] strong("reply"),
" instead."
];
return template( return template(
messageElements, messageElements,
p('Write a ', p(
"Write a ",
strong(`${publicOrPrivate} comment`), strong(`${publicOrPrivate} comment`),
' on this thread with ', " on this thread with ",
a({ href: 'https://commonmark.org/help/' }, 'Markdown'), a({ href: "https://commonmark.org/help/" }, "Markdown"),
'.', ".",
maybeReplyText maybeReplyText
), ),
form({ action, method }, form(
textarea({ { action, method },
autofocus: true, textarea(
required: true, {
name: 'text' autofocus: true,
}, markdownMention), required: true,
button({ name: "text"
type: 'submit' },
}, 'comment')) markdownMention
) ),
} button(
{
exports.listView = ({ messages }) => template( type: "submit"
messages.map((msg) => post({ msg })) },
) "comment"
exports.markdownView = ({ text }) => {
const rawHtml = ssbMarkdown.block(text)
return template(
section({ class: 'message' }, { innerHTML: rawHtml })
)
}
exports.metaView = ({ status, peers, theme, themeNames }) => {
const max = status.sync.since
const progressElements = Object.entries(status.sync.plugins).map((e) => {
const [key, val] = e
const id = `progress-${key}`
return div(
label({ for: id }, key),
progress({ id, value: val, max }, val)
)
})
const peerList = (peers || [])
.map(([, data]) =>
li(
a(
{ href: `/author/${encodeURIComponent(data.key)}` },
code(data.key)
)
) )
) )
);
};
const themeElements = themeNames.map((cur) => { exports.listView = ({ messages }) =>
const isCurrentTheme = cur === theme template(messages.map(msg => post({ msg })));
exports.markdownView = ({ text }) => {
const rawHtml = ssbMarkdown.block(text);
return template(section({ class: "message" }, { innerHTML: rawHtml }));
};
exports.metaView = ({ status, peers, theme, themeNames }) => {
const max = status.sync.since;
const progressElements = Object.entries(status.sync.plugins).map(e => {
const [key, val] = e;
const id = `progress-${key}`;
return div(label({ for: id }, key), progress({ id, value: val, max }, val));
});
const peerList = (peers || []).map(([, data]) =>
li(a({ href: `/author/${encodeURIComponent(data.key)}` }, code(data.key)))
);
const themeElements = themeNames.map(cur => {
const isCurrentTheme = cur === theme;
if (isCurrentTheme) { if (isCurrentTheme) {
return option({ value: cur, selected: true }, cur) return option({ value: cur, selected: true }, cur);
} else { } else {
return option({ value: cur }, cur) return option({ value: cur }, cur);
} }
}) });
const base16 = [ const base16 = [
// '00', removed because this is the background // '00', removed because this is the background
'01', "01",
'02', "02",
'03', "03",
'04', "04",
'05', "05",
'06', "06",
'07', "07",
'08', "08",
'09', "09",
'0A', "0A",
'0B', "0B",
'0C', "0C",
'0D', "0D",
'0E', "0E",
'0F' "0F"
] ];
const base16Elements = base16.map((base) => const base16Elements = base16.map(base =>
div({ div({
style: { style: {
'background-color': `var(--base${base})`, "background-color": `var(--base${base})`,
width: `${1 / base16.length * 100}%`, width: `${(1 / base16.length) * 100}%`,
height: '1em', height: "1em",
'margin-top': '1em', "margin-top": "1em",
display: 'inline-block' display: "inline-block"
} }
}) })
) );
return template( return template(
section({ class: 'message' }, section(
h1('Meta'), { class: "message" },
h1("Meta"),
p( p(
'Check out ', "Check out ",
a({ href: '/meta/readme' }, 'the readme'), a({ href: "/meta/readme" }, "the readme"),
', configure your theme, or view debugging information below.' ", configure your theme, or view debugging information below."
),
h2("Theme"),
p(
"Choose from any theme you'd like. The default theme is Atelier-SulphurPool-Light."
),
form(
{ action: "/theme.css", method: "post" },
select({ name: "theme" }, ...themeElements),
button({ type: "submit" }, "set theme")
), ),
h2('Theme'),
p('Choose from any theme you\'d like. The default theme is Atelier-SulphurPool-Light.'),
form({ action: '/theme.css', method: 'post' },
select({ name: 'theme' }, ...themeElements),
button({ type: 'submit' }, 'set theme')),
base16Elements, base16Elements,
h2('Status'), h2("Status"),
h3('Indexes'), h3("Indexes"),
progressElements, progressElements,
peerList.length > 0 peerList.length > 0 ? [h3("Peers"), ul(peerList)] : null
? [h3('Peers'), ul(peerList)]
: null
) )
) );
} };
exports.publicView = ({ messages, prefix = null }) => { exports.publicView = ({ messages, prefix = null }) => {
const publishForm = '/publish/' const publishForm = "/publish/";
return template( return template(
prefix, prefix,
section( section(
header(strong('🌐 Publish')), header(strong("🌐 Publish")),
form({ action: publishForm, method: 'post' }, form(
{ action: publishForm, method: "post" },
label( label(
{ for: 'text' }, { for: "text" },
'Write a new message in ', "Write a new message in ",
a({ a(
href: 'https://commonmark.org/help/', {
target: '_blank' href: "https://commonmark.org/help/",
}, 'Markdown'), target: "_blank"
'. Messages cannot be edited or deleted.' },
"Markdown"
),
". Messages cannot be edited or deleted."
), ),
textarea({ required: true, name: 'text' }), textarea({ required: true, name: "text" }),
button({ type: 'submit' }, 'submit') button({ type: "submit" }, "submit")
) )
), ),
messages.map((msg) => post({ msg })) messages.map(msg => post({ msg }))
) );
} };
exports.replyView = async ({ messages, myFeedId }) => { exports.replyView = async ({ messages, myFeedId }) => {
const replyForm = `/reply/${encodeURIComponent(messages[messages.length - 1].key)}` const replyForm = `/reply/${encodeURIComponent(
messages[messages.length - 1].key
)}`;
let markdownMention let markdownMention;
const messageElements = await Promise.all( const messageElements = await Promise.all(
messages.reverse().map((message) => { messages.reverse().map(message => {
debug('%O', message) debug("%O", message);
const authorName = message.value.meta.author.name const authorName = message.value.meta.author.name;
const authorFeedId = message.value.author const authorFeedId = message.value.author;
if (authorFeedId !== myFeedId) { if (authorFeedId !== myFeedId) {
if (message.key === messages[0].key) { if (message.key === messages[0].key) {
const x = `[@${authorName}](${authorFeedId})\n\n` const x = `[@${authorName}](${authorFeedId})\n\n`;
markdownMention = x markdownMention = x;
} }
} }
return post({ msg: message }) return post({ msg: message });
}) })
) );
return template( return template(
messageElements, messageElements,
p('Write a ', p(
strong('public reply'), "Write a ",
' to this message with ', strong("public reply"),
a({ href: 'https://commonmark.org/help/' }, 'Markdown'), " to this message with ",
'. Messages cannot be edited or deleted. To respond to an entire thread, select ', a({ href: "https://commonmark.org/help/" }, "Markdown"),
strong('comment'), ". Messages cannot be edited or deleted. To respond to an entire thread, select ",
' instead.' strong("comment"),
" instead."
), ),
form({ action: replyForm, method: 'post' }, form(
textarea({ { action: replyForm, method: "post" },
autofocus: true, textarea(
required: true, {
name: 'text' autofocus: true,
}, markdownMention), required: true,
button({ name: "text"
type: 'submit' },
}, 'reply')) markdownMention
) ),
} button(
{
type: "submit"
},
"reply"
)
)
);
};
exports.searchView = ({ messages, query }) => template( exports.searchView = ({ messages, query }) =>
section( template(
form({ action: '/search', method: 'get' }, section(
header(strong('Search')), form(
label({ for: 'query' }, 'Add word(s) to look for in downloaded messages.'), { action: "/search", method: "get" },
input({ required: true, type: 'search', name: 'query', value: query }), header(strong("Search")),
button({ label(
type: 'submit' { for: "query" },
}, 'submit')) "Add word(s) to look for in downloaded messages."
), ),
messages.map((msg) => post({ msg })) input({ required: true, type: "search", name: "query", value: query }),
) button(
{
type: "submit"
},
"submit"
)
)
),
messages.map(msg => post({ msg }))
);

View File

@ -1,4 +1,4 @@
'use strict' "use strict";
const { const {
a, a,
@ -14,17 +14,17 @@ const {
span, span,
summary, summary,
pre pre
} = require('hyperaxe') } = require("hyperaxe");
const highlightJs = require('highlight.js') const highlightJs = require("highlight.js");
const lodash = require('lodash') const lodash = require("lodash");
module.exports = ({ msg }) => { module.exports = ({ msg }) => {
const encoded = { const encoded = {
key: encodeURIComponent(msg.key), key: encodeURIComponent(msg.key),
author: encodeURIComponent(msg.value.author), author: encodeURIComponent(msg.value.author),
parent: encodeURIComponent(msg.value.content.root) parent: encodeURIComponent(msg.value.content.root)
} };
const url = { const url = {
author: `/author/${encoded.author}`, author: `/author/${encoded.author}`,
@ -35,101 +35,94 @@ module.exports = ({ msg }) => {
json: `/json/${encoded.key}`, json: `/json/${encoded.key}`,
reply: `/reply/${encoded.key}`, reply: `/reply/${encoded.key}`,
comment: `/comment/${encoded.key}` comment: `/comment/${encoded.key}`
} };
const isPrivate = Boolean(msg.value.meta.private) const isPrivate = Boolean(msg.value.meta.private);
const isRoot = msg.value.content.root == null const isRoot = msg.value.content.root == null;
const isThreadTarget = Boolean(lodash.get( const isThreadTarget = Boolean(
msg, lodash.get(msg, "value.meta.thread.target", false)
'value.meta.thread.target', );
false
))
// TODO: I think this is actually true for both replies and comments. // TODO: I think this is actually true for both replies and comments.
const isReply = Boolean(lodash.get( const isReply = Boolean(lodash.get(msg, "value.meta.thread.reply", false));
msg,
'value.meta.thread.reply',
false
))
const { name } = msg.value.meta.author const { name } = msg.value.meta.author;
const timeAgo = msg.value.meta.timestamp.received.since.replace('~', '') const timeAgo = msg.value.meta.timestamp.received.since.replace("~", "");
const depth = lodash.get(msg, 'value.meta.thread.depth', 0) const depth = lodash.get(msg, "value.meta.thread.depth", 0);
const markdownContent = msg.value.meta.md.block() const markdownContent = msg.value.meta.md.block();
const hasContentWarning = typeof msg.value.content.contentWarning === 'string' const hasContentWarning =
typeof msg.value.content.contentWarning === "string";
const likeButton = msg.value.meta.voted const likeButton = msg.value.meta.voted
? { value: 0, class: 'liked' } ? { value: 0, class: "liked" }
: { value: 1, class: null } : { value: 1, class: null };
const likeCount = msg.value.meta.votes.length const likeCount = msg.value.meta.votes.length;
const messageClasses = [] const messageClasses = [];
if (isPrivate) { if (isPrivate) {
messageClasses.push('private') messageClasses.push("private");
} }
if (isThreadTarget) { if (isThreadTarget) {
messageClasses.push('thread-target') messageClasses.push("thread-target");
} }
if (isReply) { if (isReply) {
// True for comments too, I think // True for comments too, I think
messageClasses.push('reply') messageClasses.push("reply");
} }
const isFork = msg.value.meta.postType === 'reply' const isFork = msg.value.meta.postType === "reply";
const postOptions = { const postOptions = {
post: null, post: null,
comment: [ comment: ["commented on ", a({ href: url.parent }, " thread")],
'commented on ', reply: ["replied to ", a({ href: url.parent }, " message")],
a({ href: url.parent }, ' thread') mystery: "posted a mysterious message"
], };
reply: [
'replied to ',
a({ href: url.parent }, ' message')
],
mystery: 'posted a mysterious message'
}
const emptyContent = '<p>undefined</p>\n' const emptyContent = "<p>undefined</p>\n";
const articleElement = markdownContent === emptyContent const articleElement =
? article({ class: 'content' }, pre({ markdownContent === emptyContent
innerHTML: highlightJs.highlight( ? article(
'json', { class: "content" },
JSON.stringify(msg, null, 2) pre({
).value innerHTML: highlightJs.highlight(
})) "json",
: article({ class: 'content', innerHTML: markdownContent }) JSON.stringify(msg, null, 2)
).value
})
)
: article({ class: "content", innerHTML: markdownContent });
const articleContent = hasContentWarning const articleContent = hasContentWarning
? details( ? details(summary(msg.value.content.contentWarning), articleElement)
summary(msg.value.content.contentWarning), : articleElement;
articleElement
)
: articleElement
const fragment = const fragment = section(
section({ {
id: msg.key, id: msg.key,
class: messageClasses.join(' '), class: messageClasses.join(" "),
style: `margin-left: ${depth}rem;` style: `margin-left: ${depth}rem;`
}, },
header( header(
span({ class: 'author' }, span(
a({ href: url.author }, { class: "author" },
img({ class: 'avatar', src: url.avatar, alt: '' }), a(
{ href: url.author },
img({ class: "avatar", src: url.avatar, alt: "" }),
name name
), ),
postOptions[msg.value.meta.postType] postOptions[msg.value.meta.postType]
), ),
span({ class: 'time' }, span(
isPrivate ? '🔒' : null, { class: "time" },
isPrivate ? "🔒" : null,
a({ href: url.link }, timeAgo) a({ href: url.link }, timeAgo)
) )
), ),
@ -144,21 +137,26 @@ module.exports = ({ msg }) => {
// This is used for redirecting users after they like a post, when we // This is used for redirecting users after they like a post, when we
// want the like button that they just clicked to remain close-ish to // want the like button that they just clicked to remain close-ish to
// where it was before they clicked the button. // where it was before they clicked the button.
div({ id: `centered-footer-${encoded.key}`, class: 'centered-footer' }), div({ id: `centered-footer-${encoded.key}`, class: "centered-footer" }),
footer( footer(
form({ action: url.likeForm, method: 'post' }, form(
button({ { action: url.likeForm, method: "post" },
name: 'voteValue', button(
type: 'submit', {
value: likeButton.value, name: "voteValue",
class: likeButton.class type: "submit",
}, value: likeButton.value,
`${likeCount}`)), class: likeButton.class
a({ href: url.comment }, 'comment'), },
(isPrivate || isRoot || isFork) ? null : a({ href: url.reply }, 'reply'), `${likeCount}`
a({ href: url.json }, 'json') )
)) ),
a({ href: url.comment }, "comment"),
isPrivate || isRoot || isFork ? null : a({ href: url.reply }, "reply"),
a({ href: url.json }, "json")
)
);
return fragment return fragment;
} };

View File

@ -1,4 +1,4 @@
'use strict' "use strict";
const { const {
a, a,
@ -12,47 +12,50 @@ const {
nav, nav,
title, title,
ul ul
} = require('hyperaxe') } = require("hyperaxe");
const doctypeString = '<!DOCTYPE html>' const doctypeString = "<!DOCTYPE html>";
const toAttributes = (obj) => const toAttributes = obj =>
Object.entries(obj).map(([key, val]) => `${key}=${val}`).join(', ') Object.entries(obj)
.map(([key, val]) => `${key}=${val}`)
.join(", ");
module.exports = (...elements) => { module.exports = (...elements) => {
const nodes = const nodes = html(
html({ lang: 'en' }, { lang: "en" },
head( head(
title('🏝️ Oasis'), title("🏝️ Oasis"),
link({ rel: 'stylesheet', href: '/theme.css' }), link({ rel: "stylesheet", href: "/theme.css" }),
link({ rel: 'stylesheet', href: '/assets/style.css' }), link({ rel: "stylesheet", href: "/assets/style.css" }),
link({ rel: 'stylesheet', href: '/assets/highlight.css' }), link({ rel: "stylesheet", href: "/assets/highlight.css" }),
meta({ charset: 'utf-8' }), meta({ charset: "utf-8" }),
meta({ meta({
name: 'description', name: "description",
content: 'friendly neighborhood scuttlebutt interface' content: "friendly neighborhood scuttlebutt interface"
}), }),
meta({ meta({
name: 'viewport', name: "viewport",
content: toAttributes({ width: 'device-width', 'initial-scale': 1 }) content: toAttributes({ width: "device-width", "initial-scale": 1 })
}) })
),
body(
nav(
ul(
li(a({ href: "/" }, "popular")),
li(a({ href: "/public/latest" }, "latest")),
li(a({ href: "/inbox" }, "inbox")),
li(a({ href: "/mentions" }, "mentions")),
li(a({ href: "/profile" }, "profile")),
li(a({ href: "/search" }, "search")),
li(a({ href: "/meta" }, "meta"))
)
), ),
body( main({ id: "content" }, elements)
nav( )
ul( );
li(a({ href: '/' }, 'popular')),
li(a({ href: '/public/latest' }, 'latest')),
li(a({ href: '/inbox' }, 'inbox')),
li(a({ href: '/mentions' }, 'mentions')),
li(a({ href: '/profile' }, 'profile')),
li(a({ href: '/search' }, 'search')),
li(a({ href: '/meta' }, 'meta'))
)
),
main({ id: 'content' }, elements)
))
const result = doctypeString + nodes.outerHTML const result = doctypeString + nodes.outerHTML;
return result return result;
} };