Browse Source

code checkpoint

psy 9 months ago
parent
commit
0950062902

+ 5 - 0
.depcheckrc

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

+ 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"
+  }
+}

+ 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


+ 80 - 2
README.md

@@ -1,3 +1,81 @@
-# oasis
+# SNH-Oasis
 
-SolarNET.HuB (SNH) - The Project Network - Oasis https://solarnethub.com 
+  ![SNH](https://solarnethub.com/lib/tpl/dokuwiki/images/logo.png "SolarNET.HuB")
+
+## Description:
+
+SNH-Oasis is a **free, open-source, encrypted, peer-to-peer, distributed & federated**... project networking application 
+that helps you follow interesting content and discover new ones.
+
+  ![SNH](https://solarnethub.com/_media/socialnet/snh-oasis_profile-2.png "SolarNET.HuB")
+
+ +  No browser JavaScript!. Just pure HTML+CSS.
+ +  Use your favorite web browser to read and write messages to the people you care about.
+ +  Strong cryptography in every single point of the network.
+ +  You are the center of your own distributed network. Online or offline, it works anywhere that you are.
+ +  Initial identities are randomnly generated (no username or password required).
+ +  No personal profile generated (no questions about gender, age, location, etc …).
+ +  No email or associated mobile phone required.
+ +  Automatic exif stripping (such as GPS coordinates) on images for better privacy.
+
+----------
+
+## Installing:
+
+Follow ['INSTALL.md'](docs/install.md) to build and install it on your device.
+
+----------
+
+## Setup:
+
+Visit ['Settings'](https://solarnethub.com/socialnet/snh#settings_minimal) to learn how to choose your language, set a theme & configure your avatar.
+
+----------
+
+## Multiverse:
+
+Join ['PUB: "La Plaza"'](https://solarnethub.com/socialnet/snh-pub) to start to be connected with other interesting projects in the Multiverse.
+
+  ![SNH](https://solarnethub.com/_media/socialnet/snh-oasis_federation-2.png "SolarNET.HuB")
+  
+This allows you to communicate and access content from outside the [project network](https://solarnethub.com/socialnet/overview). 
+
+  ![SNH](https://solarnethub.com/_media/socialnet/snh-multiverse.png "SolarNET.HuB")
+
+----------
+
+## SNH-Hub:
+
+The public content of the ['PUB: "La Plaza"'](https://solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
+
+  ![SNH](https://solarnethub.com/_media/socialnet/snh-pub-feed.png "SolarNET.HuB") 
+  
+Just visit: https://pub.solarnethub.com/
+
+  ![SNH](https://solarnethub.com/_media/socialnet/snh-pub-laplaza.png "SolarNET.HuB")
+
+----------
+
+## Roadmap:
+
+Review ['Roadmap'](https://solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
+
+----------
+
+## Development:
+
+Check ['Call 4 Hackers'](https://solarnethub.com/community/hackers) for contributing with developments.
+
+----------
+
+## 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
+ + The KIT: https://solarnethub.com/kit/overview
+ + Ecosystem: https://solarnethub.com/socialnet/ecosystem
+ + Project Network: https://solarnethub.com/socialnet/snh#the_project_network
+ + Role-playing: https://solarnethub.com/socialnet/roleplaying
+ + Warehouse: https://solarnethub.com/stock/submit_request

+ 26 - 0
docs/CHANGELOG.md

@@ -0,0 +1,26 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+<!--
+## [Unreleased]
+
+### Added
+### Changed
+### Deprecated
+### Removed
+### Fixed
+### Security
+-->
+
+## v0.2.3 - 2022-11-05
+
+### Added
+
+- Federation with SSB Multiverse
+
+## 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>

+ 1 - 0
docs/MAINTAINERS

@@ -0,0 +1 @@
+psy <epsylon@riseup.net>

+ 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

+ 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/

+ 16 - 0
docs/install.md

@@ -0,0 +1,16 @@
+# Install
+
+This is a guide on how to download the source code for SNH-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
+    npm install .
+    npm run start

+ 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.

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


+ 165 - 0
package.json

@@ -0,0 +1,165 @@
+{
+  "name": "@krakenslab/oasis",
+  "version": "0.2.9",
+  "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": "npm run start"
+  },
+  "scripts": {
+    "dev": "nodemon --inspect src/index.js --debug --no-open",
+    "fix": "common-good fix",
+    "prestart": "",
+    "start": "npm run start-server && npm run start-client",
+    "start-server": "node src/server.js start &",
+    "start-client": "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",
+    "await-exec": "^0.1.2",
+    "broadcast-stream": "^0.2.1",
+    "debug": "^4.3.1",
+    "env-paths": "^2.2.0",
+    "epidemic-broadcast-trees": "^9.0.4",
+    "file-type": "^16.0.1",
+    "has-network": "0.0.1",
+    "highlight.js": "^11.0.0",
+    "hyperaxe": "^1.3.0",
+    "ip": "^1.1.5",
+    "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",
+    "mdmanifest": "^1.0.8",
+    "minimist": "^1.1.3",
+    "mkdirp": "^1.0.4",
+    "multiblob": "^1.13.0",
+    "multiserver": "^3.3.1",
+    "multiserver-address": "^1.0.1",
+    "muxrpc": "^6.7.3",
+    "muxrpc-validation": "^3.0.2",
+    "muxrpcli": "^3.1.2",
+    "node-iframe": "^1.8.5",
+    "open": "^8.0.1",
+    "packet-stream": "^2.0.6",
+    "packet-stream-codec": "^1.2.0",
+    "piexifjs": "^1.0.4",
+    "pretty-ms": "^7.0.1",
+    "pull-abortable": "^4.1.1",
+    "pull-cat": "~1.1.5",
+    "pull-file": "^1.0.0",
+    "pull-many": "~1.0.6",
+    "pull-paramap": "^1.2.2",
+    "pull-pushable": "^2.2.0",
+    "pull-sort": "^1.0.2",
+    "pull-stream": "^3.6.12",
+    "request": "^2.88.1",
+    "require-style": "^1.1.0",
+    "scuttle-poll": "^1.5.1",
+    "secret-stack": "^6.4.1",
+    "ssb-about": "^2.0.1",
+    "ssb-backlinks": "^2.1.1",
+    "ssb-blobs": "^2.0.1",
+    "ssb-box": "^1.0.1",
+    "ssb-caps": "^1.0.1",
+    "ssb-client": "^4.9.0",
+    "ssb-config": "^3.4.4",
+    "ssb-conn": "^6.0.3",
+    "ssb-conn-db": "^1.0.5",
+    "ssb-conn-hub": "^1.2.0",
+    "ssb-conn-query": "^1.2.2",
+    "ssb-conn-staging": "^1.0.0",
+    "ssb-db": "^20.3.0",
+    "ssb-db2": "^6.1.1",
+    "ssb-device-address": "^1.1.6",
+    "ssb-ebt": "^8.1.2",
+    "ssb-friend-pub": "^1.0.7",
+    "ssb-friends": "^5.0.0",
+    "ssb-gossip": "^1.1.1",
+    "ssb-invite": "^3.0.1",
+    "ssb-invite-client": "^1.3.3",
+    "ssb-keys": "^8.0.0",
+    "ssb-lan": "^1.0.0",
+    "ssb-legacy-conn": "^2.0.0",
+    "ssb-links": "^3.0.10",
+    "ssb-local": "^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-partial-replication": "^3.0.1",
+    "ssb-plugins": "^1.0.2",
+    "ssb-private1": "^1.0.1",
+    "ssb-query": "^2.4.5",
+    "ssb-ref": "^2.16.0",
+    "ssb-replication-scheduler": "^2.0.2",
+    "ssb-room": "^1.3.0",
+    "ssb-search": "^1.3.0",
+    "ssb-search2": "^2.1.3",
+    "ssb-server": "^15.3.0",
+    "ssb-tangle": "^1.0.1",
+    "ssb-thread-schema": "^1.1.1",
+    "ssb-threads": "^10.0.4",
+    "ssb-tribes": "^3.1.1",
+    "ssb-tunnel": "^2.0.0",
+    "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",
+    "nodemon": "^2.0.3",
+    "stylelint-config-recommended": "^5.0.0",
+    "supertest": "^6.0.1",
+    "tap": "^14.10.7"
+  },
+  "optionalDependencies": {
+    "sharp": "^0.31.1",
+    "fsevents": "^2.3.2"
+  },
+  "bugs": {
+    "url": "https://code.03c8.net/KrakensLab/snh-oasis/issues"
+  },
+  "homepage": "https://code.03c8.net/KrakensLab/snh-oasis",
+  "directories": {
+    "doc": "docs"
+  },
+  "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;
+}

+ 748 - 0
src/assets/style.css

@@ -0,0 +1,748 @@
+: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: 40px;
+}
+
+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: 108%;
+  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;
+  margin-right: 30px;
+  #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;
+  padding-right: 50px;
+}
+
+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
+ 1136 - 0
src/index.js


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


File diff suppressed because it is too large
+ 173 - 0
src/server.js


+ 10 - 0
src/ssb/cli-cmd-aliases.js

@@ -0,0 +1,10 @@
+module.exports = {
+  feed: 'createFeedStream',
+  history: 'createHistoryStream',
+  hist: 'createHistoryStream',
+  public: 'getPublicKey',
+  pub: 'getPublicKey',
+  log: 'createLogStream',
+  logt: 'messagesByType',
+  conf: 'config'
+}

+ 47 - 0
src/ssb/flotilla.js

@@ -0,0 +1,47 @@
+const stack = require("secret-stack");
+const debug = require("debug")("oasis");
+const ssbConfig = require("ssb-config");
+
+const plugins = [
+  // Authentication often hooked for authentication.
+  require("ssb-master"),
+  require("ssb-db"),
+  require("ssb-backlinks"),
+  require("ssb-conn"),
+  require("ssb-about"),
+  require("ssb-blobs"),
+  require("ssb-ebt"),
+  require("ssb-friends"),
+  require("ssb-replication-scheduler"),
+  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();
+  const walk = (input) => {
+    if (Array.isArray(input)) {
+      input.forEach(walk);
+    } else {
+      debug(input.name || "???");
+      server.use(input);
+    }
+  };
+
+  walk(plugins);
+
+  return server({ ...ssbConfig, ...config });
+};

+ 190 - 0
src/ssb/index.js

@@ -0,0 +1,190 @@
+"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 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.
+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 = (ssb) => {
+      resolve(ssb);
+    };
+    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) => {
+      setTimeout(() => {
+      attemptConnection()
+        .then((ssb) => {
+          resolve(ssb);
+        })
+        .catch(() => {
+          serverHandle = flotilla(customConfig);
+            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");
+  }
+
+  // Make a copy of `ssbConfig` to avoid mutating.
+  const customConfig = JSON.parse(JSON.stringify(ssbConfig));
+
+  // 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();
+      }
+    },
+  };
+
+  cooler.open();
+
+  return cooler;
+};

+ 45 - 0
src/ssb/progress.js

@@ -0,0 +1,45 @@
+module.exports = function (progress) {
+  function bar (r) {
+    var s = '\r', M = 50
+    for(var i = 0; i < M; i++)
+      s += i < M*r ? '*' : '.'
+
+    return s
+  }
+
+  function round (n, p) {
+    return Math.round(n * p) / p
+  }
+
+  function percent (n) {
+    return (round(n, 1000)*100).toString().substring(0, 4)+'%'
+  }
+
+  function rate (prog) {
+    if(prog.target == prog.current) return 1
+    return (prog.current - prog.start) / (prog.target - prog.start)
+  }
+
+  var prog = -1
+  var int = setInterval(function () {
+    var p = progress()
+    var r = 1, c = 0
+    var tasks = []
+    for(var k in p) {
+      var _r = rate(p[k])
+      if(_r < 1)
+        tasks.push(k+':'+percent(_r))
+      r = Math.min(_r, r)
+      c++
+    }
+    if(r != prog) {
+      prog = r
+      var msg = tasks.join(', ')
+      process.stdout.write('\r'+bar(prog) + ' ('+msg+')\x1b[K\r')
+    }
+  }, 333)
+  int.unref && int.unref()
+}
+
+
+

+ 152 - 0
src/supports.js

@@ -0,0 +1,152 @@
+#!/usr/bin/env node
+
+const fs = require("fs");
+const path = require("path");
+const homedir = require('os').homedir();
+const supportingPath = path.join(homedir, ".ssb/flume/contacts2.json");
+const {
+  a,
+  br,
+  li,
+} = require("hyperaxe");
+
+const envPaths = require("env-paths");
+const cli = require("./cli");
+const ssb = require("./ssb");
+const defaultConfig = {};
+const defaultConfigFile = path.join(
+  envPaths("oasis", { suffix: "" }).config,
+  "/default.json"
+);
+
+const config = cli(defaultConfig, defaultConfigFile);
+if (config.debug) {
+  process.env.DEBUG = "oasis,oasis:*";
+}
+const cooler = ssb({ offline: config.offline });
+const { about} = require("./models")({
+  cooler,
+  isPublic: config.public,
+});
+
+async function getNameByIdSupported(supported){
+  name_supported = await about.name(supported);
+  return name_supported
+}
+
+async function getNameByIdBlocked(blocked){
+  name_blocked = await about.name(blocked);
+  return name_blocked
+}
+
+async function getNameByIdRecommended(recommended){
+  name_recommended = await about.name(recommended);
+  return name_recommended
+}
+
+  try{
+      var supporting = JSON.parse(fs.readFileSync(supportingPath, {encoding:'utf8', flag:'r'})).value;
+    }catch{
+      var supporting = undefined;
+    }
+    if (supporting == undefined) {
+        var supportingValue = "false";
+    }else{
+        var keys = Object.keys(supporting);
+        if (keys[0] === undefined){
+          var supportingValue = "false";
+        }else{
+          var supportingValue = "true";
+        }
+    }
+
+    if (supportingValue === "true") {
+      var arr = [];
+      var keys = Object.keys(supporting);
+        var data = Object.entries(supporting[keys[0]]);
+        Object.entries(data).forEach(([key, value]) => {
+         if (value[1]===1){
+          var supported = (value[0])
+           if (!arr.includes(supported)) {
+              getNameSupported(supported);
+              async function getNameSupported(supported){
+                 name_supported = await getNameByIdSupported(supported);
+              arr.push(
+               li(
+                 name_supported,br,
+                 a(
+                  { href: `/author/${encodeURIComponent(supported)}` }, 
+                  supported
+                 )
+               ), br
+              );
+             }
+           }
+         }
+      });
+    }else{
+      var arr = [];
+    }
+    var supports = arr;
+
+    if (supportingValue === "true") {
+      var arr2 = [];
+      var keys = Object.keys(supporting);
+      var data = Object.entries(supporting[keys[0]]);
+       Object.entries(data).forEach(([key, value]) => {
+         if (value[1]===-1){
+          var blocked = (value[0])
+           if (!arr2.includes(blocked)) {
+              getNameBlocked(blocked);
+              async function getNameBlocked(blocked){
+                 name_blocked = await getNameByIdBlocked(blocked);
+              arr2.push(
+               li(
+                 name_blocked,br,
+                 a( 
+                  { href: `/author/${encodeURIComponent(blocked)}` },
+                  blocked
+                 )
+               ), br
+              );
+             }
+           }
+         }
+      });
+    }else{
+      var arr2 = [];
+    }
+    var blocks = arr2;
+
+    if (supportingValue === "true") {
+      var arr3 = [];
+      var keys = Object.keys(supporting);
+      var data = Object.entries(supporting[keys[0]]);
+       Object.entries(data).forEach(([key, value]) => {
+         if (value[1]===-2){
+          var recommended = (value[0])
+           if (!arr3.includes(recommended)) {
+              getNameRecommended(recommended);
+              async function getNameRecommended(recommended){
+                 name_recommended = await getNameByIdRecommended(recommended);
+              arr3.push(
+               li(
+                 name_recommended,br,
+                 a( 
+                  { href: `/author/${encodeURIComponent(recommended)}` },
+                  recommended
+                 )
+               ), br
+              );
+             }
+           }
+         }
+      });
+    }else{
+      var arr3 = [];
+    }
+    var recommends = arr3;
+
+module.exports.supporting = supports;
+module.exports.blocking = blocks;
+module.exports.recommending = recommends;

+ 43 - 0
src/updater.js

@@ -0,0 +1,43 @@
+const request = require("request");
+const fs = require("fs");
+const path = require("path");
+const
+  promisify = require('util').promisify,
+  cb = promisify(request);
+const localpackage = path.join("package.json");
+const remoteUrl = "https://code.03c8.net/KrakensLab/oasis/src/master/package.json" // Official SNH-Oasis
+const remoteUrl2 = "https://github.com/epsylon/oasis/blob/main/package.json" // Mirror SNH-Oasis
+
+let requestInstance;
+
+exports.getRemoteVersion = function(callback){
+(async () => {
+  if (fs.existsSync(".git")) {
+    requestInstance = await cb(remoteUrl, function(error, response, body) {
+      if (error != null){
+        checkMirror(); 
+      }else{
+        diffVersion(body);
+      };
+    });
+    function checkMirror(){
+      requestInstance2 = request(remoteUrl2, function (error, response, body) {
+        diffVersion(body);
+      });
+    };
+    function diffVersion(body){
+      remoteVersion =  body.split('<li class="L3" rel="L3">').pop().split('</li>')[0];
+      remoteVersion = remoteVersion.split('&#34;version&#34;: &#34;').pop().split('&#34;,')[0];
+      localVersion = fs.readFileSync(localpackage, "utf8");
+      localVersion = localVersion.split('"name":').pop().split('"description":')[0];
+      localVersion = localVersion.split('"version"').pop().split('"')[1];
+      if (remoteVersion != localVersion){
+        checkversion = "required";
+      }else{
+        checkversion = "";
+      };
+    callback(checkversion);
+    };
+  };
+})();
+};

+ 407 - 0
src/views/i18n.js

@@ -0,0 +1,407 @@
+const { a, em, strong } = require("hyperaxe");
+
+const i18n = {
+  en: {
+    // navbar items
+    extended: "Multiverse",
+    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 multiverse, sorted by recency. Select the timestamp of any post to see the rest of the thread.",
+    ],
+    profile: "Avatar",
+    inhabitants: "Inhabitants", 
+    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",
+    continueReading: "continue reading",
+    moreComments: "more comment",
+    readThread: "read the rest of the thread",
+    // 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",
+    spreadedDescription: "List of posts spread by the inhabitant.",
+    likedBy: " -> Spreads",
+    // composer
+    attachFiles: "Attach files",
+    mentionsMatching: "Matching mentions",
+    preview: "Preview",
+    publish: "Write",
+    contentWarningPlaceholder: "Add a subject to the post (optional)",
+    privateWarningPlaceholder: "Add inhabitants to send a private post (ex: @bob @alice) (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 }, "write an advanced post"),
+      ".",
+    ],
+    publishBasicInfo: ({ href }) => [
+      "If you have not experience, you should ",
+      a({ href }, "write a post"),
+      ".",
+    ],
+    publishCustom: "Write 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
+    updateit: "Get updates",
+    info: "Info",
+    settingsIntro: ({ version }) => [
+      `SNH-Oasis: [${version}]`,
+    ],
+    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",
+    online: "Online",
+    supported: "Supported",
+    recommended: "Recommended", 
+    blocked: "Blocked",
+    noConnections: "No peers connected.",
+    noSupportedConnections: "No peers supported.",
+    noBlockedConnections: "No peers blocked.",
+    noRecommendedConnections: "No peers recommended.",
+    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",
+    acceptedInvites: "Federated Networks",
+    noInvites: "No invites accepted.",
+    // 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",
+    inhabitants: "Habitantes",
+    search: "Buscar",
+    imageSearch: "Buscar Imágenes",
+    settings: "Configuración",
+    continueReading: "continuar leyendo",
+    moreComments: "comentario",
+    readThread: "leer el resto del hilo",
+    // navbar items
+    extended: "Multiverso",
+    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 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 publicación para leer el hilo completo.",
+    ],
+    threads: "Hilos",
+    threadsDescription: [
+      strong("Posts que tienen comentarios"),
+      " de habitantes que apoyas y de tu multiverso, ordenados por los más recientes. Selecciona la hora de 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 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",
+    spreadedDescription: "Listado de posts difundidos del habitante.",
+    likedBy: " -> Difusiones",
+    // composer
+    attachFiles: "Agregar archivos",
+    mentionsMatching: "Menciones coincidentes",
+    preview: "Vista previa",
+    publish: "Escribir",
+    contentWarningPlaceholder: "Añade un asunto al post (opcional)",
+    privateWarningPlaceholder: "Añade habitantes para enviar un post privado (ej: @bob @alice) (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 }, "escribir un post avanzado"),
+      ".",
+    ],
+    publishBasicInfo: ({ href }) => [
+      "Si no tienes experiencia, lo mejor es ",
+      a({ href }, "escribir un post normal"),
+      ".",
+    ],
+    publishCustom: "Escribir 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
+    updateit: "Obtener actualizaciones",
+    info: "Info",
+    settingsIntro: ({ version }) => [
+      `SNH-Oasis: [${version}]`,
+    ],
+    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",
+    online: "Online",
+    supported: "Soportados",
+    recommended: "Recomendados",
+    blocked: "Bloqueados",
+    noConnections: "Sin enlaces conectados.",
+    noSupportedConnections: "Sin enlaces soportados.",
+    noBlockedConnections: "Sin enlaces bloqueados.",
+    noRecommendedConnections: "Sin enlaces recomendados.",
+    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 invitación",
+    acceptedInvites: "Redes Federadas",
+    noInvites: "Sin invitaciones aceptadas.",
+    // search page
+    searchLabel:
+      "Busca habitantes y palabras clave, entre los posts que tienes descargados.",
+    // posts and comments
+    commentDescription: ({ parentUrl }) => [
+      " comentó en el",
+      a({ href: parentUrl }, " hilo"),
+    ],
+    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
+ 1549 - 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,
+  });