Browse Source

initial seed

psy 1 year ago
parent
commit
c64522871f

+ 62 - 0
.cspell.json

@@ -0,0 +1,62 @@
+{
+  "version": "0.1",
+  "language": "en",
+  "words": [
+    "AGPL",
+    "APPDATA",
+    "Argyris",
+    "CSRF",
+    "EACCESS",
+    "EXIF",
+    "Hintjens",
+    "Kata",
+    "LGPL",
+    "Machinekit",
+    "OSSEC",
+    "Pieter",
+    "Termux",
+    "abortable",
+    "autoconf",
+    "automake",
+    "backlinks",
+    "christianbundy",
+    "dogfood",
+    "hyperaxe",
+    "hyperscript",
+    "libsodium",
+    "libtool",
+    "manyverse",
+    "mebibyte",
+    "mebibytes",
+    "minlength",
+    "mkdir",
+    "monokai",
+    "msgs",
+    "multiserver",
+    "muxrpc",
+    "nanohtml",
+    "noauth",
+    "nosniff",
+    "paramap",
+    "patchbay",
+    "patchfoo",
+    "patchfox",
+    "piexifjs",
+    "promisify",
+    "rebecca",
+    "recps",
+    "roadmap",
+    "sameorigin",
+    "sbot",
+    "shortname",
+    "socio",
+    "ssbc",
+    "sulphurpool",
+    "summerfruit",
+    "systemctl",
+    "systemd",
+    "unfollow",
+    "unikitty",
+    "zeromq"
+  ]
+}

+ 5 - 0
.depcheckrc

@@ -0,0 +1,5 @@
+ignores: [
+  "@types/*",
+  "husky",
+  "stylelint-config-recommended"
+]

+ 2 - 0
.dockerignore

@@ -0,0 +1,2 @@
+node_modules
+.git

+ 3 - 0
.github/ISSUE_TEMPLATE.md

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

+ 3 - 0
.github/PULL_REQUEST_TEMPLATE.md

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

+ 12 - 0
.github/dependabot.yml

@@ -0,0 +1,12 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+  - package-ecosystem: "npm" # See documentation for possible values
+    directory: "/" # Location of package manifests
+    schedule:
+      interval: "daily"
+    open-pull-requests-limit: 16

+ 28 - 0
.github/workflows/pr.yml

@@ -0,0 +1,28 @@
+name: Node.js CI
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
+
+jobs:
+  build:
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      matrix:
+        node-version: [10.x, 12.x, 14.x]
+        os: [macos-latest, windows-latest, ubuntu-latest]
+
+    steps:
+      - run: git config --global core.autocrlf false
+      - uses: actions/checkout@v2
+      - name: Use Node.js ${{ matrix.node-version }}
+        uses: actions/setup-node@v1
+        with:
+          node-version: ${{ matrix.node-version }}
+      - run: npm ci
+      - run: npm test

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+*.log*
+.DS_Store
+.nyc_output/
+coverage/
+dist/
+node_modules/
+tmp/
+yarn.lock
+
+# Visual Studio Code
+launch.json
+*.code-workspace
+
+# Vim
+.*.sw[a-z]

+ 5 - 0
.huskyrc

@@ -0,0 +1,5 @@
+{
+  "hooks": {
+    "pre-commit": "npm test"
+  }
+}

+ 4 - 0
.mailmap

@@ -0,0 +1,4 @@
+Cinnamon <cinnamon_oasis@fastmail.com> <cinnamon_bun_github@fastmail.com>
+Cinnamon <cinnamon_oasis@fastmail.com> <32660718+cinnamon-bun@users.noreply.github.com>
+Jonathan Dahan <github@jonathan.is> <hi@jonathan.is>
+kawaiipunk <kawaiipunk@posteo.net> <georgeowell@users.noreply.github.com>

+ 1 - 0
.prettierignore

@@ -0,0 +1 @@
+.nyc_output

+ 3 - 0
.stylelintrc

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

File diff suppressed because it is too large
+ 658 - 228
LICENSE


+ 38 - 2
README.md

@@ -1,3 +1,39 @@
-# oasis
+# SNH-Oasis
 
-[PROD] SolarNET.HuB (SNH) - The Project Network - Oasis
+  ![SNH](https://solarnethub.com/lib/tpl/dokuwiki/images/logo.png "SolarNET.HuB")
+
+## Description:
+
+Oasis is a **free, open-source, peer-to-peer project networking application** 
+that helps you follow interesting content and discover new ones.
+
+----------
+
+## Installing:
+
+Follow ['INSTALL.md'](docs/install.md) to build and install it on your device.
+
+----------
+
+## Development:
+
+Review ['Call 4 Hackers'](https://solarnethub.com/community/hackers) to start contributing with developments.
+
+----------
+
+## Roadmap:
+
+Visit ['Roadmap'](https://solarnethub.com/socialnet/roadmap) to know what are some of the functionalities that are interesting to be implemented.
+
+----------
+
+## Links:
+
+ + SNH Website: https://solarnethub.com
+ + Kräkens.Lab: https://krakenslab.com
+ + Research: https://solarnethub.com/docs/research
+ + Code of Conduct: https://solarnethub.com/docs/code_of_conduct
+ + Project Network: https://solarnethub.com/socialnet/snh#the_project_network
+ + Ecosystem: https://solarnethub.com/socialnet/ecosystem
+ + The KIT: https://solarnethub.com/kit/overview
+ + Warehouse: https://solarnethub.com/stock/submit_request

+ 25 - 0
contrib/Dockerfile

@@ -0,0 +1,25 @@
+FROM node:lts
+
+# Ensure that the ~/.ssb directory is persistent and owned by the 'node' user.
+RUN mkdir /home/node/.ssb && chown node:node /home/node/.ssb
+VOLUME /home/node/.ssb
+
+# Don't run as root.
+USER node
+
+# Create app directory and use it.
+RUN mkdir /home/node/app
+WORKDIR /home/node/app
+
+# Add dependency metadata and install dependencies.
+ADD package.json package-lock.json ./
+RUN npm ci
+
+# Add the rest of the source code.
+ADD ./ ./
+
+# Expose ports for Oasis and SSB replication.
+EXPOSE 3000 8008
+
+# Listen on the container's public interfaces but allow 'localhost' connections.
+CMD ["node", ".", "--host", "0.0.0.0", "--allow-host", "localhost"]

+ 36 - 0
contrib/install-systemd-service.js

@@ -0,0 +1,36 @@
+const fs = require("fs");
+const path = require("path");
+const mkdirp = require("mkdirp");
+const { execSync } = require("child_process");
+const open = require("open");
+
+let xdgConfigHome = process.env.XDG_CONFIG_HOME;
+let systemdUserHome = process.env.SYSTEMD_USER_HOME;
+
+if (xdgConfigHome == null) {
+  // Note: path.join() throws when arguments are null-ish.
+  xdgConfigHome = path.join(process.env.HOME, ".config");
+}
+
+if (systemdUserHome == null) {
+  systemdUserHome = path.join(xdgConfigHome, "systemd", "user");
+}
+
+const targetPath = path.join(systemdUserHome, "oasis.service");
+
+if (fs.existsSync(targetPath)) {
+  console.log("Cowardly refusing to overwrite file:", targetPath);
+} else {
+  mkdirp.sync(systemdUserHome);
+
+  const sourcePath = path.join(__dirname, "oasis.service");
+  fs.copyFileSync(sourcePath, targetPath);
+
+  execSync("systemctl --user daemon-reload");
+  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.
+execSync("systemctl --user enable oasis");
+execSync("systemctl --user start oasis");
+open("http://localhost:4515");

+ 15 - 0
contrib/oasis.service

@@ -0,0 +1,15 @@
+[Unit]
+Description=Oasis - Project Network Web Interface
+Documentation=https://code.03c8.net/KrakensLab/snh-oasis.git
+After=network.target
+
+[Service]
+Type=simple
+# %s : run in the user's shell because they might have a custom PATH
+# -l : invoke a login shell to make sure to source .profile, .zprofile, .bash_profile, ...
+# -c : invoke a shell because we'd have to hardcode a path using ExecStart
+ExecStart=%s -l -c 'oasis --host localhost --port 4515 --no-open'
+Restart=on-failure
+
+[Install]
+WantedBy=default.target

+ 20 - 0
docs/CHANGELOG.md

@@ -0,0 +1,20 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+<!--
+## [Unreleased]
+
+### Added
+### Changed
+### Deprecated
+### Removed
+### Fixed
+### Security
+-->
+
+## v0.1.0 - 2022-07-24
+
+### Added
+
+- Initial commit

+ 2 - 0
docs/CONTRIBUTORS

@@ -0,0 +1,2 @@
+psy <epsylon@riseup.net>
+Christian Bundy <christianbundy@fraction.io>

+ 2 - 0
docs/MAINTAINERS

@@ -0,0 +1,2 @@
+psy <epsylon@riseup.net>
+Christian Bundy <christianbundy@fraction.io>

+ 17 - 0
docs/architecture.md

@@ -0,0 +1,17 @@
+# Architecture
+
+```
+├── assets: static assets like CSS
+├── cli:    command-line interface (yargs)
+├── http:   HTTP interface (koa)
+├── index:  mediator that ties everything together
+├── models: data sources
+├── ssb:    SSB connection and interfaces
+└── views:  HTML presentation to be exposed over HTTP
+```
+
+## Debugging
+
+Debugging is never going to be easy, but the debug script helps a bit. You can
+use `oasis --debug` or debug the source with `npm run debug` / `yarn debug`.
+

+ 35 - 0
docs/blob-security.md

@@ -0,0 +1,35 @@
+# Blob security
+
+**This is how we secure blob pages from interacting with Oasis. If you notice
+any errors or omissions, please follow the steps in the security policy.**
+
+One of the problems we have when hosting content from other people in a P2P
+network is avoiding
+[arbitrary code execution](https://en.wikipedia.org/wiki/Arbitrary_code_execution).
+In the context of Oasis, we need to be very sure that we aren't letting any code
+other than Oasis run in the browser. Markdown is a security concern, but it's
+got lots of eyeballs on the problem, whereas blob security is a security
+concern without any common best practices. The problem we need to solve isn't
+super common: hosting arbitrary data, especially HTML, in a safe way that doesn't
+open security vulnerabilities.
+
+The way we currently deal with this is a [content security policy (CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP),
+which gives Oasis a way to tell the web browser which features can be safely
+disabled. Since Oasis doesn't use any front-end JavaScript, we can disable all
+JavaScript being run by the web browser. This is _huge_ and massively reduces
+the surface area that might be vulnerable to attack. You can find all of the
+CSP code in [`http.js`].
+
+With JavaScript out of the way, the only attack vector that we should worry
+about is an [HTML form](https://developer.mozilla.org/en-US/docs/Learn/Forms#See_also).
+If one of these were embedded in a blob, they would be able to send HTTP POST
+requests to our API endpoints, impersonating the user. A button called "click
+me", could publish posts, change follow status, make changes to our settings
+page, or other bad behavior that we want to avoid.
+
+The mitigation for this is a referrer check on all POST endpoints, which helps
+us guarantee that all form submissions came from a non-blob page. If we receive
+an HTTP POST without a referrer, we throw an error. If we receive a referrer from
+a blob page, we throw an error. If a form submission passes these two checks,
+we can safely assume that the POST request came from a legitimate person using
+Oasis.

+ 76 - 0
docs/code-of-conduct.md

@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity and expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+- Using welcoming and inclusive language
+- Being respectful of differing viewpoints and experiences
+- Gracefully accepting constructive criticism
+- Focusing on what is best for the community
+- Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+- The use of sexualized language or imagery and unwelcome sexual attention or
+  advances
+- Trolling, insulting/derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or electronic
+  address, without explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies within all project spaces, and it also applies when
+an individual is representing the project or its community in public spaces.
+Examples of representing a project or community include using an official
+project e-mail address, posting via an official social media account, or acting
+as an appointed representative at an online or offline event. Representation of
+a project may be further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at <solarnethub@riseup.net>. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq

+ 48 - 0
docs/configuring.md

@@ -0,0 +1,48 @@
+# Configuring
+
+The default options can be permanently set with a configuration file found in a
+standard folder for configuration, depending on your operating system:
+
+- Linux: `$XDG_CONFIG_HOME/oasis/default.json`.
+  Usually this is `/home/<your username>/.config/oasis/default.json`
+- Windows `%APPDATA%\oasis\default.json`.
+- Mac OS, `/Users/<your username>/Library/Preferences/oasis/default.json`
+
+The configuration file can override any or all of the command-line _defaults_.
+Here is an example customizing the port number and the "open" settings:
+
+```json
+{
+  "open": false,
+  "port": 19192
+}
+```
+
+If you run `oasis --help` you'll see all of the parameters.
+
+## Semantics
+
+Which value is given is decided like this:
+
+1. If an argument is given on the command-line, use that value.
+2. Otherwise, use the value from the configuration file if present.
+3. If neither command-line nor configuration file are given, use the built-in default value.
+
+# Custom Styles
+
+The stylesheet values may be overridden by adding custom values to a file found in a
+standard folder for configuration, depending on your operating system:
+
+- Linux: `$XDG_CONFIG_HOME/oasis/custom-style.css`.
+  Usually this is `/home/<your username>/.config/oasis/custom-style.css`
+- Windows `%APPDATA%\oasis\custom-style.css`.
+- Mac OS, `/Users/<your username>/Library/Preferences/oasis/custom-style.css`
+
+As an example the width used for the main body may be changed to a different
+fixed width or a dynamic width:
+
+```css
+:root {
+  --measure: 75%;
+}
+```

+ 125 - 0
docs/contract.md

@@ -0,0 +1,125 @@
+# Collective Code Construction Contract - Oasis Implementation
+
+The Collective Code Construction Contract (C4) is an evolution of the github.com [Fork + Pull Model](https://help.github.com/articles/about-pull-requests/), aimed at providing an optimal collaboration model for free software projects.
+
+This is the Oasis-specific implementation, based on [revision 2 of C4](https://github.com/zeromq/rfc/blob/63024673f19ad136652ff7b3bfb3a6547811e006/42/README.md).
+
+## Summary
+
+Thank you for contributing to Oasis! Here we try capture how we collaborate, and why we do it this way.
+
+This entire document is open for changes, if there is anything that is confusing or can be improved, please start a discussion with us!
+
+## License
+
+Copyright (c) 2009-2016 Pieter Hintjens.
+
+This Specification is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
+
+This Specification is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with this program; if not, see <http://www.gnu.org/licenses>.
+
+## Abstract
+
+C4 provides a standard process for contributing, evaluating and discussing improvements on software projects. It defines specific technical requirements for projects like a style guide, unit tests, `git` and similar platforms. It also establishes different personas for projects, with clear and distinct duties. C4 specifies a process for documenting and discussing issues including seeking consensus and clear descriptions, use of "pull requests" and systematic reviews.
+
+## Language
+
+The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](http://tools.ietf.org/html/rfc2119).
+
+## 1. Goals
+
+C4 is meant to provide a reusable optimal collaboration model for open source software projects. It has these specific goals:
+
+1. To maximize the scale and diversity of the community around a project, by reducing the friction for new Contributors and creating a scaled participation model with strong positive feedbacks;
+1. To relieve dependencies on key individuals by separating different skill sets so that there is a larger pool of competence in any required domain;
+1. To allow the project to develop faster and more accurately, by increasing the diversity of the decision making process;
+1. To support the natural life cycle of project versions from experimental through to stable, by allowing safe experimentation, rapid failure, and isolation of stable code;
+1. To reduce the internal complexity of project repositories, thus making it easier for Contributors to participate and reducing the scope for error;
+1. To enforce collective ownership of the project, which increases economic incentive to Contributors and reduces the risk of hijack by hostile entities.
+
+## 2. Design
+
+### 2.1. Preliminaries
+
+1. The project MUST use the git distributed revision control system.
+1. The project MUST be hosted on github.com or equivalent, herein called the "Platform".
+1. The project MUST use the Platform issue tracker.
+1. The project SHOULD have clearly documented guidelines for code style.
+1. A "Contributor" is a person who wishes to provide a patch, being a set of commits that solve some clearly identified problem.
+1. A "Maintainer" is a person who merges patches to the project. Maintainers are not developers; their job is to enforce process.
+1. Contributors MUST NOT have commit access to the repository unless they are also Maintainers.
+1. Maintainers MUST have commit access to the repository.
+1. Everyone, without distinction or discrimination, MUST have an equal right to become a Contributor under the terms of this contract.
+
+### 2.2. Licensing and Ownership
+
+1. The project MUST use a share-alike license such as the MPLv2, or a GPLv3 variant thereof (GPL, LGPL, AGPL).
+1. All contributions to the project source code ("patches") MUST use the same license as the project.
+1. All patches are owned by their authors. There MUST NOT be any copyright assignment process.
+1. Each Contributor MUST be responsible for identifying themselves in the project Contributor list.
+
+### 2.3. Patch Requirements
+
+1. Maintainers and Contributors MUST have a Platform account and SHOULD use their real names or a well-known alias.
+1. A patch SHOULD be a minimal and accurate answer to exactly one identified and agreed problem.
+1. A patch MUST adhere to the code style guidelines of the project if these are defined.
+1. A patch MUST adhere to the "Evolution of Public Contracts" guidelines defined below.
+1. A patch MUST NOT include non-trivial code from other projects unless the Contributor is the original author of that code.
+1. A patch MUST compile cleanly and pass project self-tests on at least the principal target platform.
+1. A patch commit message SHOULD consist of a single short (less than 50 characters) line stating the problem ("Problem: ...") being solved, followed by a blank line and then the proposed solution ("Solution: ...").
+1. A "Correct Patch" is one that satisfies the above requirements.
+
+### 2.4. Development Process
+
+1. Change on the project MUST be governed by the pattern of accurately identifying problems and applying minimal, accurate solutions to these problems.
+1. To request changes, a user SHOULD log an issue on the project Platform issue tracker.
+1. The user or Contributor SHOULD write the issue by describing the problem they face or observe.
+1. The user or Contributor SHOULD seek consensus on the accuracy of their observation, and the value of solving the problem.
+1. Users MUST NOT log feature requests, ideas, suggestions, or any solutions to problems that are not explicitly documented and provable.
+1. Thus, the release history of the project MUST be a list of meaningful issues logged and solved.
+1. To work on an issue, a Contributor MUST fork the project repository and then work on their forked repository.
+1. To submit a patch, a Contributor MUST create a Platform pull request back to the project.
+1. A Contributor MUST NOT commit changes directly to the project.
+1. If the Platform implements pull requests as issues, a Contributor MAY directly send a pull request without logging a separate issue.
+1. To discuss a patch, people MAY comment on the Platform pull request, on the commit, or elsewhere.
+1. To accept or reject a patch, a Maintainer MUST use the Platform interface.
+1. Maintainers SHOULD NOT merge their own patches except in exceptional cases, such as non-responsiveness from other Maintainers for an extended period (more than 1-2 days).
+1. Maintainers MUST NOT make value judgments on correct patches.
+1. Maintainers MUST merge correct patches from other Contributors rapidly.
+1. Maintainers MAY merge incorrect patches from other Contributors with the goals of (a) ending fruitless discussions, (b) capturing toxic patches in the historical record, (c) engaging with the Contributor on improving their patch quality.
+1. The user who created an issue SHOULD close the issue after checking the patch is successful.
+1. Any Contributor who has value judgments on a patch SHOULD express these via their own patches.
+1. Maintainers SHOULD close user issues that are left open without action for an uncomfortable period of time.
+
+### 2.5. Branches and Releases
+
+1. The project MUST have one branch ("master") that always holds the latest in-progress version and SHOULD always build.
+1. The project MUST NOT use topic branches for any reason. Personal forks MAY use topic branches.
+1. To make a stable release a Maintainer must tag the repository. Stable releases MUST always be released from the repository master.
+
+### 2.6. Evolution of Public Contracts
+
+1. All Public Contracts (APIs or protocols) MUST be documented.
+1. All Public Contracts SHOULD have space for extensibility and experimentation.
+1. A patch that modifies a stable Public Contract SHOULD not break existing applications unless there is overriding consensus on the value of doing this.
+1. A patch that introduces new features SHOULD do so using new names (a new contract).
+1. New contracts SHOULD be marked as "draft" until they are stable and used by real users.
+1. Old contracts SHOULD be deprecated in a systematic fashion by marking them as "deprecated" and replacing them with new contracts as needed.
+1. When sufficient time has passed, old deprecated contracts SHOULD be removed.
+1. Old names MUST NOT be reused by new contracts.
+
+### 2.7. Project Administration
+
+1. The project founders MUST act as Administrators to manage the set of project Maintainers.
+1. The Administrators MUST ensure their own succession over time by promoting the most effective Maintainers.
+1. A new Contributor who makes correct patches, who clearly understands the project goals, and the process SHOULD be invited to become a Maintainer.
+1. Administrators SHOULD remove Maintainers who are inactive for an extended period of time, or who repeatedly fail to apply this process accurately.
+1. Administrators SHOULD block or ban "bad actors" who cause stress and pain to others in the project. This should be done after public discussion, with a chance for all parties to speak. A bad actor is someone who repeatedly ignores the rules and culture of the project, who is needlessly argumentative or hostile, or who is offensive, and who is unable to self-correct their behavior when asked to do so by others.
+
+## Further Reading
+
+- [Argyris' Models 1 and 2](http://en.wikipedia.org/wiki/Chris_Argyris) - the goals of C4 are consistent with Argyris' Model 2.
+
+- [Toyota Kata](http://en.wikipedia.org/wiki/Toyota_Kata) - covering the Improvement Kata (fixing problems one at a time) and the Coaching Kata (helping others to learn the Improvement Kata).

+ 68 - 0
docs/contributing.md

@@ -0,0 +1,68 @@
+# Contributing
+
+If you want to dive into the details, please see the [contract](./contract.md)
+that defines the contributor role in this project. If you're comfortable with
+a top-level summary, you can start here first.
+
+Our workflow is basically [GitHub Flow][github-flow] with specific roles:
+
+- **Contributor:** Write patches that reduce the number of problems.
+- **Maintainers:** Merge patches that reduce the number of problems.
+
+If you have an issue, it's best to open an issue to describe the problem and
+discuss solutions, but don't worry if you've already skipped that step.
+
+Assuming you already have a [developer install](./install.md) you should be
+able to start editing source code. There are a few useful commands you should
+know about:
+
+- **`npm install`**: Ensure that software dependencies are installed.
+- **`npm test`**: Ensure that all automated tests pass.
+- **`npm run fix`**: If an automated test failed, this may fix it.
+
+Please run `npm test` before writing a commit, because if there are errors then
+maintainers won't be able to merge your patch. Please ask for help if `npm test`
+is giving you any trouble.
+
+**Note:** `npm run fix` is run automatically as a pre-commit hook. You always
+have the option to disable pre-commit hooks with `git commit --no-verify`.
+
+## Frequently Failed Tests
+
+### Unknown word
+
+<!-- spell-checker:disable -->
+
+```
+/src/index.js:10:42 - Unknown word (Scuttlebtut)
+```
+
+<!-- spell-checker:enable -->
+
+If this word is a typo, please fix the typo. If this error is a mistake, and
+you're sure that this is a word, please add the word to `.cspell.json`.
+
+### Code style issues found
+
+```
+Checking formatting...
+src/index.js
+README.md
+Code style issues found in the above file(s). Forgot to run Prettier?
+```
+
+You can use `npm run fix` to resolve inconsistent code style. Please remember to
+add those changes with `git add` or similar before you commit.
+
+## Tips
+
+### TypeScript opportunities
+
+If you're looking for places where TypeScript would enjoy more detail, you can
+run the TypeScript linter with `--noImplicitAny`:
+
+```sh
+npx tsc --allowJs --resolveJsonModule --lib es2018,dom --checkJs --noEmit --skipLibCheck --noImplicitAny src/index.js
+```
+
+[github-flow]: https://guides.github.com/introduction/flow/

+ 22 - 0
docs/install.md

@@ -0,0 +1,22 @@
+# Install
+
+This is a guide on how to download the source code for Oasis so that you can
+build and install it on your device.
+
+--------------
+
+For a GNU/Linux based system, execute the following steps (from a shell):
+
+    sudo apt-get install git curl
+    curl -sL http://deb.nodesource.com/setup_14.x | sudo bash -
+    sudo apt-get install -y nodejs
+    git clone https://code.03c8.net/KrakensLab/oasis
+    cd oasis
+    sudo npm -g install .
+    oasis
+
+--------------
+
+If you want to run Oasis in the background, see [`with-systemd.md`](./with-systemd.md).
+
+If you want to run Oasis in a container, see [`with-docker.md`](./with-docker.md).

+ 35 - 0
docs/maintaining.md

@@ -0,0 +1,35 @@
+# Maintaining
+
+Please read the [contract](./contract) that defines the maintainer role in this
+project. In short:
+
+- Please merge any patches that reduce the number of problems in this project.
+- If you have small nitpicks about a patch, please merge the patch and write a
+  new patch with your preferred improvements.
+- **Take care of yourself and don't burn out.** Please don't sacrifice your
+  health to improve this project, and know that there are much more important
+  things in life than merging pull requests quickly.
+
+## Tips
+
+### Checking out a patch
+
+If you want to check out pull request number 42 and you're comfortable running
+the code on your local device.
+
+```sh
+remote="https://code.03c8.net/krakenlabs/oasis.git"
+git fetch "$remote"
+git reset --hard $remote master
+git pull "$remote" pull/42/head
+npm ci && npm test && npm start
+```
+
+No need to add their fork as a remote.
+
+Or for ultimate convenience (and github lock-in), use the [github cli tool](https://cli.github.com):
+
+```sh
+gh pr list
+gh pr checkout 42
+```

+ 31 - 0
docs/security.md

@@ -0,0 +1,31 @@
+# Security Policy
+
+## Security Model
+
+Oasis is experimental software, please don't trust it with your life.
+
+If everything is working correctly, it's likely that:
+
+- Only your computer can access Oasis.
+- Only you can publish a message to your feed.
+- Only the recipients of private messages can read the message.
+- Only basic HTML is supported in blobs, which can't access the rest of Oasis.
+
+It's important to know that this is not a silver bullet:
+
+- Your public messages can be read by anyone on the Secure Scuttlebutt network.
+- Your IP address can be seen by anyone that peers with you.
+- Your private messages can be read by anyone with access to your private key.
+
+You should also know:
+
+- Information that others can read can be saved, without your permission.
+- Encryption techniques that are unbreakable today may become compromised in the future; maybe in dozens or hundreds of years.
+
+## Supported Versions
+
+Only the latest release is supported.
+
+## Reporting a Vulnerability
+
+Send an email to solarnethub@riseup.net to report any security problems. Please do not use the public issue tracker.

+ 20 - 0
docs/with-docker.md

@@ -0,0 +1,20 @@
+# With Docker
+
+**Warning:** Experimental.
+
+Use [Docker](https://www.docker.com/) to run Oasis in a container.
+
+## Build
+
+```shell
+docker build --tag oasis --file contrib/Dockerfile  .
+docker volume create ssb
+```
+
+## Run
+
+```
+docker run --mount source=ssb,target=/home/node/.ssb --publish 127.0.0.1:3000:3000 --rm oasis
+```
+
+You should now be able to open http://localhost:3000 in your browser.

+ 14 - 0
docs/with-systemd.md

@@ -0,0 +1,14 @@
+## With systemd
+
+**Warning:** Experimental.
+
+If you're using Linux and your distribution supports
+[systemd](https://systemd.io/), you may be able to have Oasis automatically
+start in the background when you start your computer. This is good for SSB and
+makes it more likely that you'll download messages that you want.
+
+```shell
+node contrib/install-systemd-service.js
+```
+
+Follow the instructions to finish configuring the background service.

File diff suppressed because it is too large
+ 14327 - 0
package-lock.json


+ 122 - 0
package.json

@@ -0,0 +1,122 @@
+{
+  "name": "@krakenslab/oasis",
+  "version": "0.1.4",
+  "description": "SNH-Oasis Project Network GUI",
+  "repository": {
+    "type": "git",
+    "url": "git+ssh://git@code.03c8.net/krakenlabs/oasis.git"
+  },
+  "license": "AGPL-3.0",
+  "author": "psy <epsylon@riseup.net>",
+  "main": "src/index.js",
+  "bin": {
+    "oasis": "src/index.js"
+  },
+  "scripts": {
+    "dev": "nodemon --inspect src/index.js --debug --no-open",
+    "fix": "common-good fix",
+    "prestart": "git pull && npm install",
+    "start": "node src/index.js",
+    "test": "tap --timeout 240 && common-good test",
+    "preversion": "npm test",
+    "version": "mv docs/CHANGELOG.md ./ && changelog-version && mv CHANGELOG.md docs/ && git add docs/CHANGELOG.md"
+  },
+  "dependencies": {
+    "@fraction/base16-css": "^1.1.0",
+    "@koa/router": "^10.0.0",
+    "debug": "^4.1.1",
+    "env-paths": "^2.2.0",
+    "file-type": "^16.0.1",
+    "highlight.js": "^11.0.0",
+    "hyperaxe": "^1.3.0",
+    "is-svg": "^4.2.1",
+    "koa": "^2.7.0",
+    "koa-body": "^4.1.0",
+    "koa-mount": "^4.0.0",
+    "koa-static": "^5.0.0",
+    "lodash": "^4.17.11",
+    "lodash.shuffle": "^4.2.0",
+    "markdown-it": "^12.0.2",
+    "open": "^8.0.1",
+    "piexifjs": "^1.0.4",
+    "pretty-ms": "^7.0.1",
+    "pull-abortable": "^4.1.1",
+    "pull-paramap": "^1.2.2",
+    "pull-sort": "^1.0.2",
+    "pull-stream": "^3.6.12",
+    "require-style": "^1.1.0",
+    "secret-stack": "^6.3.0",
+    "sharp": "^0.28.0",
+    "ssb-about": "^2.0.1",
+    "ssb-backlinks": "^2.1.1",
+    "ssb-blobs": "^2.0.1",
+    "ssb-client": "^4.9.0",
+    "ssb-config": "^3.4.4",
+    "ssb-conn": "^6.0.3",
+    "ssb-db": "^20.3.0",
+    "ssb-ebt": "^7.0.1",
+    "ssb-friends": "^4.4.7",
+    "ssb-invite": "^2.1.6",
+    "ssb-keys": "^8.0.0",
+    "ssb-lan": "^1.0.0",
+    "ssb-logging": "^1.0.0",
+    "ssb-markdown": "^6.0.7",
+    "ssb-master": "^1.0.3",
+    "ssb-meme": "^1.1.0",
+    "ssb-mentions": "^0.5.2",
+    "ssb-msgs": "^5.2.0",
+    "ssb-no-auth": "^1.0.0",
+    "ssb-onion": "^1.0.0",
+    "ssb-ooo": "^1.3.3",
+    "ssb-plugins": "^1.0.2",
+    "ssb-private1": "^1.0.1",
+    "ssb-query": "^2.4.5",
+    "ssb-ref": "^2.13.9",
+    "ssb-replicate": "^1.3.0",
+    "ssb-room": "^1.3.0",
+    "ssb-search": "^1.3.0",
+    "ssb-tangle": "^1.0.1",
+    "ssb-thread-schema": "^1.1.1",
+    "ssb-unix-socket": "^1.0.0",
+    "ssb-ws": "^6.2.3",
+    "yargs": "^17.0.0"
+  },
+  "devDependencies": {
+    "@types/debug": "^4.1.5",
+    "@types/koa": "^2.11.3",
+    "@types/koa__router": "^8.0.2",
+    "@types/koa-mount": "^4.0.0",
+    "@types/koa-static": "^4.0.1",
+    "@types/lodash": "^4.14.150",
+    "@types/markdown-it": "^12.0.0",
+    "@types/mkdirp": "^1.0.0",
+    "@types/nodemon": "^1.19.0",
+    "@types/pull-stream": "^3.6.0",
+    "@types/sharp": "^0.28.0",
+    "@types/supertest": "^2.0.9",
+    "@types/yargs": "^17.0.2",
+    "changelog-version": "^2.0.0",
+    "common-good": "^4.0.3",
+    "husky": "^7.0.1",
+    "mkdirp": "^1.0.4",
+    "nodemon": "^2.0.3",
+    "stylelint-config-recommended": "^5.0.0",
+    "supertest": "^6.0.1",
+    "tap": "^14.10.7"
+  },
+  "optionalDependencies": {
+    "sharp": "^0.28.0"
+  },
+  "bugs": {
+    "url": "https://code.03c8.net/KrakensLab/snh-oasis/issues"
+  },
+  "homepage": "https://code.03c8.net/KrakensLab/snh-oasis",
+  "directories": {
+    "doc": "docs",
+    "test": "test"
+  },
+  "keywords": [],
+  "engines": {
+    "node": "^10.0.0 || >=12.0.0"
+  }
+}

+ 77 - 0
scripts/build.sh

@@ -0,0 +1,77 @@
+#!/bin/sh
+
+set -ex
+
+BASEDIR="$(dirname "$0")"
+TARGET_VERSION="0.1.2"
+
+cd "$BASEDIR/.."
+
+git clean -fdx
+
+mkdir -p vendor
+cd vendor
+
+get_tgz () {
+  TARGET_PLATFORM="$1"
+  TARGET="node-v$TARGET_VERSION-$TARGET_PLATFORM-x64"
+  ARCHIVE="$TARGET.tar.gz"
+  URL="https://nodejs.org/dist/v$TARGET_VERSION/$ARCHIVE"
+  TARGET_NODE="$TARGET/bin/node"
+
+  wget "$URL"
+  tar -xvf "$ARCHIVE" "$TARGET_NODE"
+  rm -f "$ARCHIVE"
+}
+
+get_zip () {
+  TARGET_PLATFORM="$1"
+  TARGET="node-v$TARGET_VERSION-$TARGET_PLATFORM-x64"
+  ARCHIVE="$TARGET.zip"
+  URL="https://nodejs.org/dist/v$TARGET_VERSION/$ARCHIVE"
+  TARGET_NODE="$TARGET/node.exe"
+
+  wget "$URL"
+  unzip "$ARCHIVE" "$TARGET_NODE"
+  rm -f "$ARCHIVE"
+}
+
+get_tgz darwin
+get_tgz linux
+get_zip win
+
+cd ..
+
+# Avoid building anything from source.
+npm ci --only=prod --ignore-scripts --no-audit --no-fund
+# More trouble than it's worth :)
+rm -rf ./node_modules/sharp
+
+export GOARCH="amd64"
+
+# Darwin (shell script)
+export GOOS="darwin"
+OUTFILE="oasis-$GOOS-$GOARCH"
+go build -ldflags "-X main.node=vendor/node-v$TARGET_VERSION-darwin-x64/bin/node" -o "$OUTFILE" scripts/oasis.go
+chmod +x "$OUTFILE"
+
+# Linux (ELF executable)
+export GOOS="linux"
+OUTFILE="oasis-$GOOS-$GOARCH"
+go build -ldflags "-X main.node=vendor/node-v$TARGET_VERSION-linux-x64/bin/node" -o "$OUTFILE" scripts/oasis.go
+chmod +x "$OUTFILE"
+
+# Windows (batch file)
+export GOOS="windows"
+OUTFILE="oasis-$GOOS-$GOARCH.exe"
+go build -ldflags "-X main.node=vendor\\node-v$TARGET_VERSION-win-x64\\bin\\node" -o "$OUTFILE" scripts/oasis.go
+chmod +x "$OUTFILE"
+
+# I think if the zip already exists it's adding files to the existing archive?
+ZIP_PATH="/tmp/oasis-x64.zip"
+
+rm -f "$ZIP_PATH"
+zip -r "$ZIP_PATH" . -x ".git/**"
+
+git clean -fdx
+

+ 57 - 0
scripts/oasis.go

@@ -0,0 +1,57 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+)
+
+// The relative path to the `node` binary depends on the platform, so we
+// pass this via an `-ldflags` hack I don't completely understand. In my
+// head this is similar to how GCC lets you use `-D` to define a macro to
+// be inserted by the preprocessor.
+var node string
+
+func main() {
+	// The problem with relative paths is that they only work when
+	// you run `./oasis-platform-x64`, but not when you run a command
+	// like `./path/to/oasis-platform-x64`. To resolve this problem
+	// we need to put together an absolute path, which we can build
+	// with the first argument (the relative path of this executable)
+	// and the relative path of either the `node` binary or the
+	// source code directory so that we can run `node src`.
+	node := filepath.Join(filepath.Dir(os.Args[0]), node)
+	src := filepath.Join(filepath.Dir(os.Args[0]), "src")
+
+	// We know that the command will be the absolute path to `node`
+	// and the first argument will be the absolute path to the `src`
+	// directory, but we need to get collect the rest of the arguments
+	// programatically by pulling them out of the `os.Args` slice and
+	// putting them in a new slice called `args`.
+	args := []string{src}
+	for i := 1; i < len(os.Args); i++ {
+		args = append(args, os.Args[i])
+	}
+
+	// This seems to execute the script and pass-through all of the
+	// arguments we want, *plus* it hooks up stdout and stderr, but
+	// the exit code of Oasis doesn't seem to be passed through. This
+	// is easy to test with a command like:
+	//
+	//	./oasis-platform-x64 --port -1
+	//
+	// This should give an exit code of 1, but it seems to exit 0. :/
+	cmd := exec.Command(node, args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	// This catches problems like "no such file or directory" if the
+	// `node` variable points to a path where there isn't a binary.
+	//
+	// TODO: I think we're supposed to handle the exit code here.
+	err := cmd.Run()
+	if err != nil {
+		fmt.Println(err)
+	}
+}

+ 10 - 0
scripts/release.sh

@@ -0,0 +1,10 @@
+#!/usr/bin/sh
+
+git push -f origin master:release-$(jq -r .version < package.json)
+
+until git push origin master; do
+  sleep 120;
+done
+
+npm publish
+

+ 4 - 0
src/assets/favicon.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+    <title>oasis favicon</title>
+    <text x="0" y="14">🏝️</text>
+</svg>

+ 63 - 0
src/assets/highlight.css

@@ -0,0 +1,63 @@
+.hljs-comment,
+.hljs-quote {
+  color: var(--base03);
+}
+
+.hljs-variable,
+.hljs-template-variable,
+.hljs-tag,
+.hljs-name,
+.hljs-selector-id,
+.hljs-selector-class,
+.hljs-regexp,
+.hljs-deletion {
+  color: var(--base08);
+}
+
+.hljs-number,
+.hljs-built_in,
+.hljs-builtin-name,
+.hljs-literal,
+.hljs-type,
+.hljs-params,
+.hljs-meta,
+.hljs-link {
+  color: var(--base09);
+}
+
+.hljs-attribute {
+  color: var(--base0A);
+}
+
+.hljs-string,
+.hljs-symbol,
+.hljs-bullet,
+.hljs-addition {
+  color: var(--base0B);
+}
+
+.hljs-title,
+.hljs-section {
+  color: var(--base0D);
+}
+
+.hljs-keyword,
+.hljs-selector-tag {
+  color: var(--base0E);
+}
+
+.hljs {
+  display: block;
+  overflow-x: auto;
+  background: white;
+  color: var(--base05);
+  padding: 0.5em;
+}
+
+.hljs-emphasis {
+  font-style: italic;
+}
+
+.hljs-strong {
+  font-weight: bold;
+}

+ 746 - 0
src/assets/style.css

@@ -0,0 +1,746 @@
+:root {
+  /*
+   * according to https://www.color-blindness.com/color-name-hue/
+   *
+   * amber
+   * chartreuse
+   * free-speech-green (lime)
+   * aqua
+   * blue
+   * electric-indigo
+   * hot-magenta
+   */
+  --red: var(--base08);
+  --orange: var(--base09);
+  --yellow: var(--base0A);
+  --green: var(--base0B);
+  --cyan: var(--base0C);
+  --blue: var(--base0D);
+  --violet: var(--base0E);
+  --magenta: var(--base0F);
+
+  /* convenience */
+  --bg: var(--base00);
+  --bg-status: var(--base01);
+  --bg-selection: var(--base02);
+  --fg-alt: var(--base03);
+  --fg-status: var(--base04);
+  --fg: var(--base05);
+  --fg-light: var(--base06);
+  --bg-light: var(--base07);
+
+  /* size (2^n) */
+  --size-3: 8rem;
+  --size-2: 4rem;
+  --size-1: 2rem;
+  --size-0: 1rem;
+  --size--1: 0.5rem;
+  --size--2: 0.25rem;
+  --size--3: 0.125rem;
+  --size--4: 0.0625rem;
+
+  /* size */
+  --common-radius: var(--size--2);
+  --measure: 36rem;
+  --line: 1.5rem;
+  --code-size: 85%;
+}
+
+* {
+  scrollbar-color: var(--fg-status) var(--bg);
+}
+
+::selection {
+  background-color: var(--bg-selection);
+  color: var(--fg-light);
+}
+
+html {
+  display: flex;
+  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+    "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
+    "Helvetica Neue", sans-serif;
+  justify-content: center;
+  font-size: 12pt;
+  line-height: 1.5;
+  margin: 0;
+  padding: 0;
+  overflow-y: scroll;
+}
+
+main {
+  margin: 0;
+  margin-bottom: var(--size-0);
+}
+
+/* https://www.desmos.com/calculator/3elcf5cwhn */
+h1 {
+  font-size: 133%;
+} /*     4 / 3 */
+h2 {
+  font-size: 115%;
+} /*     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,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  color: var(--bg-light);
+  margin: var(--size-0) 0;
+}
+
+ul,
+ol {
+  padding-left: var(--size-0);
+  margin-left: var(--size--3);
+}
+
+a {
+  color: var(--fg-light);
+}
+
+button,
+.file-button {
+  cursor: pointer;
+  background: var(--fg);
+  color: var(--bg);
+  border: var(--size--4) solid var(--fg);
+  padding: var(--size--1) var(--size-0);
+  border-radius: var(--common-radius);
+  font-size: 8pt;
+}
+
+.file-button {
+  float: right;
+  margin: 0;
+  background: transparent;
+  color: var(--fg);
+}
+
+#blob {
+  visibility: hidden;
+  height: 0;
+  padding: 0;
+  margin: 0;
+}
+
+section header a {
+  display: flex;
+  color: var(--fg-status);
+  text-decoration: none;
+  margin-right: var(--size--2);
+  margin-left: var(--size--2);
+  font-weight: bold;
+}
+
+/* For use with elements specific for
+ * rendering in a text browser and intended
+ * to be hidden in a graphical browser. */
+.text-browser {
+  display: none;
+}
+
+section > footer > div > a,
+section > footer > div > form > button {
+  color: var(--fg-status);
+  font-weight: bold;
+}
+
+section > footer > div > form > button {
+  display: inline-block;
+  border: 0;
+  background: transparent;
+  cursor: pointer;
+  padding: 0;
+}
+
+select,
+input {
+  background: var(--bg);
+  color: var(--fg);
+  border: var(--size--4) solid var(--bg-selection);
+  padding: var(--size--1);
+  margin: var(--size-0) 0;
+  -moz-appearance: none;
+  appearance: none;
+  border-radius: var(--common-radius);
+  display: block;
+}
+
+.contentWarning {
+  background-color: var(--bg);
+  box-sizing: border-box;
+  display: block;
+  font-size: var(--size-0);
+  padding: var(--size-0);
+  width: 100%;
+  margin: var(--size-0) 0;
+  border: var(--size--4) solid var(--bg-selection);
+  border-radius: var(--common-radius);
+  color: var(--fg);
+}
+
+textarea {
+  background-color: var(--bg);
+  box-sizing: border-box;
+  display: block;
+  font-size: var(--size-0);
+  padding: var(--size-0);
+  resize: vertical;
+  width: 100%;
+  margin: var(--size-0) 0;
+  height: 12rem;
+  border: var(--size--4) solid var(--bg-selection);
+  border-radius: var(--common-radius);
+  color: var(--fg);
+}
+
+button:focus,
+input:focus,
+select:focus,
+textarea:focus {
+  border-color: var(--blue);
+}
+
+/* Prevent button styles being applied to heart button */
+button:focus,
+button:hover {
+  background-color: var(--fg-light);
+}
+
+section > footer > div > form > button:hover,
+section > footer > div > form > button:focus {
+  background-color: transparent;
+}
+
+pre {
+  overflow-x: auto;
+  background-color: var(--bg);
+  padding: var(--size--1);
+  font-size: 92%;
+  border-radius: var(--common-radius);
+  border: var(--size--4) solid var(--bg-status);
+}
+
+section code {
+  max-width: 100%;
+  overflow-wrap: break-word;
+  padding: 0.125em 0.25em;
+  margin: 0;
+  font-size: var(--code-size);
+  background-color: var(--bg);
+  border-radius: var(--common-radius);
+  border: var(--size--4) solid var(--bg-status);
+}
+
+section pre code {
+  color: inherit;
+  padding: 0;
+  margin: 0;
+  font-size: 100%;
+  background-color: initial;
+  border: initial;
+  border-radius: initial;
+}
+
+section blockquote {
+  margin-left: 0;
+  border-left: var(--size--1) solid var(--bg-status);
+  padding-left: var(--size-0);
+}
+
+section img,
+section video {
+  max-width: 100%;
+  max-height: 100vh;
+  border-radius: var(--common-radius);
+  box-sizing: border-box;
+}
+
+section > h1 {
+  margin-top: 0;
+  padding-top: 0;
+}
+
+.profile > img,
+.profile > h1 {
+  display: inline-block;
+}
+
+section > header.profile {
+  height: auto;
+  justify-content: left;
+}
+
+.profile > img {
+  width: 4rem;
+  height: 4rem;
+  margin-right: var(--size-0);
+  border-radius: var(--common-radius);
+}
+
+.private {
+  border-left: var(--size--1) solid var(--violet);
+  border-color: var(--violet);
+}
+
+section.thread-target {
+  border: var(--size--4) solid var(--blue);
+  box-shadow: 0 0 var(--size--2) var(--blue);
+}
+
+section.thread-target.private {
+  border: var(--size--4) solid var(--violet);
+  border-left: var(--size--1) solid var(--violet);
+  border-color: var(--violet);
+  box-shadow: 0 0 var(--size--2) var(--violet);
+}
+
+section audio {
+  width: 100%;
+}
+
+@media screen {
+  html {
+    min-height: 100%;
+    color: var(--fg);
+    background-color: var(--bg-status);
+  }
+
+  body {
+    width: 100%;
+    max-width: var(--measure);
+    margin: 0;
+  }
+}
+
+nav {
+  margin: var(--size-0) 0;
+  margin-left: 20px;
+}
+
+nav > ul > li > a {
+  margin-left: 10px;
+  margin-right: 0px;
+  color: var(--fg);
+  text-decoration: none;
+  font-weight: bold;
+}
+
+.author-action > a {
+  text-decoration: underline;
+}
+
+section header a:hover {
+  text-decoration: underline;
+}
+
+nav > ul > li > a:hover {
+  text-decoration: underline;
+}
+
+nav > ul > li > a.current {
+  font-weight: bold;
+}
+
+section {
+  padding: var(--size-0);
+  border-radius: var(--common-radius);
+  margin: var(--size-0) 0;
+  word-wrap: break-word;
+  background: var(--bg);
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.indent section,
+.thread-container section {
+  margin: unset;
+  border-radius: unset;
+  border-bottom: var(--fg-alt) solid 1px;
+}
+
+.indent details[open] {
+  border-bottom: var(--fg-alt) solid 1px;
+}
+
+.indent section:last-of-type,
+.thread-container section:last-of-type {
+  border-bottom: unset;
+}
+
+.mentions-container {
+  display: grid;
+  grid-template-columns: 4rem auto;
+  grid-column-gap: 1rem;
+  margin-bottom: var(--size-0);
+}
+
+section > header {
+  background: var(--bg);
+  color: var(--fg-status);
+  margin-bottom: calc(-1 * var(--size--1));
+  margin-top: calc(-1 * var(--size--1));
+  padding-bottom: var(--size--1);
+  padding-top: var(--size--1);
+  position: sticky;
+  top: 0;
+  z-index: 1;
+}
+
+section > header > div {
+  display: flex;
+  justify-content: space-between;
+  flex-wrap: wrap;
+}
+
+section header a > .avatar {
+  width: var(--line);
+  height: var(--line);
+  border-radius: var(--common-radius);
+  margin-right: var(--size--2);
+}
+
+section header span {
+  display: inline-flex;
+}
+
+/*
+ * HACK: centered-footer
+ *
+ * When someone likes a message we want to submit the form and then redirect
+ * them back to the original page. Unfortunately when you link to anchor tags
+ * that scrolls the browser so that they're at the *top* of the page, not the
+ * center of the page. In our view we have an empty div with an appropriate
+ * anchor tag, so here we use CSS to center it on the screen.
+ *
+ * The code below creates padding-top that takes up 50% of the height of the
+ * viewport and then gets rid of it with negative margin. This has no effect
+ * on the display of the item, but means that when we link to the anchor tag
+ * it centers this empty element vertically on the screen.
+ *
+ * We also use `pointer-events: none` to ensure that this invisible div doesn't
+ * capture cursor events (clicks, drags, etc) on surrounding elements, because
+ * otherwise we could have a problem where someone clicks above the invisible
+ * div but the browser picogs they're clicking the gigantic amount of padding.
+ */
+section > .centered-footer {
+  padding-top: 50vh;
+  margin-top: -50vh;
+  pointer-events: none;
+}
+
+section > footer {
+  color: var(--fg-status);
+}
+
+section > footer br {
+  display: none;
+}
+
+section > footer > div {
+  display: flex;
+  justify-content: space-between;
+}
+
+section > footer > div > * {
+  text-decoration: none;
+}
+
+section > footer > div > form > button:first-of-type {
+  font-size: 100%;
+}
+
+section > footer > div > form > button.liked {
+  color: var(--red);
+}
+
+label {
+  display: block;
+  margin: 0;
+}
+
+nav > ul {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  margin: 0;
+  padding: 0;
+}
+
+nav > ul > li {
+  list-style: none;
+  margin-right: var(--size--1);
+}
+
+.profile {
+  display: flex;
+  margin-bottom: var(--size-0);
+}
+
+progress {
+  display: block;
+  width: 100%;
+}
+
+progress::-moz-progress-bar,
+progress::-webkit-progress-value,
+progress {
+  background: var(--blue);
+  border-color: var(--blue);
+}
+
+summary {
+  padding: var(--size--1);
+  margin: var(--size-0) 0;
+  cursor: pointer;
+  background: var(--bg);
+  border-radius: var(--common-radius);
+  list-style-type: "+ ";
+  border: var(--size--4) dashed var(--fg-status);
+}
+
+details[open] > summary {
+  list-style-type: "− ";
+}
+
+.indent > details > summary {
+  border: none;
+}
+
+.md-mention {
+  -moz-user-select: all;
+  -ms-user-select: all;
+  -webkit-user-select: all;
+  user-select: all;
+  background: none;
+  overflow: hidden;
+}
+
+table {
+  width: 100%;
+  table-layout: fixed;
+}
+
+td,
+th {
+  padding: var(--size--1);
+  outline: var(--size--4) solid var(--bg-status);
+}
+
+th {
+  text-align: left;
+  background-color: var(--bg-status);
+}
+
+input[type="search"] {
+  width: 100%;
+  margin: var(--size-0) 0;
+}
+
+.image-search-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-gap: var(--size-0);
+}
+
+.image-search-grid .image-result {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: var(--size--1) 0;
+  background: var(--bg);
+  border-radius: var(--common-radius);
+}
+
+.image-search-grid .image-result .result-text {
+  hyphens: auto;
+  text-align: center;
+}
+
+hr {
+  border: var(--size--4) solid var(--fg-alt);
+}
+
+.form-button-group {
+  display: flex;
+  justify-content: space-between;
+  margin: var(--size-0) 0;
+}
+
+/* sidebar only appears on big screens */
+@media (min-width: calc(45rem)) {
+  body > nav > ul {
+    justify-content: right;
+    flex-direction: column;
+    margin-right: var(--size-1);
+    position: sticky;
+    top: var(--size-0);
+  }
+  body > nav > ul > li {
+    margin-bottom: var(--size-0);
+  }
+  main {
+    width: 100%;
+    max-width: var(--measure);
+  }
+  body {
+    display: flex;
+    justify-content: center;
+    max-width: none;
+  }
+}
+
+/* Use the browser's default font rendering instead of using our fancy
+ * font-family above. This resolves a problem where some emoji were being
+ * rendered in the system-ui font, which is rarely what we want.
+ */
+.emoji {
+  font-family: initial;
+}
+
+/* This indent is used on the summaries page to create an indent of 1. It might
+ * be wise to nest these recursively on the thread view, which would make it so
+ * that we don't need any inline CSS anymore.
+ */
+.indent {
+  padding-left: 1rem;
+  border-left: var(--size--2) solid var(--bg-selection);
+}
+
+.mentions-image {
+  grid-row: 1 / span 2;
+}
+.mentions-image > img {
+  border: var(--fg) solid 1px;
+}
+.mentions-container .emoji {
+  font-size: 1.5rem;
+}
+
+.mentions-name {
+  font-size: 1.25rem;
+  text-decoration: unset;
+}
+
+.mentions-name:hover {
+  text-decoration: underline;
+}
+
+.emo-rel {
+  display: inline-grid;
+  align-items: center;
+  grid-template-columns: 2rem auto;
+  grid-column-gap: 0.25rem;
+}
+
+.mentions-listing {
+  display: inline;
+  background-color: var(--bg);
+  padding: var(--size--1);
+  border-radius: var(--common-radius);
+  border: var(--size--4) solid var(--bg-status);
+  user-select: all;
+  font-size: var(--size--1);
+  overflow-x: auto;
+  width: 24rem;
+}
+
+section.post-preview {
+  padding-top: 0;
+  background: var(--bg-selection);
+  border: var(--fg-alt) solid 1px;
+}
+
+section.post-preview > section > footer {
+  display: none;
+}
+
+section.post.blocked {
+  font-style: italic;
+}
+
+section > footer > div > a:hover,
+section > footer > div > form > button:hover {
+  text-decoration: underline;
+}
+
+.author-action {
+  flex-grow: 1;
+}
+
+section header .author > a:first-child {
+  margin-left: 0;
+  color: var(--fg-light);
+  font-weight: bold;
+}
+
+.theme-preview {
+  width: calc(100% / 15);
+  height: var(--size-0);
+  margin-top: var(--size-0);
+  display: inline-block;
+}
+
+.theme-preview-00 {
+  background-color: var(--base00);
+}
+.theme-preview-01 {
+  background-color: var(--base01);
+}
+.theme-preview-02 {
+  background-color: var(--base02);
+}
+.theme-preview-03 {
+  background-color: var(--base03);
+}
+.theme-preview-04 {
+  background-color: var(--base04);
+}
+.theme-preview-05 {
+  background-color: var(--base05);
+}
+.theme-preview-06 {
+  background-color: var(--base06);
+}
+.theme-preview-07 {
+  background-color: var(--base07);
+}
+.theme-preview-08 {
+  background-color: var(--base08);
+}
+.theme-preview-09 {
+  background-color: var(--base09);
+}
+.theme-preview-0A {
+  background-color: var(--base0A);
+}
+.theme-preview-0B {
+  background-color: var(--base0B);
+}
+.theme-preview-0C {
+  background-color: var(--base0C);
+}
+.theme-preview-0D {
+  background-color: var(--base0D);
+}
+.theme-preview-0E {
+  background-color: var(--base0E);
+}
+.theme-preview-0F {
+  background-color: var(--base0F);
+}

+ 61 - 0
src/cli.js

@@ -0,0 +1,61 @@
+"use strict";
+
+const yargs = require("yargs");
+const _ = require("lodash");
+
+/**
+ * @param {object} presets
+ * @param {string} defaultConfigFile
+ */
+module.exports = (presets, defaultConfigFile) =>
+  yargs
+    .scriptName("oasis")
+    .env("OASIS")
+    .help("h")
+    .alias("h", "help")
+    .usage("Usage: $0 [options]")
+    .options("open", {
+      describe:
+        "Automatically open app in web browser. Use --no-open to disable.",
+      default: _.get(presets, "open", true),
+      type: "boolean",
+    })
+    .options("offline", {
+      describe:
+        "Don't try to connect to scuttlebutt peers or pubs. This can be changed on the 'settings' page while Oasis is running.",
+      default: _.get(presets, "offline", false),
+      type: "boolean",
+    })
+    .options("host", {
+      describe: "Hostname for web app to listen on",
+      default: _.get(presets, "host", "localhost"),
+      type: "string",
+    })
+    .options("allow-host", {
+      describe:
+        "Extra hostname to be whitelisted (useful when running behind a proxy)",
+      default: _.get(presets, "allow-host", null),
+      type: "string",
+    })
+    .options("port", {
+      describe: "Port for web app to listen on",
+      default: _.get(presets, "port", 3000),
+      type: "number",
+    })
+    .options("public", {
+      describe:
+        "Assume Oasis is being hosted publicly, disable HTTP POST and redact messages from people who haven't given consent for public web hosting.",
+      default: _.get(presets, "public", false),
+      type: "boolean",
+    })
+    .options("debug", {
+      describe: "Use verbose output for debugging",
+      default: _.get(presets, "debug", false),
+      type: "boolean",
+    })
+    .options("theme", {
+      describe: "The theme to use, if a theme hasn't been set in the cookies",
+      default: _.get(presets, "theme", "classic-light"),
+      type: "string",
+    })
+    .epilog(`The defaults can be configured in ${defaultConfigFile}.`).argv;

+ 139 - 0
src/http.js

@@ -0,0 +1,139 @@
+const Koa = require("koa");
+const koaStatic = require("koa-static");
+const path = require("path");
+const mount = require("koa-mount");
+
+/**
+ * @type function
+ * @param {{ host: string, port: number, middleware: any[], allowHost: string | null }} input
+ * @return function
+ */
+module.exports = ({ host, port, middleware, allowHost }) => {
+  const assets = new Koa();
+  assets.use(koaStatic(path.join(__dirname, "assets")));
+
+  const app = new Koa();
+
+  const validHosts = [];
+
+  // All non-GET requests must have a path that doesn't start with `/blob/`.
+  const isValidRequest = (request) => {
+    // All requests must use our hostname to prevent DNS rebind attacks.
+    if (validHosts.includes(request.hostname) !== true) {
+      console.log(`Invalid HTTP hostname: ${request.hostname}`);
+      return false;
+    }
+
+    // All non-GET requests must ...
+    if (request.method !== "GET") {
+      // ...have a referer...
+      if (request.header.referer == null) {
+        console.log("No referer");
+        return false;
+      }
+
+      try {
+        const refererUrl = new URL(request.header.referer);
+        // ...with a valid hostname...
+        if (validHosts.includes(refererUrl.hostname) !== true) {
+          console.log(`Invalid referer hostname: ${refererUrl.hostname}`);
+          return false;
+        }
+
+        // ...and must not originate from a blob path.
+        if (refererUrl.pathname.startsWith("/blob/")) {
+          console.log(`Invalid referer path: ${refererUrl.pathname}`);
+          return false;
+        }
+      } catch (e) {
+        console.log(`Invalid referer URL: ${request.header.referer}`);
+        return false;
+      }
+    }
+
+    // If all of the above checks pass, this is a valid request.
+    return true;
+  };
+
+  app.on("error", (err, ctx) => {
+    // Output full error objects
+    console.error(err);
+
+    // Avoid printing errors for invalid requests.
+    if (isValidRequest(ctx.request)) {
+      err.message = err.stack;
+      err.expose = true;
+    }
+
+    return null;
+  });
+
+  app.use(mount("/assets", assets));
+
+  // headers
+  app.use(async (ctx, next) => {
+    const csp = [
+      "default-src 'none'",
+      "img-src 'self'",
+      "form-action 'self'",
+      "media-src 'self'",
+      "style-src 'self'",
+    ].join("; ");
+
+    // Disallow scripts.
+    ctx.set("Content-Security-Policy", csp);
+
+    // Disallow <iframe> embeds from other domains.
+    ctx.set("X-Frame-Options", "SAMEORIGIN");
+
+    const isBlobPath = ctx.path.startsWith("/blob/");
+
+    if (isBlobPath === false) {
+      // Disallow browsers overwriting declared media types.
+      //
+      // This should only happen on non-blob URLs.
+      ctx.set("X-Content-Type-Options", "nosniff");
+    }
+
+    // Disallow sharing referrer with other domains.
+    ctx.set("Referrer-Policy", "same-origin");
+
+    // Disallow extra browser features except audio output.
+    ctx.set("Feature-Policy", "speaker 'self'");
+
+    const validHostsString = validHosts.join(" or ");
+
+    ctx.assert(
+      isValidRequest(ctx.request),
+      400,
+      `Request must be addressed to ${validHostsString} and non-GET requests must contain non-blob referer.`
+    );
+
+    await next();
+  });
+
+  middleware.forEach((m) => app.use(m));
+
+  const server = app.listen({ host, port });
+
+  server.on("listening", () => {
+    const address = server.address();
+
+    if (typeof address === "string") {
+      // This shouldn't happen, but TypeScript was complaining about it.
+      throw new Error("HTTP server should never bind to Unix socket");
+    }
+
+    if (allowHost !== null) {
+      validHosts.push(allowHost);
+    }
+
+    validHosts.push(address.address);
+
+    if (validHosts.includes(host) === false) {
+      validHosts.push(host);
+    }
+  });
+
+  return server;
+};

File diff suppressed because it is too large
+ 1120 - 0
src/index.js


File diff suppressed because it is too large
+ 1913 - 0
src/models.js


+ 56 - 0
src/ssb/flotilla.js

@@ -0,0 +1,56 @@
+const stack = require("secret-stack");
+const shuffle = require("lodash.shuffle");
+const debug = require("debug")("oasis");
+const ssbConfig = require("ssb-config");
+
+const plugins = [
+  // Authentication often hooked for authentication.
+  require("ssb-master"),
+  // Methods often used during init().
+  require("ssb-db"),
+  // Method `replicate()` often hooked for improvements.
+  require("ssb-replicate"),
+  // Required by ssb-about, ssb-tangle, etc.
+  require("ssb-backlinks"),
+  // Required by ssb-room
+  require("ssb-conn"),
+  shuffle([
+    require("ssb-about"),
+    require("ssb-blobs"),
+    require("ssb-ebt"),
+    require("ssb-friends"),
+    require("ssb-invite"),
+    require("ssb-lan"),
+    require("ssb-logging"),
+    require("ssb-meme"),
+    require("ssb-no-auth"),
+    require("ssb-onion"),
+    require("ssb-ooo"),
+    require("ssb-plugins"),
+    require("ssb-private1"),
+    require("ssb-query"),
+    require("ssb-room/tunnel/client"),
+    require("ssb-search"),
+    require("ssb-tangle"),
+    require("ssb-unix-socket"),
+    require("ssb-ws"),
+  ]),
+];
+
+module.exports = (config) => {
+  const server = stack();
+
+  // TODO: Move this out of the main function.
+  const walk = (input) => {
+    if (Array.isArray(input)) {
+      input.forEach(walk);
+    } else {
+      debug(input.name || "???");
+      server.use(input);
+    }
+  };
+
+  walk(plugins);
+
+  return server({ ...ssbConfig, ...config });
+};

+ 221 - 0
src/ssb/index.js

@@ -0,0 +1,221 @@
+"use strict";
+
+// This module exports a function that connects to SSB and returns an interface
+// to call methods over MuxRPC. It's a thin wrapper around SSB-Client, which is
+// a thin wrapper around the MuxRPC module.
+
+const { promisify } = require("util");
+const ssbClient = require("ssb-client");
+const ssbConfig = require("ssb-config");
+const ssbTangle = require("ssb-tangle");
+const ssbKeys = require("ssb-keys");
+const debug = require("debug")("oasis");
+const path = require("path");
+const lodash = require("lodash");
+const fs = require("fs");
+const os = require("os");
+
+const flotilla = require("./flotilla");
+
+// Use temporary path if we're running a test.
+// TODO: Refactor away 'OASIS_TEST' variable.
+if (process.env.OASIS_TEST) {
+  ssbConfig.path = fs.mkdtempSync(path.join(os.tmpdir(), "oasis-"));
+  ssbConfig.keys = ssbKeys.generate();
+}
+
+const socketPath = path.join(ssbConfig.path, "socket");
+const publicInteger = ssbConfig.keys.public.replace(".ed25519", "");
+const remote = `unix:${socketPath}~noauth:${publicInteger}`;
+
+/**
+ * @param formatter {string} input
+ * @param args {any[]} input
+ */
+const log = (formatter, ...args) => {
+  const isDebugEnabled = debug.enabled;
+  debug.enabled = true;
+  debug(formatter, ...args);
+  debug.enabled = isDebugEnabled;
+};
+
+/**
+ * @param [options] {object} - options to pass to SSB-Client
+ * @returns Promise
+ */
+const connect = (options) =>
+  new Promise((resolve, reject) => {
+    const onSuccess = (api) => {
+      if (api.tangle === undefined) {
+        // HACK: SSB-Tangle isn't available in Patchwork, but we want that
+        // compatibility. This code automatically injects SSB-Tangle into our
+        // stack so that we don't get weird errors when using Patchwork.
+        api.tangle = ssbTangle.init(api);
+
+        // MuxRPC supports promises but the raw plugin does not.
+        api.tangle.branch = promisify(api.tangle.branch);
+      }
+
+      resolve(api);
+    };
+
+    ssbClient(process.env.OASIS_TEST ? ssbConfig.keys : null, options)
+      .then(onSuccess)
+      .catch(reject);
+  });
+
+let closing = false;
+let serverHandle;
+let clientHandle;
+
+/**
+ * Attempts connection over Unix socket, falling back to TCP socket if that
+ * fails. If the TCP socket fails, the promise is rejected.
+ * @returns Promise
+ */
+const attemptConnection = () =>
+  new Promise((resolve, reject) => {
+    const originalConnect = process.env.OASIS_TEST
+      ? new Promise((resolve, reject) =>
+          reject({
+            message: "could not connect to sbot",
+          })
+        )
+      : connect({ remote });
+
+    originalConnect
+      .then((ssb) => {
+        debug("Connected to existing Scuttlebutt service over Unix socket");
+        resolve(ssb);
+      })
+      .catch((e) => {
+        if (closing) return;
+        debug("Unix socket failed");
+        if (e.message !== "could not connect to sbot") {
+          throw e;
+        }
+        connect()
+          .then((ssb) => {
+            log("Connected to existing Scuttlebutt service over TCP socket");
+            resolve(ssb);
+          })
+          .catch((e) => {
+            if (closing) return;
+            debug("TCP socket failed");
+            if (e.message !== "could not connect to sbot") {
+              throw e;
+            }
+            reject(new Error("Both connection options failed"));
+          });
+      });
+  });
+
+let pendingConnection = null;
+
+const ensureConnection = (customConfig) => {
+  if (pendingConnection === null) {
+    pendingConnection = new Promise((resolve) => {
+      attemptConnection()
+        .then((ssb) => {
+          resolve(ssb);
+        })
+        .catch(() => {
+          debug("Connection attempts to existing Scuttlebutt services failed");
+          log("Starting Scuttlebutt service");
+
+          // Adjust with `customConfig`, which declares further preferences.
+          serverHandle = flotilla(customConfig);
+
+          // Give the server a moment to start. This is a race condition. :/
+          setTimeout(() => {
+            attemptConnection()
+              .then(resolve)
+              .catch((e) => {
+                throw new Error(e);
+              });
+          }, 100);
+        });
+    });
+
+    const cancel = () => (pendingConnection = null);
+    pendingConnection.then(cancel, cancel);
+  }
+
+  return pendingConnection;
+};
+
+module.exports = ({ offline }) => {
+  if (offline) {
+    log("Offline mode activated - not connecting to scuttlebutt peers or pubs");
+    log(
+      "WARNING: Oasis can connect to the internet through your other SSB apps if they're running."
+    );
+  }
+
+  // Make a copy of `ssbConfig` to avoid mutating.
+  const customConfig = JSON.parse(JSON.stringify(ssbConfig));
+
+  // This is unnecessary when https://github.com/ssbc/ssb-config/pull/72 is merged
+  customConfig.connections.incoming.unix = [
+    { scope: "device", transform: "noauth" },
+  ];
+
+  // Only change the config if `--offline` is true.
+  if (offline === true) {
+    lodash.set(customConfig, "conn.autostart", false);
+  }
+
+  // Use `conn.hops`, or default to `friends.hops`, or default to `0`.
+  lodash.set(
+    customConfig,
+    "conn.hops",
+    lodash.get(ssbConfig, "conn.hops", lodash.get(ssbConfig.friends.hops, 0))
+  );
+
+  /**
+   * This is "cooler", a tiny interface for opening or reusing an instance of
+   * SSB-Client.
+   */
+  const cooler = {
+    open() {
+      // This has interesting behavior that may be unexpected.
+      //
+      // If `clientHandle` is already an active [non-closed] connection, return that.
+      //
+      // 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
+      // that app is closed, then Oasis will seamlessly start its own SSB service.
+      return new Promise((resolve, reject) => {
+        if (clientHandle && clientHandle.closed === false) {
+          resolve(clientHandle);
+        } else {
+          ensureConnection(customConfig).then((ssb) => {
+            clientHandle = ssb;
+            if (closing) {
+              cooler.close();
+              reject(new Error("Closing Oasis"));
+            } else {
+              resolve(ssb);
+            }
+          });
+        }
+      });
+    },
+    close() {
+      closing = true;
+      if (clientHandle && clientHandle.closed === false) {
+        clientHandle.close();
+      }
+      if (serverHandle) {
+        serverHandle.close();
+      }
+    },
+  };
+
+  // Important: This ensures that we have an SSB connection as soon as Oasis
+  // starts. If we don't do this, then we don't even attempt an SSB connection
+  // until we receive our first HTTP request.
+  cooler.open();
+
+  return cooler;
+};

+ 399 - 0
src/views/i18n.js

@@ -0,0 +1,399 @@
+const { a, em, strong } = require("hyperaxe");
+
+const i18n = {
+  en: {
+    // navbar items
+    extended: "Extended",
+    extendedDescription: [
+      "When you support someone you may download posts from the inhabitants they support, and those posts show up here, sorted by recency.",
+    ],
+    popular: "Highlights",
+    popularDescription: [
+      "Posts from inhabitants in your network, ",
+      strong("sorted by spreads"),
+      ". Select the period of time, to get a list.",
+    ],
+    day: "Day",
+    week: "Week",
+    month: "Month",
+    year: "Year",
+    latest: "Latest",
+    latestDescription: [
+      strong("Posts"),
+      " from yourself and inhabitants you support, sorted by recency.",
+    ],
+    topics: "Themes",
+    topicsDescription: [
+      strong("Themes"),
+      " from yourself and inhabitants you support, sorted by recency. Select the timestamp of any post to see the rest of the thread.",
+    ],
+    summaries: "Summaries",
+    summariesDescription: [
+      strong("Themes and some comments"),
+      " from yourself and inhabitants you support, sorted by recency. Select the timestamp of any post to see the rest of the thread.",
+    ],
+    threads: "Threads",
+    threadsDescription: [
+      strong("Posts that have comments"),
+      " from inhabitants you support and your extended network, sorted by recency. Select the timestamp of any post to see the rest of the thread.",
+    ],
+    profile: "Avatar",
+    manualMode: "Manual Mode",
+    mentions: "Mentions",
+    mentionsDescription: [
+      strong("Posts that @mention you"),
+      ", sorted by recency.",
+    ],
+    private: "Inbox",
+    peers: "Peers",
+    privateDescription: [
+      "The latest comment from ",
+      strong("private threads that include you"),
+      ", sorted by recency. Private posts are encrypted for your public key, and have a maximum of 7 recipients. Recipients cannot be added after the thread has started. Select the timestamp to view the full thread.",
+    ],
+    search: "Search",
+    imageSearch: "Image Search",
+    settings: "Settings",
+    // post actions
+    comment: "Comment",
+    subtopic: "Subtopic",
+    json: "JSON",
+    // relationships
+    unfollow: "Unsupport",
+    follow: "Support",
+    block: "Block",
+    unblock: "Unblock",
+    newerPosts: "Newer posts",
+    olderPosts: "Older posts",
+    feedRangeEmpty: "The given range is empty for this feed. Try viewing the ",
+    seeFullFeed: "full feed",
+    feedEmpty: "The local client has never seen posts from this account.",
+    beginningOfFeed: "This is the beginning of the feed",
+    noNewerPosts: "No newer posts have been received yet.",
+    relationshipNotFollowing: "",
+    relationshipTheyFollow: "",
+    relationshipMutuals: "",
+    relationshipFollowing: "You are supporting",
+    relationshipYou: "You",
+    relationshipBlocking: "You are blocking",
+    relationshipNone: "",
+    relationshipConflict: "",
+    relationshipBlockingPost: "Blocked post",
+    // spreads view
+    viewLikes: "View spreads",
+    clonedDescription: "List of posts spread by the inhabitant.",
+    likedBy: " -> Spreads",
+    // composer
+    attachFiles: "Attach files",
+    mentionsMatching: "Matching mentions",
+    preview: "Preview",
+    publish: "Publish",
+    contentWarningPlaceholder: "Add a subject to the post (optional)",
+    publishWarningPlaceholder: "...",
+    publishCustomDescription: [
+      "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.",
+    ],
+    commentWarning: [
+      "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.",
+    ],
+    commentPublic: "public",
+    commentPrivate: "private",
+    commentLabel: ({ publicOrPrivate, markdownUrl }) => [
+    ],
+    publishLabel: ({ markdownUrl, linkTarget }) => [
+      "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.",
+    ],
+    replyLabel: ({ markdownUrl }) => [
+      "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.",
+    ],
+    publishCustomInfo: ({ href }) => [
+      "If you have experience, you can also ",
+      a({ href }, "publish an advanced post"),
+      ".",
+    ],
+    publishBasicInfo: ({ href }) => [
+      "If you have not experience, you should ",
+      a({ href }, "publish a post"),
+      ".",
+    ],
+    publishCustom: "Publish advanced post",
+    subtopicLabel: ({ markdownUrl }) => [
+      "Create a ",
+      strong("public subtopic"),
+      " of this post with ",
+      a({ href: markdownUrl }, "Markdown"),
+      ". Posts cannot be edited or deleted. To respond to an entire thread, select ",
+      strong("comment"),
+      " instead. Preview shows attached media.",
+    ],
+    // settings
+    versionIntro: "Version",
+    info: "Info",
+    settingsIntro: ({ version }) => [
+      `SNH-Oasis: ${version}...`,
+    ],
+    // SNH
+    docsUrls: ({ snhUrl, projectUrl, roleUrl }) => [
+      a({ href: snhUrl }, "Website"),
+      " | ",
+      a({ href: projectUrl }, "The Project Network"),
+      " | ",
+      a({ href: roleUrl }, "Role-playing"),
+    ],
+    theme: "Theme",
+    themeIntro:
+      "Choose a theme.",
+    setTheme: "Set theme",
+    language: "Language",
+    languageDescription:
+      "If you'd like to use another language, select it here.",
+    setLanguage: "Set language",
+    status: "Status",
+    peerConnections: "Peers",
+    connectionsIntro:
+      "Your device is syncing data with these other devices:",
+    noConnections: "No peers connected.",
+    connectionActionIntro:
+      "",
+    startNetworking: "Start networking",
+    stopNetworking: "Stop networking",
+    restartNetworking: "Restart networking",
+    sync: "Sync",
+    indexes: "Indexes",
+    indexesDescription:
+      "Rebuilding your indexes is safe, and may fix some types of bugs.",
+    invites: "Invites",
+    invitesDescription:
+      "Use the PUB's invite codes here.",
+    acceptInvite: "Accept invite",
+    // search page
+    searchLabel: "Seek inhabitants and keywords, among the posts you have downloaded.",
+    // image search page
+    imageSearchLabel: "Enter words to search for images labelled with them.",
+    // posts and comments
+    commentDescription: ({ parentUrl }) => [
+      " commented on ",
+      a({ href: parentUrl }, " thread"),
+    ],
+    commentTitle: ({ authorName }) => [`Comment on @${authorName}'s post`],
+    subtopicDescription: ({ parentUrl }) => [
+      " created a subtopic from ",
+      a({ href: parentUrl }, " a post"),
+    ],
+    subtopicTitle: ({ authorName }) => [`Subtopic on @${authorName}'s post`],
+    mysteryDescription: "posted a mysterious post",
+    // misc
+    oasisDescription: "SNH Project Network",
+    submit: "Submit",
+    editProfile: "Edit Avatar",
+    editProfileDescription:
+      "",
+    profileName: "Avatar name (plain text)",
+    profileImage: "Avatar image",
+    profileDescription: "Avatar description (Markdown)",
+    hashtagDescription:
+      "Posts from inhabitants in your network that reference this #hashtag, sorted by recency.",
+    rebuildName: "Rebuild database",
+  },
+  /* spell-checker: disable */
+  es: {
+    latest: "Novedades",
+    profile: "Avatar",
+    search: "Buscar",
+    imageSearch: "Buscar Imágenes",
+    settings: "Configuración",
+    // navbar items
+    extended: "Extendida",
+    extendedDescription: [
+      "Cuando apoyes a alguien, podrás descargar publicaciones de habitantes que apoye, y esas publicaciones aparecerán aquí, ordenadas por las más recientes.",
+    ],
+    popular: "Destacadas",
+    day: "Día",
+    week: "Semana",
+    month: "Mes",
+    year: "Año",
+    popularDescription: [
+      "Posts de habitantes de tu red, ",
+      strong("ordenados por difusiones"),
+      ". Selecciona el periodo de tiempo, para obtener una lista.",
+    ],
+    latestDescription: [
+      strong("Posts"), 
+      " tuyos y de habitantes que apoyas, ordenados por los más recientes.",
+    ],
+    topics: "Temáticas",
+    topicsDescription: [
+      strong("Temáticas"),
+      " tuyas y de habitantes que apoyas, ordenadas por las más recientes. Selecciona la hora de una publicación para leer el hilo completo.",
+    ],
+    summaries: "Resumen",
+    summariesDescription: [
+      strong("Temáticas y algunos comentarios"),
+      " tuyos y de habitantes que apoyas, ordenado por lo más reciente. Selecciona la hora de una publicación para leer el hilo completo.",
+    ],
+    threads: "Hilos",
+    threadsDescription: [
+      strong("Posts que tienen comentarios"),
+      " de habitantes que apoyas y de tu red extendida, ordenados por los más recientes. Selecciona la hora de una publicación para leer el hilo completo.",
+    ],
+    manualMode: "Modo manual",
+    mentions: "Menciones",
+    mentionsDescription: [
+      strong("Posts que te @mencionan"),
+      ", ordenados por los más recientes.",
+    ],
+    private: "Buzón",
+    peers: "Enlaces",
+    privateDescription: [
+      "Los comentarios más recientes de ",
+      strong("hilos privados que te incluyen"),
+      ". Las publicaciones privadas están cifradas para ti, y contienen un máximo de 7 destinatarios. No se podrán añadir nuevos destinarios después de que empieze el hilo. Selecciona la hora de una publicación para leer el hilo completo.",
+    ],
+    // post actions
+    comment: "Comentar",
+    reply: "Responder",
+    subtopic: "Subhilo",
+    json: "JSON",
+    // relationships
+    relationshipNotFollowing: "",
+    relationshipTheyFollow: "",
+    relationshipMutuals: "",
+    relationshipFollowing: "Apoyando",
+    relationshipYou: "Tú",
+    relationshipBlocking: "Bloqueado",
+    relationshipNone: "",
+    relationshipConflict: "",
+    relationshipBlockingPost: "Post bloqueado",
+    unfollow: "Dejar de apoyar",
+    follow: "Apoyar",
+    block: "Bloquear",
+    unblock: "Desbloquear",
+    newerPosts: "Nuevos posts",
+    olderPosts: "Anteriores posts",
+    feedRangeEmpty: "El rango requerido está vacío para éste hilo. Prueba a ver el ",
+    seeFullFeed: "hilo completo",
+    feedEmpty: "No tienes posts de ésta cuenta.",
+    beginningOfFeed: "Éste es el comienzo del hilo",
+    noNewerPosts: "No se han recibido nuevos posts aún.",
+    // spreads view
+    viewLikes: "Ver difusiones",
+    clonedDescription: "Listado de posts difundidos del habitante.",
+    likedBy: " -> Difusiones",
+    // composer
+    attachFiles: "Agregar archivos",
+    mentionsMatching: "Menciones coincidentes",
+    preview: "Vista previa",
+    publish: "Publicar",
+    contentWarningPlaceholder: "Añade un asunto al post (opcional)",
+    publishWarningPlaceholder: "...",
+    publishCustomDescription: [
+      "RECUERDA: Debido a la tecnología blockchain, una vez publicado un post, no podrá ser editado o borrado.",
+    ],
+    commentWarning: [
+      " RECUERDA: Debido a la tecnología blockchain, una vez publicado un post, no podrá ser editado o borrado.",
+    ],
+    commentPublic: "público",
+    commentPrivate: "privado",
+    commentLabel: ({ publicOrPrivate, markdownUrl }) => [
+    ],
+    publishLabel: ({ markdownUrl, linkTarget }) => [
+      "RECUERDA: Debido a la tecnología blockchain, una vez publicado un post, no podrá ser editado o borrado.",
+    ],
+    publishCustomInfo: ({ href }) => [
+      "Si tienes experiencia, también puedes ",
+      a({ href }, "publicar un post avanzado"),
+      ".",
+    ],
+    publishBasicInfo: ({ href }) => [
+      "Si no tienes experiencia, lo mejor es ",
+      a({ href }, "publicar un post normal"),
+      ".",
+    ],
+    publishCustom: "Publicar post avanzado",
+    replyLabel: ({ markdownUrl }) => [
+      "RECUERDA: Debido a la tecnología blockchain, una vez publicados los posts, no podrán ser editados o borrados.",
+    ],
+    // settings-es
+    versionIntro: "Versión",
+    info: "Info",
+    settingsIntro: ({ version }) => [
+      `SNH-Oasis: ${version}...`,
+    ],
+    // SNH
+    docsUrls: ({ snhUrl, projectUrl, roleUrl }) => [
+      a({ href: snhUrl }, "Website"),
+      " | ",
+      a({ href: projectUrl }, "The Project Network"),
+      " | ",
+      a({ href: roleUrl }, "Role-playing"),
+    ],
+    theme: "Tema",
+    themeIntro:
+      "Elige un tema.",
+    setTheme: "Seleccionar tema",
+    language: "Idioma",
+    languageDescription:
+      "Si quieres usar otro idioma, seleccionalo aquí.",
+    setLanguage: "Seleccionar idioma",
+    status: "Estado",
+    peerConnections: "Enlaces",
+    connectionsIntro:
+      "Tu dispositivo está sincronizando datos con los siguientes dispositivos:",
+    noConnections: "Sin enlaces conectados.",
+    connectionActionIntro:
+      "",
+    startNetworking: "Iniciar red",
+    stopNetworking: "Detener red",
+    restartNetworking: "Reiniciar red",
+    sync: "Sincronizar",
+    indexes: "Índices",
+    indexesDescription:
+      "Reconstruir la caché de forma segura, puede solucionar algunos errores si se presentan.",
+    invites: "Invitaciones",
+    invitesDescription:
+      "Utiliza los códigos de invitación de los PUBs aquí.",
+    acceptInvite: "Aceptar la invitación",
+    // search page
+    searchLabel:
+      "Busca habitantes y palabras clave, entre los posts que tienes descargados.",
+    // posts and comments
+    commentDescription: ({ parentUrl }) => [
+      " comentó en el hilo ",
+      a({ href: parentUrl }, ""),
+    ],
+    replyDescription: ({ parentUrl }) => [
+      " respondido al ",
+      a({ href: parentUrl }, "post "),
+    ],
+    // image search page
+    imageSearchLabel:
+      "Busca entre los títulos de las imágenes que tienes descargadas.",
+    // posts and comments
+    commentTitle: ({ authorName }) => [
+      `Comentó en el post de @${authorName}`,
+    ],
+    subtopicDescription: ({ parentUrl }) => [
+      " creó un nuevo hilo para ",
+      a({ href: parentUrl }, "este post"),
+    ],
+    subtopicTitle: ({ authorName }) => [
+      `Nuevo hilo en el post de @${authorName}`,
+    ],
+    mysteryDescription: "publicó un post misterioso",
+    // misc
+    oasisDescription:
+      "Red de Proyectos de SNH",
+    submit: "Aceptar",
+    editProfile: "Editar avatar",
+    editProfileDescription:
+      "",
+    profileName: "Nombre del avatar (texto)",
+    profileImage: "Imagen del avatar",
+    profileDescription: "Descripción del avatar (Markdown)",
+    hashtagDescription:
+      "Posts de habitantes en tu red que referencian a ésta #etiqueta, ordenados por los más recientes.",
+    rebuildName: "Reconstruir base de datos",
+  },
+};
+
+module.exports = i18n;

File diff suppressed because it is too large
+ 1493 - 0
src/views/index.js


+ 62 - 0
src/views/markdown.js

@@ -0,0 +1,62 @@
+"use strict";
+
+const md = require("ssb-markdown");
+const ssbMessages = require("ssb-msgs");
+const ssbRef = require("ssb-ref");
+const { span } = require("hyperaxe");
+
+/** @param {{ link: string}[]} mentions */
+const toUrl = (mentions) => {
+  /** @type {{name: string, link: string}[]} */
+  const mentionNames = [];
+
+  /** @param {{ link: string, name: string}} arg */
+  const handleLink = ({ name, link }) => {
+    if (typeof name === "string") {
+      const atName = name.charAt(0) === "@" ? name : `@${name}`;
+      mentionNames.push({ name: atName, link });
+    }
+  };
+
+  ssbMessages.links(mentions, "feed").forEach(handleLink);
+
+  /** @param {string} ref */
+  const urlHandler = (ref) => {
+    // @mentions
+    const found = mentionNames.find(({ name }) => name === ref);
+    if (found !== undefined) {
+      return `/author/${encodeURIComponent(found.link)}`;
+    }
+
+    if (ssbRef.isFeedId(ref)) {
+      return `/author/${encodeURIComponent(ref)}`;
+    }
+    if (ssbRef.isMsgId(ref)) {
+      return `/thread/${encodeURIComponent(ref)}`;
+    }
+    const splitIndex = ref.indexOf("?");
+    const blobRef = splitIndex === -1 ? ref : ref.slice(0, splitIndex);
+    // const blobParams = splitIndex !== -1 ? ref.slice(splitIndex) : "";
+
+    if (ssbRef.isBlobId(blobRef)) {
+      return `/blob/${encodeURIComponent(blobRef)}`;
+    }
+    if (ref && ref[0] === "#") {
+      return `/hashtag/${encodeURIComponent(ref.substr(1))}`;
+    }
+    return "";
+  };
+
+  return urlHandler;
+};
+
+/**
+ * @param {string} input
+ * @param {{name: string, link: string}[]} mentions
+ */
+module.exports = (input, mentions = []) =>
+  md.block(input, {
+    toUrl: toUrl(mentions),
+    /** @param character {string} */
+    emoji: (character) => span({ class: "emoji" }, character).outerHTML,
+  });