├── .eslintrc.js
├── .github
├── release-drafter.yml
└── workflows
│ ├── docker-hub.yml
│ ├── publish.yml
│ └── test-and-lint.yml
├── .gitignore
├── .npmignore
├── .travis.yml
├── DEVELOPMENT.md
├── README.md
├── bin
├── armadietto.js
├── conf.modular.json
├── conf.monolithic.json
├── dev-conf.json
├── test-suite-storage
│ └── rs
│ │ ├── rs-test-other
│ │ └── auth.json
│ │ └── rs-test
│ │ └── auth.json
├── test-suite.json
└── www
├── contrib
├── openwrt
│ ├── README.md
│ └── armadietto.sh
└── systemd
│ ├── README.md
│ └── armadietto.service
├── docker
├── Dockerfile-monolithic
└── README.md
├── example
├── README.md
├── ignore
│ ├── code.html
│ ├── logging.js
│ ├── styles.css
│ └── welcome.txt
├── index.html
├── load.html
├── package-lock.json
├── package.json
├── server.js
└── ssl
│ ├── npm-debug.log
│ ├── server.crt
│ └── server.key
├── lib
├── appFactory.js
├── armadietto.js
├── assets
│ ├── admin-users.mjs
│ ├── armadietto-utilities.js
│ ├── armadietto.svg
│ ├── contact-url.mjs
│ ├── favicon.svg
│ ├── login.mjs
│ ├── oauth.mjs
│ ├── outfit-variablefont_wght.woff2
│ ├── passkeymajor-svgrepo-com.svg
│ ├── register.mjs
│ ├── simplewebauthn-browser.js
│ ├── sprite.svg
│ └── style.css
├── controllers
│ ├── assets.js
│ ├── base.js
│ ├── oauth.js
│ ├── storage.js
│ ├── users.js
│ └── web_finger.js
├── logger.js
├── middleware
│ ├── formOrQueryData.js
│ ├── rateLimiterMiddleware.js
│ ├── redirectToSSL.js
│ ├── sanityCheckUsername.js
│ └── secureRequest.js
├── robots.txt
├── routes
│ ├── S3_store_router.js
│ ├── account.js
│ ├── admin.js
│ ├── index.js
│ ├── login.js
│ ├── oauth.js
│ ├── request-invite.js
│ ├── storage_common.js
│ └── webfinger.js
├── stores
│ ├── core.js
│ └── file_tree.js
├── util
│ ├── EndResponseError.js
│ ├── NoSuchBlobError.js
│ ├── NoSuchUserError.js
│ ├── ParameterError.js
│ ├── corsMiddleware.js
│ ├── errToMessages.js
│ ├── errorPage.js
│ ├── getHost.js
│ ├── getOriginator.js
│ ├── isSecureRequest.js
│ ├── loginOptsWCreds.js
│ ├── nameFromUseragent.js
│ ├── normalizeETag.js
│ ├── protocols.js
│ ├── removeUserDataFromSession.js
│ ├── replaceUint8.js
│ ├── shorten.js
│ ├── timeoutError.js
│ ├── updateSessionPrivileges.js
│ ├── validations.js
│ ├── verifyCredential.js
│ └── widelyCompatibleId.js
└── views
│ ├── account.html
│ ├── account.xml
│ ├── account
│ └── account.html
│ ├── admin
│ ├── invite-requests.html
│ ├── invite-valid.html
│ └── users.html
│ ├── auth-passkey.html
│ ├── auth.html
│ ├── begin.html
│ ├── contact-url.html
│ ├── end.html
│ ├── error.html
│ ├── footer.html
│ ├── header.html
│ ├── host.xml
│ ├── index.html
│ ├── index2.html
│ ├── login.html
│ ├── login
│ ├── error.html
│ ├── login.html
│ ├── logout.html
│ ├── request-invite-success.html
│ └── request-invite.html
│ ├── resource.xml
│ ├── signup-success.html
│ └── signup.html
├── notes
├── S3-store-router.md
├── modular-server.md
├── monolithic-server.md
├── protocol.md
└── reverse-proxy-configuration.md
├── package-lock.json
├── package.json
└── spec
├── account.spec.js
├── armadietto
├── a_not_found_spec.js
├── a_oauth_spec.js
├── a_root_spec.js
├── a_signup_spec.js
├── a_static_spec.js
├── a_storage_spec.js
├── a_web_finger.spec.js
├── oauth_spec.js
├── signup_spec.js
├── storage_spec.js
└── web_finger_spec.js
├── modular
├── account.spec.js
├── admin.spec.js
├── m_not_found.spec.js
├── m_oauth.spec.js
├── m_root.spec.js
├── m_static.spec.js
├── m_storage_common.spec.js
├── m_web_finger.spec.js
├── protocols.spec.js
├── request_invite.spec.js
├── robots.txt.spec.js
└── updateSessionPrivileges.spec.js
├── not_found.spec.js
├── oauth.spec.js
├── root.spec.js
├── runner.js
├── signup.spec.js
├── static_files.spec.js
├── storage_common.spec.js
├── store_handler.spec.js
├── store_handlers
└── S3_store_handler.spec.js
├── store_spec.js
├── stores
├── file_tree_lockfree_spec.js
├── file_tree_spec.js
└── redis_spec.js
├── util
├── LongStream.js
├── callMiddleware.js
├── longString.js
├── mockAccount.js
└── mostRecentFile.js
├── web_finger.spec.js
└── whut2.jpg
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "extends": "standard",
3 | "rules": {
4 | "semi": [2, "always"],
5 | "no-extra-semi": 2,
6 | "n/no-deprecated-api": "warn"
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | template: |
2 | ## Changes
3 |
4 | $CHANGES
5 |
--------------------------------------------------------------------------------
/.github/workflows/docker-hub.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | release:
5 | types: [published]
6 | push:
7 | branches:
8 | - '*'
9 | tags:
10 | - '*'
11 |
12 | jobs:
13 | version:
14 | runs-on: ubuntu-latest
15 | outputs:
16 | version: ${{ steps.version.outputs.version }}
17 | steps:
18 | - id: version
19 | name: Get and check version
20 | run: |
21 | export VERSION=$(echo $GITHUB_REF | sed -re 's/^.*\/([0-9a-zA-Z._-]+)$/\1/')
22 | echo "::set-output name=version::$VERSION"
23 | echo version is $VERSION
24 | build:
25 | runs-on: ubuntu-latest
26 | needs:
27 | - version
28 | env:
29 | DOCKER_USER: ${{ secrets.DOCKER_USER }}
30 | steps:
31 | - uses: actions/checkout@v4
32 | - name: Set up Docker Buildx
33 | uses: docker/setup-buildx-action@v3
34 | - name: Build
35 | uses: docker/build-push-action@v6
36 | with:
37 | context: .
38 | file: ./docker/Dockerfile-monolithic
39 | tags: ${{ env.DOCKER_USER }}/armadietto-monolithic:${{ needs.version.outputs.version }}
40 | outputs: type=docker,dest=/tmp/docker.tar
41 | - name: Upload artifact
42 | uses: actions/upload-artifact@v4
43 | with:
44 | name: docker
45 | path: /tmp/docker.tar
46 | sec_test:
47 | runs-on: ubuntu-latest
48 | needs:
49 | - version
50 | - build
51 | env:
52 | DOCKER_USER: ${{ secrets.DOCKER_USER }}
53 | steps:
54 | - name: Download artifacts
55 | uses: actions/download-artifact@v4
56 | with:
57 | name: docker
58 | path: /tmp
59 | - name: Load docker image
60 | run: docker load --input /tmp/docker.tar
61 | - name: Run security tests
62 | continue-on-error: true
63 | run: |
64 | docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image --exit-code 1 $DOCKER_USER/armadietto-monolithic:${{ needs.version.outputs.version }}
65 | #e2e_test:
66 | # runs-on: ubuntu-latest
67 | # needs:
68 | # - version
69 | # - build
70 | # steps:
71 | # - uses: actions/checkout@v4
72 | # - name: Download artifacts
73 | # uses: actions/download-artifact@v4
74 | # with:
75 | # name: docker
76 | # path: /tmp
77 | # - name: Load docker image
78 | # run: docker load --input /tmp/docker.tar
79 | publish:
80 | if: github.ref_type == 'tag'
81 | runs-on: ubuntu-latest
82 | needs:
83 | - version
84 | - sec_test
85 | #- e2e_test
86 | env:
87 | DOCKER_USER: ${{ secrets.DOCKER_USER }}
88 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
89 | steps:
90 | - name: Download artifacts
91 | uses: actions/download-artifact@v4
92 | with:
93 | name: docker
94 | path: /tmp
95 | - name: Load docker image
96 | run: docker load --input /tmp/docker.tar
97 | - name: Publish Docker image
98 | run: |
99 | docker login -u $DOCKER_USER -p $DOCKER_TOKEN
100 | docker push $DOCKER_USER/armadietto-monolithic:${{ needs.version.outputs.version }}
101 | docker tag $DOCKER_USER/armadietto-monolithic:${{ needs.version.outputs.version }} $DOCKER_USER/armadietto-monolithic
102 | docker push $DOCKER_USER/armadietto-monolithic
103 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on:
3 | push:
4 | branches: [ master ]
5 |
6 | jobs:
7 | Lint:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - run: echo "🎉 This job was triggered by a “${{ github.event_name }}” event on “${{ github.ref }}”."
11 |
12 | - uses: actions/checkout@v2
13 | - uses: actions/setup-node@v2
14 | with:
15 | node-version: 16
16 | - run: npm ci
17 | - run: npm run lint
18 |
19 | Automated-Tests:
20 | runs-on: ubuntu-latest
21 | strategy:
22 | matrix:
23 | # Support LTS versions based on https://nodejs.org/en/about/releases/
24 | node-version: ['18', '20', '21']
25 | steps:
26 | - uses: actions/checkout@v2
27 | - uses: actions/setup-node@v2
28 | with:
29 | node-version: ${{ matrix.node-version }}
30 | - run: npm ci
31 | - run: npm test
32 |
33 | Conditional-Publish-to-NPM:
34 | needs: [Lint, Automated-Tests]
35 | runs-on: ubuntu-latest
36 | steps:
37 | - uses: actions/checkout@v2
38 | - uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939
39 | with:
40 | token: ${{ secrets.NPM_TOKEN }}
41 |
42 | - if: steps.publish.outputs.type != 'none'
43 | run: |
44 | echo "NPM ${{steps.publish.outputs.type}} version changed: ${{ steps.publish.outputs.old-version }} => ${{ steps.publish.outputs.version }}"
45 |
--------------------------------------------------------------------------------
/.github/workflows/test-and-lint.yml:
--------------------------------------------------------------------------------
1 | name: test-and-lint
2 | on:
3 | push:
4 | branches: [ master, modular ]
5 | pull_request:
6 | branches: [ master, modular ]
7 | jobs:
8 | build:
9 | name: node.js
10 | runs-on: ubuntu-latest
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | # Support LTS versions based on https://nodejs.org/en/about/releases/
15 | node-version: ['18', '20', '21']
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: Use Node.js ${{ matrix.node-version }}
19 | uses: actions/setup-node@v4
20 | with:
21 | node-version: ${{ matrix.node-version }}
22 | - name: Install dependencies
23 | run: npm ci
24 | - name: Run linter
25 | run: npm run lint
26 | - name: Run tests
27 | run: npm test -- --timeout 10000
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dump.rdb
2 | example/store
3 | example/tree
4 | node_modules
5 | bin/test-suite-storage/rs/rs-test/.lock
6 | bin/test-suite-storage/rs/rs-test/.~meta
7 | bin/test-suite-storage/rs/rs-test/storage
8 | logs
9 | test-log
10 | dev-log
11 | dev-storage
12 | .vscode
13 | .idea
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | # Data stores
23 | myminio
24 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .git
2 | .gitignore
3 | .gitmodules
4 | .npmignore
5 | dump.rdb
6 | node_modules
7 | notes
8 | spec
9 | vendor
10 | yarn.lock
11 | benchmark
12 | .vscode
13 | bin/test-suite-storage
14 | example
15 | .eslintrc.js
16 | .travis.yml
17 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "8"
5 | - "9"
6 |
7 | services:
8 | - redis-server
9 |
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # Development for Armadietto
2 |
3 | ## Setup
4 |
5 | 1. Run `git clone https://github.com/remotestorage/armadietto.git` to pull the code.
6 | 2. Run `npm install` to install the dependencies.
7 | 3. Register for S3-compatible storage with your hosting provider, install [a self-hosted implementation](notes/S3-store-router.md), or use the public account on `play.min.io` (which is slow, and to which anyone in the world can read and write!).
8 |
9 | ## Development
10 |
11 | * Run `npm test` to run the automated tests for both monolithic and modular servers (except the tests for S3 store router).
12 | * If you don't have an S3-compatible server configured, run `npm test-s3-wo-configured-server`
13 | * Set the S3 environment variables and run Mocha with `./node_modules/mocha/bin/mocha.js -u bdd-lazy-var/getter spec/armadietto/storage_spec.js` to test the S3 store router using your configured S3 server. (If the S3 environment variables aren't set, the S3 router uses the shared public account on play.min.io.) If any tests fail on one S3 implementation but not others, add a note to [the S3 notes](notes/S3-store-router.md)
14 | * Run `npm run modular` to start a modular server on your local machine, and have it automatically restart when you update a source code file in `/lib`.
15 | * Run `npm run dev` to start a monolithic server on your local machine, and have it automatically restart when you update a source code file in `/lib`.
16 |
17 | Set the environment `DEBUG` to enable verbose logging of HTTP requests. For the modular server, these are the requests to the S3 server. For the monolithic server, these are the requests to Armadietto.
18 |
19 | Add automated tests for any new functionality. For bug fixes, start by writing an automated test that fails under the buggy code, but will pass when you've written a fix. Using TDD is not required, but will help you write better code.
20 |
21 | Before committing, run `npm lint:fix` and `npm test`. Fix any tests that fail.
22 |
--------------------------------------------------------------------------------
/bin/armadietto.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const Armadietto = require('../lib/armadietto');
3 | const path = require('path');
4 | const fs = require('fs');
5 |
6 | const remoteStorageServer = {
7 |
8 | // read and return configuration file
9 | readConf (confPath) {
10 | return JSON.parse(fs.readFileSync(confPath, 'utf8'));
11 | },
12 |
13 | // parse cli args
14 | parseArgs () {
15 | const ArgumentParser = require('argparse').ArgumentParser;
16 | const version = require(path.join(__dirname, '/../package.json')).version;
17 | const parser = new ArgumentParser({
18 | add_help: true,
19 | description: 'NodeJS remoteStorage server / ' + version
20 | });
21 |
22 | parser.add_argument('-c', '--conf', {
23 | help: 'Path to configuration'
24 | });
25 |
26 | parser.add_argument('-e', '--exampleConf', {
27 | help: 'Print configuration example',
28 | action: 'store_true'
29 | });
30 |
31 | return parser.parse_args();
32 | },
33 |
34 | init () {
35 | const args = this.parseArgs();
36 | let conf = {};
37 |
38 | if (args.exampleConf) {
39 | console.log(fs.readFileSync(path.join(__dirname, '/conf.monolithic.json'), 'utf8'));
40 | return -1;
41 | }
42 |
43 | if (!args.conf) {
44 | console.error('[ERR] Configuration file needed (help with -h)');
45 | return -1;
46 | }
47 |
48 | try {
49 | conf = this.readConf(args.conf);
50 | } catch (e) {
51 | console.error(e.toString());
52 | return -1;
53 | }
54 |
55 | process.umask(0o077);
56 | const store = new Armadietto.FileTree({ path: conf.storage_path, lock_timeout_ms: conf.lock_timeout_ms, lock_stale_after_ms: conf.lock_stale_after_ms });
57 | const server = new Armadietto({
58 | basePath: conf.basePath,
59 | store,
60 | logging: conf.logging,
61 | http: {
62 | host: conf.http.host,
63 | port: conf.http.port
64 | },
65 | https: conf.https
66 | ? {
67 | host: conf.https.host,
68 | port: conf.https.enable && conf.https.port,
69 | force: conf.https.force,
70 | cert: conf.https.cert,
71 | key: conf.https.key
72 | }
73 | : {},
74 | allow: {
75 | signup: conf.allow_signup || false
76 | },
77 | cacheViews: conf.cache_views || false
78 | });
79 |
80 | server.boot();
81 | }
82 | };
83 |
84 | if (require.main === module) {
85 | remoteStorageServer.init();
86 | }
87 |
--------------------------------------------------------------------------------
/bin/conf.modular.json:
--------------------------------------------------------------------------------
1 | {
2 | "host_identity": "example.com",
3 | "basePath": "",
4 | "allow_signup": true,
5 | "http": {
6 | "host": "0.0.0.0",
7 | "port": 8000
8 | },
9 | "https": {
10 | "host": "0.0.0.0",
11 | "enable": false,
12 | "force": false,
13 | "port": 4443,
14 | "cert": "/etc/letsencrypt/live/example.com/cert.pem",
15 | "key": "/etc/letsencrypt/live/example.com/privkey.pem"
16 | },
17 | "logging": {
18 | "log_dir": "logs",
19 | "stdout": ["info"],
20 | "log_files": ["notice"]
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/bin/conf.monolithic.json:
--------------------------------------------------------------------------------
1 | {
2 | "allow_signup": true,
3 | "storage_path": "/usr/share/armadietto",
4 | "cache_views": true,
5 | "http": {
6 | "host": "0.0.0.0",
7 | "port": 8000
8 | },
9 | "https": {
10 | "host": "0.0.0.0",
11 | "enable": false,
12 | "force": false,
13 | "port": 4443,
14 | "cert": "/etc/letsencrypt/live/example.com/cert.pem",
15 | "key": "/etc/letsencrypt/live/example.com/privkey.pem"
16 | },
17 | "logging": {
18 | "log_dir": "logs",
19 | "stdout": ["info"],
20 | "log_files": ["error"]
21 | },
22 | "basePath": ""
23 | }
24 |
--------------------------------------------------------------------------------
/bin/dev-conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "host_identity": "example.com",
3 | "basePath": "",
4 | "allow_signup": true,
5 | "storage_path": "./dev-storage",
6 | "lock_timeout_ms": 30000,
7 | "lock_stale_after_ms": 60000,
8 | "cache_views": true,
9 | "http": {
10 | "host": "0.0.0.0",
11 | "port": 8000
12 | },
13 | "https": {
14 | "enable": false,
15 | "port": 4443,
16 | "key": "../../example.com+5-key.pem",
17 | "cert": "../../example.com+5.pem"
18 | },
19 | "logging": {
20 | "log_dir": "./dev-log",
21 | "stdout": ["info"],
22 | "log_files": ["notice"]
23 | },
24 | "s3": {
25 | "user_name_suffix": null
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/bin/test-suite-storage/rs/rs-test-other/auth.json:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remotestorage/armadietto/c4e5bc0dde5dacc35518c878ce3d431f7268eaee/bin/test-suite-storage/rs/rs-test-other/auth.json
--------------------------------------------------------------------------------
/bin/test-suite-storage/rs/rs-test/auth.json:
--------------------------------------------------------------------------------
1 | {
2 | "sessions": {
3 | "abcdef": {
4 | "permissions": {
5 | "/": {
6 | "r": true,
7 | "w": true
8 | }
9 | }
10 | },
11 | "123456789": {
12 | "permissions": {
13 | "/api-test/": {
14 | "r": true
15 | }
16 | }
17 | },
18 | "123456abcdef": {
19 | "permissions": {
20 | "/api-test/": {
21 | "r": true,
22 | "w": true
23 | }
24 | }
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/bin/test-suite.json:
--------------------------------------------------------------------------------
1 | {
2 | "allow_signup": true,
3 | "storage_path": "./bin/test-suite-storage",
4 | "cache_views": true,
5 | "http": {
6 | "host": "127.0.0.1",
7 | "port": 8000
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/contrib/openwrt/README.md:
--------------------------------------------------------------------------------
1 | # Configuring Armadietto as a Daemon for OpenWrt
2 |
3 | The Armadietto is a Node.JS app and a regular OpenWrt router is too weak to run it.
4 | But some routers like Turris Omnia are powerful enough.
5 |
6 | ## Install
7 | Your RS server needs to have a public IP with domain and a TLS certificate that is needed for HTTPS.
8 |
9 | If you don't have a domain you can use a free https://DuckDNS.org.
10 | See [DDNS client configuration](https://openwrt.org/docs/guide-user/base-system/ddns).
11 |
12 | The TLS certificate may be issued with acme.sh.
13 | See [TLS certificates for a server](https://openwrt.org/docs/guide-user/services/tls/certs) for details how to issue a new cert.
14 | We assume that you already issued a cert
15 |
16 | To store data you need to mount a disk.
17 | See [Quick Start for Adding a USB drive](https://openwrt.org/docs/guide-user/storage/usb-drives-quickstart).
18 |
19 | In the example it's mounted to /mnt/disk/
20 | Next we need to create a folder to store the user data. Login to OpenWrt with `ssh root@192.168.1.1` and execute:
21 |
22 | mkdir /mnt/disk/armadietto/
23 |
24 | Then install Node.JS and NPM:
25 |
26 | opkg update
27 | opkg install node node-npm
28 |
29 | Then install the Armadietto with NPM:
30 |
31 | npm -g i armadietto
32 |
33 | Now create a sample config and store to `/etc/armadietto/conf.json`:
34 |
35 | armadietto -e > /etc/armadietto/conf.json
36 |
37 | Now edit the generated file with `vi /etc/armadietto/conf.json` and change the following:
38 | * `storage_path` set to `/mnt/disk/armadietto/`
39 | * `http.port` set to `0` to disable the raw unecrypted HTTP.
40 | * `https.enable` set to `true`
41 | * `https.force` set to `true`
42 | * `https.cert` set to `/etc/acme/domainname_ecc/fullchain.cer` where the `domainname` is your domain
43 | * `https.key` set to `/etc/acme/domainname_ecc/domainname.key`
44 | * `logging.stdout` set to `warn`
45 |
46 | So it should look like:
47 | ```json
48 | {
49 | "allow_signup": true,
50 | "storage_path": "/srv/armadietto",
51 | "cache_views": true,
52 | "http": {
53 | "host": "0.0.0.0",
54 | "port": 0
55 | },
56 | "https": {
57 | "enable": true,
58 | "force": true,
59 | "host": "0.0.0.0",
60 | "port": 4443,
61 | "cert": "/etc/acme/yurt.jkl.mn_ecc/fullchain.cer",
62 | "key": "/etc/acme/yurt.jkl.mn_ecc/yurt.jkl.mn.key"
63 | },
64 | "logging": {
65 | "log_dir": "logs",
66 | "stdout": ["warn"],
67 | "log_files": ["error"]
68 | },
69 | "basePath": ""
70 | }
71 | ```
72 |
73 | Optionally you can `https.port` set to default HTTPS `443` if you don't have any other sites on the port.
74 | If you do have then you need to configure a reverse proxy. If you not sure then leave it 4443.
75 |
76 | You'll need to open the port on a firewall to make it accessible from internet.
77 | You can do that in the firewall GUI or via command line:
78 |
79 | uci add firewall rule
80 | uci set firewall.wan_https_turris_rule=rule
81 | uci set firewall.wan_https_turris_rule.name='Allow-WAN-RS-HTTPS'
82 | uci set firewall.wan_https_turris_rule.target='ACCEPT'
83 | uci set firewall.wan_https_turris_rule.dest_port='4443'
84 | uci set firewall.wan_https_turris_rule.proto='tcp'
85 | uci set firewall.wan_https_turris_rule.src='wan'
86 | uci commit firewall
87 | service firewall restart
88 |
89 | Now we need to setup a service. Copy the file `armadietto.sh` into `/etc/init.d/armadietto`.
90 | You can do this with SCP:
91 |
92 | scp contrib/openwrt/armadietto.sh root@192.168.1.1:/etc/init.d/armadietto
93 |
94 | Then you can enable and start the service:
95 |
96 | service armadietto enable
97 | service armadietto start
98 |
99 | After than open in a browser your https://domainname:4443/ and signup for a new account.
100 | Then try to use it with some like e.g. https://litewrite.net/
101 |
--------------------------------------------------------------------------------
/contrib/openwrt/armadietto.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh /etc/rc.common
2 | USE_PROCD=1
3 | START=95
4 | STOP=01
5 | start_service() {
6 | procd_open_instance
7 | procd_set_param command /usr/bin/armadietto -c /etc/armadietto/conf.json
8 | procd_set_param stderr 1
9 | procd_set_param stdout 1
10 | procd_close_instance
11 | }
12 |
--------------------------------------------------------------------------------
/contrib/systemd/README.md:
--------------------------------------------------------------------------------
1 | # Configuring Armadietto as a Daemon with Systemd
2 |
3 | As root, or using `sudo`:
4 |
5 | 1. Copy `armadietto.service` to `/etc/systemd/system/`
6 | 2. If Armadietto should run as a user **other** than `armadietto`, edit `User` and `Group` in `armadietto.service`.
7 | 3. If your config file is **not** at `/etc/armadietto/conf.json`, edit `ExecStart` in `armadietto.service`.
8 | 4. Run `systemctl daemon-reload`
9 | 5. Run `systemctl enable armadietto`
10 | 6. Run `systemctl start armadietto`
11 |
--------------------------------------------------------------------------------
/contrib/systemd/armadietto.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Armadietto RemoteStorage server
3 | Requires=network.target
4 | After=network.target
5 | StartLimitIntervalSec=0
6 | Documentation=https://github.com/remotestorage/armadietto/
7 |
8 | [Service]
9 | Type=simple
10 | Restart=on-failure
11 | RestartSec=1
12 | User=armadietto
13 | Group=armadietto
14 | Environment=NODE_ENV=production
15 | ExecStartPre=
16 | ExecStart=/usr/bin/armadietto -c /etc/armadietto/conf.json
17 | ExecStartPost=
18 | ExecStop=
19 | ExecReload=
20 |
21 | [Install]
22 | WantedBy=multi-user.target
23 |
--------------------------------------------------------------------------------
/docker/Dockerfile-monolithic:
--------------------------------------------------------------------------------
1 | FROM alpine:latest AS build
2 |
3 | ARG PKG_MANAGER="npm"
4 | ARG INSTALL_COMMAND="npm ci --production"
5 |
6 | RUN mkdir /opt/armadietto
7 | WORKDIR /opt/armadietto
8 |
9 | RUN apk add nodejs $PKG_MANAGER
10 |
11 | COPY package.json ./
12 | COPY package-lock.json ./
13 |
14 | RUN $INSTALL_COMMAND
15 |
16 | FROM alpine:latest
17 |
18 | LABEL description="Armadietto Node.js web service (a remoteStorage server)"
19 |
20 | ARG CONFIG_PATH_STORAGE="/usr/share/armadietto"
21 | ARG CONFIG_PATH_LOGS="/opt/armadietto/logs"
22 | ARG USER="armadietto"
23 | ARG PORT="8000"
24 |
25 | ENV NODE_ENV=production
26 | ENV USER=$USER
27 | ENV PORT=$PORT
28 |
29 | RUN mkdir /opt/armadietto
30 | WORKDIR /opt/armadietto
31 |
32 |
33 | RUN apk add nodejs
34 |
35 | RUN adduser -u 6582 -HD $USER
36 |
37 | RUN mkdir -m 0700 $CONFIG_PATH_STORAGE
38 | RUN mkdir -m 0700 $CONFIG_PATH_LOGS
39 | RUN chown $USER $CONFIG_PATH_STORAGE
40 | RUN chown $USER $CONFIG_PATH_LOGS
41 |
42 | COPY --from=build /opt/armadietto/node_modules/ node_modules/
43 | COPY package.json ./
44 | COPY README.md ./
45 | COPY lib/ lib/
46 | COPY bin/ bin/
47 |
48 | # Ensure bin file (esp the bang line) has unix eol
49 | RUN dos2unix bin/armadietto.js
50 |
51 | RUN ln -s /opt/armadietto/bin/armadietto.js /usr/local/bin/armadietto
52 |
53 | COPY bin/conf.monolithic.json /etc/armadietto/conf.json
54 |
55 | VOLUME $CONFIG_PATH_STORAGE
56 | VOLUME $CONFIG_PATH_LOGS
57 | EXPOSE $PORT
58 | USER $USER
59 |
60 | CMD ["armadietto", "-c", "/etc/armadietto/conf.json"]
61 |
62 | HEALTHCHECK --start-period=10s CMD wget -q -O /dev/null http://127.0.0.1:$PORT/
63 |
--------------------------------------------------------------------------------
/docker/README.md:
--------------------------------------------------------------------------------
1 | # Armadietto [](http://travis-ci.org/remotestorage/armadietto) [](https://github.com/Flet/semistandard)
2 |
3 | > ### WARNING
4 | > Please do not consider `armadietto` production ready, this project is still
5 | > considered experimental. As with any alpha-stage storage technology, you
6 | > MUST expect that it will eat your data and take precautions against this. You
7 | > SHOULD expect that its APIs and storage schemas will change before it is
8 | > labelled stable.
9 |
10 | ## What is this?
11 |
12 | Armadietto is a [RemoteStorage](https://remotestorage.io) server written for Node.js.
13 |
14 | This is a complete rewrite of [reStore](https://github.com/jcoglan/restore).
15 |
16 | It is also available as the
17 | [armadietto](https://www.npmjs.com/package/armadietto) NPM package.
18 |
19 | ## Usage of containerized Armadietto
20 |
21 | You may need to preface the `docker` commands below with `sudo`, depending on how Docker is installed on your host machine.
22 |
23 | ### Quick test
24 |
25 | For a quick test server, run
26 |
27 | ```shell
28 | docker run -d -p 8000:8000 remotestorage/armadietto-monolithic:latest
29 | ```
30 | It will serve over HTTP only on port 8000.
31 | User data will be discarded when the container is deleted.
32 |
33 | ### Configuration
34 |
35 | The default configuration file for armadietto can be found within the docker
36 | container in `/etc/armadietto/conf.json`. For the monolithic server, it looks like:
37 |
38 | ```json
39 | {
40 | "allow_signup": true,
41 | "storage_path": "/usr/share/armadietto",
42 | "cache_views": true,
43 | "http": {
44 | "host": "0.0.0.0",
45 | "port": 8000
46 | },
47 | "https": {
48 | "host": "0.0.0.0",
49 | "enable": false,
50 | "force": false,
51 | "port": 4443,
52 | "cert": "/etc/letsencrypt/live/example.com/cert.pem",
53 | "key": "/etc/letsencrypt/live/example.com/privkey.pem"
54 | },
55 | "logging": {
56 | "log_dir": "logs",
57 | "stdout": ["info"],
58 | "log_files": ["error"]
59 | },
60 | "basePath": ""
61 | }
62 | ```
63 |
64 | A custom configuration file can be used by mounting it in the container
65 |
66 | ```shell
67 | docker run -d -v /my/custom/armadietto.conf.json:/etc/armadietto/conf.json:ro -p 8000:8000 remotestorage/armadietto-monolithic:latest
68 | ```
69 |
70 | A suitable data directory should also be mounted in the container to
71 | ensure data is persisted.
72 |
73 | ```shell
74 | docker run -d -v /data/armadietto:/usr/share/armadietto -p 8000:8000 remotestorage/armadietto-monolithic:latest
75 | ```
76 |
77 | To persist logs, mount their directory:
78 |
79 | ```shell
80 | docker run -d -v /data/armadietto-logs:/opt/armadietto/logs -p 8000:8000 remotestorage/armadietto-monolithic:latest
81 | ```
82 |
83 | *Note:* The data and log folders and their contents must be writable and
84 | readable by the container user, which is by default the `armadietto` user
85 | (UID 6582).
86 |
87 | ### Behind a Proxy
88 |
89 | To use armadietto behind a proxy, ensure the `X-Forwarded-Host` and
90 | `X-Forwareded-Proto` headers are passed to armadietto to ensure it uses the
91 | correct address. For more information, see the
92 | [notes](../notes)
93 | folder in the armadietto git repository.
94 |
95 | ## Development
96 |
97 | The armadietto-monolithic docker image is built using the
98 | [armadietto](https://github.com/remotestorage/armadietto) git repository
99 | and the [`docker/Dockerfile-monolithic`](./Dockerfile-monolithic)
100 | [Dockerfile](https://docs.docker.com/engine/reference/builder/). To build
101 | the image yourself, clone the git repository and use the
102 | [`docker build`](https://docs.docker.com/engine/reference/commandline/build/) command:
103 |
104 | ```shell
105 | git clone https://github.com/remotestorage/armadietto
106 | cd armadietto
107 | npm run build-monolithic
108 | ```
109 |
110 | Further information about the development of armadietto can be found in the
111 | [DEVELOPMENT.md](../DEVELOPMENT.md)
112 | file in git repository.
113 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # armadietto-example
2 |
3 | This repository contains an example of using both the [remotestorage HTTP APIs](https://tools.ietf.org/id/draft-dejong-remotestorage-12.txt) directly as well as abstracted with the [remoteStorage.js](https://github.com/remotestorage/remotestorage.js) library; both, against the [armadietto remoteStorage server](https://github.com/remotestorage/armadietto).
4 |
5 | ## Getting Started
6 |
7 | In a terminal:
8 |
9 | ```
10 | $ cd $ARMADIETTO_REPO/example
11 | $ npm install
12 | ```
13 |
14 | Where $ARMADIETTO_REPO is the root where you synced the [armadietto repo](https://github.com/remotestorage/armadietto).
15 |
16 | To run the example server:
17 |
18 | ```
19 | $ npm run start
20 | ```
21 |
22 | The armadietto server starts on port 8000.
23 |
24 | Leave the terminal running the server as is.
25 |
26 | In a *new* terminal host the example application:
27 |
28 | ```
29 | $ npm run serve
30 | ```
31 |
32 | The example application is now available on port 8080.
33 |
34 | Now open the example app. You may need to dismiss browser
35 | warnings about the self-signed certificate for `localhost` before the clients
36 | will connect properly.
37 |
38 | open http://localhost:8080
39 |
40 | ## About the App Layout
41 |
42 | Please read the text in the lower-right pane of the application.
43 |
44 | The same text is available here: [ignore/welcome.txt](ignore/welcome.txt).
45 |
46 | ## Create a User
47 |
48 | ### Using Widget
49 |
50 | The example app shows the [remotestorage-widget](https://github.com/remotestorage/remotestorage-widget) integration.
51 |
52 | The easiest way to create a user is to use the widget. To create user 'tester' click the widget and enter:
53 |
54 | tester@localhost:8000
55 |
56 | Click `Connect`. You'll be taken to an on-boarding site served by your localhost *armadietto*.
57 |
58 | Click `Sign Up` in upper right corner.
59 |
60 | There is no automated redirection back to the example application at this point, reload it:
61 |
62 | open http://localhost:8080
63 |
64 | ### Using "GOTO authDialogUrl (and redirect back)" Use Case
65 |
66 | First we need to fill out some configuration fields in the app.
67 |
68 | For *server* put in `http://localhost:8000`.
69 |
70 | For *user* type in the user's name, e.g. `tester`.
71 |
72 | Click *run getWebFinger()* in *GET /.well-known/webfinger* use-case. The *remotestorageHref* field should be filled in.
73 |
74 | All fields should now be filled in the *GOTO authDialogUrl (and redirect back)* use-case.
75 |
76 | Click *run gotoAuth()* to go to the *armadietto* server's onboarding/login page.
77 |
78 | Click `Sign Up` in upper right corner.
79 |
80 | There is no automated redirection back to the example application at this point, reload it:
81 |
82 | open http://localhost:8080
83 |
84 | ## Login
85 |
86 | Once a user is setup (above) a login involves the same steps as creating a user (above) except the password is entered when asked for, instead of clicking on `Sign Up`.
87 |
88 | You're logged in when all the fields including a *token* are filled in at the top of the app:
89 |
90 | * *server*
91 | * *user*
92 | * *token*
93 | * *scope*
94 |
95 | Note that going through the login flow will redirect you back to the app with the *token* filled in.
96 |
97 | ## Use [remotestorage HTTP APIs](https://tools.ietf.org/id/draft-dejong-remotestorage-12.txt) Directly
98 |
99 | Ensure you're logged in: either through *widget* or manually.
100 |
101 | To work with data using [remotestorage HTTP APIs](https://tools.ietf.org/id/draft-dejong-remotestorage-12.txt) go to the *DATA over HTTP * /storage* use-case.
102 |
103 | Everything will be done with this use-case.
104 |
105 | Click on the use-case title to expand instructions.
106 |
107 | Play around with the values and observe the log (top-right pane). Click on interesting log traces to go to the source code (bottom-right pane).
108 |
109 | ## Use [remoteStorage.js](https://github.com/remotestorage/remotestorage.js)
110 |
111 | Ensure you're logged in: either through *widget* or manually.
112 |
113 | To work with data using [remoteStorage.js](https://github.com/remotestorage/remotestorage.js) go to the *DATA over remoteStorage.js* use-case.
114 |
115 | Everything will be done with this use-case.
116 |
117 | Click on the use-case title to expand instructions.
118 |
119 | Play around with the values and observe the log (top-right pane). Click on interesting log traces to go to the source code (bottom-right pane).
120 |
--------------------------------------------------------------------------------
/example/ignore/code.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
65 |
66 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/example/ignore/logging.js:
--------------------------------------------------------------------------------
1 | // logging.js
2 | //
3 | // Helper code to allow click through from login.html and service.html to show their respective source code.
4 |
5 | var isScreenSetup = false;
6 | var logsPostScreenSetup = [];
7 |
8 | function log(message, params, fileName) {
9 | fileName = fileName || 'index.html';
10 | var prefix = `[${(new Date()).toLocaleTimeString()}] (${fileName}) :: `;
11 | var msg = params ? prefix + message + " : {\n" + fixupParamsOutput(prefix.length, JSON.stringify(params, null, 2)) + "\n" : prefix + message + "\n";
12 | msg = "" + msg + "
";
13 |
14 | if (isScreenSetup) {
15 | $('#logviewcontents').append(msg);
16 | $("#logview").scrollTop($("#logviewcontents").height());
17 | } else {
18 | logsPostScreenSetup.push(msg);
19 | }
20 | }
21 |
22 | function fixupParamsOutput(prefixLength, input) {
23 | var lines = input.split('\n');
24 | lines = lines.slice(1);
25 | var prefix = ' '.repeat(prefixLength);
26 | var output = ""
27 | for (line of lines) {
28 | output += `${prefix}${line}\n`;
29 | }
30 | return output;
31 | }
32 |
33 | function showPostScreenSetupLogs() {
34 | for (var log of logsPostScreenSetup) {
35 | $('#logviewcontents').append(log);
36 | $("#logview").scrollTop($("#logviewcontents").height());
37 | }
38 | isScreenSetup = true;
39 | }
40 |
41 | function setCodeToMessage(file, message) {
42 | $('#codeframe').attr('src', null);
43 | setTimeout(function () {
44 | $('#codeframe').attr('src', 'ignore/code.html#../' + file + '/' + encodeURI(message));
45 | }, 0);
46 | }
--------------------------------------------------------------------------------
/example/ignore/styles.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | margin: 0px;
3 | }
4 |
5 | #getwider {
6 | display: none;
7 | }
8 |
9 | #window {
10 | margin: 0px;
11 | height: 100vh;
12 | overflow: hidden;
13 | display: block;
14 | }
15 |
16 | #demoview {
17 | float: left;
18 | height: 100%;
19 | overflow: auto;
20 | width: calc(50% - 20px);
21 | padding: 10px;
22 | }
23 |
24 | #panes {
25 | float: right;
26 | height: 100%;
27 | width: 50%;
28 | }
29 |
30 | #logview {
31 | height: 30%;
32 | width: 100%;
33 | background: #404040;
34 | color: #66ff66;
35 | overflow: auto;
36 | font-family: arial, "lucida console", sans-serif;
37 | padding-top: 5px;
38 | padding-bottom: 5px;
39 | }
40 |
41 | #logview pre {
42 | font-size: smaller;
43 | margin: 0px;
44 | padding: 0px;
45 | line-height: 1em;
46 | }
47 |
48 | #codeview {
49 | height: 70%;
50 | overflow: hidden;
51 | padding-top: 1px;
52 | }
53 |
54 | #codeframe {
55 | width: 100%;
56 | height: 100%;
57 | border: 0px;
58 | }
59 |
60 | pre.clickable {
61 | cursor: pointer;
62 | }
63 |
64 | @media only screen and (max-width: 1100px) {
65 | #getwider {
66 | display: block;
67 | font-family: monospace;
68 | }
69 |
70 | #window {
71 | display: none;
72 | }
73 | }
74 |
75 | #remotestorage-widget-anchor {
76 | height: 80px;
77 | width: 100%;
78 | }
79 |
80 | .uiterminal-full {
81 | margin-bottom: 10px;
82 | }
83 |
84 | .usecase {
85 | color: #2196F3;
86 | font-weight: bold;
87 | font-size: larger;
88 | }
89 |
90 | .usecase:focus {
91 | outline: none;
92 | }
93 |
94 | details > p {
95 | color: grey;
96 | background-image: linear-gradient(to right, yellow, white 5%);
97 | }
--------------------------------------------------------------------------------
/example/ignore/welcome.txt:
--------------------------------------------------------------------------------
1 | ///
2 | ///////////
3 | ////// ////// remoteStorage Example App
4 | ///* ,/////*
5 | ,// ///,
6 | ,/////* *///// https://remotestorage.io
7 | ,//,////// //////
8 | ,//* /////////// *//, https://github.com/remotestorage
9 | ////// ///// //////,
10 | //////. */////,,//,
11 | ,/// /////////// ///, (The MIT License)
12 | //////, *///* ,//////
13 | ////// ////// Copyright (c) remoteStorage contributors
14 | ///////////
15 |
16 |
17 | ,-------.
18 | | ABOUT |
19 | `-------'
20 |
21 | This example is part of the https://github.com/remotestorage/armadietto
22 | remoteStorage server repository.
23 |
24 | For running instructions read the example README.md at:
25 | https://github.com/remotestorage/armadietto/blob/master/example/README.md
26 |
27 |
28 | ,--------------.
29 | | INSTRUCTIONS |
30 | `--------------'
31 |
32 | The pane on the LEFT is the actual rendered
33 | example application for you to interact with.
34 |
35 | The TOP-RIGHT pane shows "logs" logged from this
36 | application.
37 |
38 | When this example Web application's JavaScript
39 | calls a "log" function, the logged message
40 | appears in the TOP-RIGHT pane.
41 |
42 | Clicking any log message will scroll the
43 | BOTTOM-RIGHT pane to the corresponding
44 | JavaScript source code where this log message
45 | occurs.
46 |
47 | The idea being: as you interact with the app
48 | you will see logs for various data setting
49 | calls, state transitions, events, and
50 | callbacks. Clicking on the log will show
51 | you the code.
52 |
53 | This way you will see how the example app uses
54 | remotestorage.js and how it leverages the
55 | remoteStorage APIs on.
56 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "armadietto-example",
3 | "version": "1.0.0",
4 | "description": "This repository contains an example using remoteStorage.js against the armadietto remoteStorage server.",
5 | "main": "server.js",
6 | "scripts": {
7 | "serve": "http-server ./ -p 8080 "
8 | },
9 | "author": "remoteStorage contributors",
10 | "license": "MIT",
11 | "dependencies": {
12 | "http-server": "14.1.0",
13 | "remotestoragejs": "^2.0.0-beta.1",
14 | "remotestorage-widget": "^1.5.4"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/example/server.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const Armadietto = require('../lib/armadietto');
3 | let store;
4 |
5 | const type = process.argv[2];
6 |
7 | if (type === 'redis') store = new Armadietto.Redis({ database: 3 });
8 | else store = new Armadietto.FileTree({ path: path.join(__dirname, 'tree') });
9 |
10 | const server = new Armadietto({
11 | store,
12 | http: {
13 | port: 8000
14 | },
15 | allow: {
16 | signup: true
17 | },
18 | cacheViews: false
19 | });
20 |
21 | console.log('LISTENING ON PORT 8000');
22 | server.boot();
23 |
--------------------------------------------------------------------------------
/example/ssl/npm-debug.log:
--------------------------------------------------------------------------------
1 | 0 info it worked if it ends with ok
2 | 1 verbose cli [ '/home/james/.nvm/v0.10.8/bin/node',
3 | 1 verbose cli '/home/james/.nvm/v0.10.8/bin/npm',
4 | 1 verbose cli 'test' ]
5 | 2 info using npm@1.2.23
6 | 3 info using node@v0.10.8
7 | 4 verbose read json /home/james/projects/restore/example/ssl/package.json
8 | 5 error Error: ENOENT, open '/home/james/projects/restore/example/ssl/package.json'
9 | 6 error If you need help, you may report this log at:
10 | 6 error
11 | 6 error or email it to:
12 | 6 error
13 | 7 error System Linux 3.2.0-43-generic
14 | 8 error command "/home/james/.nvm/v0.10.8/bin/node" "/home/james/.nvm/v0.10.8/bin/npm" "test"
15 | 9 error cwd /home/james/projects/restore/example/ssl
16 | 10 error node -v v0.10.8
17 | 11 error npm -v 1.2.23
18 | 12 error path /home/james/projects/restore/example/ssl/package.json
19 | 13 error code ENOENT
20 | 14 error errno 34
21 | 15 verbose exit [ 34, true ]
22 |
--------------------------------------------------------------------------------
/example/ssl/server.crt:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIB/zCCAWgCCQD6K4PG6y0CyTANBgkqhkiG9w0BAQUFADBEMQswCQYDVQQGEwJV
3 | SzEPMA0GA1UECAwGTG9uZG9uMRAwDgYDVQQKDAdqY29nbGFuMRIwEAYDVQQDDAls
4 | b2NhbC5kZXYwHhcNMTMwNTMxMTQ0MjEzWhcNMTQwNTMxMTQ0MjEzWjBEMQswCQYD
5 | VQQGEwJVSzEPMA0GA1UECAwGTG9uZG9uMRAwDgYDVQQKDAdqY29nbGFuMRIwEAYD
6 | VQQDDAlsb2NhbC5kZXYwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOA9upan
7 | iis5E7NTB4vfhwcsz+RYkHg0aXd7aclsueZFjCIR1G7Z9yWK8IDLnQAKefb7Rzzs
8 | NOdMdm9edclm+gUhmquF5VpaWgM+b7kIvYwAWqHj4FEBR9NsAWGXMkTzehGnVXR8
9 | zQNst4qhFUy/21uwCgNvm/hWyB3tVlBzgCslAgMBAAEwDQYJKoZIhvcNAQEFBQAD
10 | gYEAatv3gm4Y25SobMrUq8I6SW3Wf09lFqnsIjwj0ycd7L3k6mL8UEKLuiu/1qmZ
11 | z2EPKFBsiGZkY1S7r9281lO56Iwt8M4oDUqTG45O6Zxv7VT0kpRz+CojxsBQ+yHr
12 | m7w1Qb+Lg/wa7eFPwjw0rGJV3zUBuEnoZ1wAF61wgO62BUM=
13 | -----END CERTIFICATE-----
14 |
--------------------------------------------------------------------------------
/example/ssl/server.key:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | MIICXgIBAAKBgQDgPbqWp4orOROzUweL34cHLM/kWJB4NGl3e2nJbLnmRYwiEdRu
3 | 2fclivCAy50ACnn2+0c87DTnTHZvXnXJZvoFIZqrheVaWloDPm+5CL2MAFqh4+BR
4 | AUfTbAFhlzJE83oRp1V0fM0DbLeKoRVMv9tbsAoDb5v4Vsgd7VZQc4ArJQIDAQAB
5 | AoGBAIim1YhtvROuCsVjOdwRcfX7Zw1es1uthQAdI1Bug7NCeq1gdDbBeY1VaE70
6 | xk56E+1hH/6Oa4bw39PqmpkLuhw2BzI7NxnilxMmSRiWh7cgDE5Bii+UdyiA/yar
7 | xGgYkSnfveCodCD3kc0lJicgxyq9S8LXl2s6qvn1A/NZTM+BAkEA+8NMCpCUtuw5
8 | lkZ5bhM7H2YjuKkOmgibiIWSuJmoJcoYD1zlqKkHx3KmJDQ9Ee/6doLTe99ip+uO
9 | n/8mGy/ftQJBAOQD2zQljKSqjjpiDDYxq5tBzrrYy59+7UalxQfhzVIIG9ev8ZCw
10 | r04gpWu3xERVRmcPoNkYZTXnnouEd++F47ECQQDbZ9WrF1kjGTcOiZhln4jU0lSr
11 | J1m8T2gMUCwPiImLdVYGfXT/yV8oJ/g2cPgP282w3k6kE4eMw7JmKRvycYRJAkAh
12 | EdIb+Qox466nFwtQXNnXta6m+MRniIAfS/GMmBowOI7ZNGJjqsxyF1gGjGuBwaBp
13 | WCHq+pfLPqGG+JpwecmxAkEAj/5vkWs0Wz27vat3uvRcgQr+UUH8aOdWSjUGW1BP
14 | GD//R8b6YgcPocCLUHCOWQDm8ehGPqrxhZLVDVg8Fv/rMQ==
15 | -----END RSA PRIVATE KEY-----
16 |
--------------------------------------------------------------------------------
/lib/assets/armadietto-utilities.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | // function to set a given theme/color-scheme
4 | function setTheme (themeName) {
5 | localStorage.setItem('theme', themeName);
6 | document.documentElement.setAttribute('data-theme', themeName);
7 | document.getElementById('switch').setAttribute('class', themeName);
8 | }
9 |
10 | // function to toggle between light and dark theme
11 | function toggleTheme () {
12 | localStorage.getItem('theme') === 'dark' ? setTheme('light') : setTheme('dark');
13 | }
14 |
15 | // Immediately invoked function to set the theme on initial load
16 | (function () {
17 | localStorage.getItem('theme') === 'dark' ? setTheme('dark') : setTheme('light');
18 | })();
19 |
20 | document.getElementById('switch')?.addEventListener('click', toggleTheme);
21 |
22 | const togglePassword = document.querySelector('#togglePassword');
23 | const password = document.querySelector('#password');
24 |
25 | if (togglePassword) {
26 | togglePassword.addEventListener('click', function () {
27 | // toggle the type attribute
28 | const type = password.getAttribute('type') === 'password' ? 'text' : 'password';
29 | password.setAttribute('type', type);
30 |
31 | // toggle the icon
32 | this.classList.toggle('icon-slash');
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/lib/assets/armadietto.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/lib/assets/contact-url.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-env browser es2022 */
2 |
3 | const select = document.querySelector('select');
4 | select.addEventListener('input', protocolChanged);
5 | select.dispatchEvent(new InputEvent('input'));
6 |
7 | function protocolChanged(evt) {
8 | let type, pattern, label, placeholder;
9 | switch (evt.target.value) {
10 | case 'sgnl:':
11 | type = 'tel';
12 | pattern = '\\+?[\\d \\)\\(\\*\\-]{4,24}';
13 | label = 'Phone number'
14 | placeholder = '+1 800 555 1212';
15 | break;
16 | case 'threema:': // threema://compose?text=Test%20Text
17 | type = 'text';
18 | pattern = '[a-zA-Z0-9]{8}';
19 | label = 'Threema ID';
20 | placeholder = 'ABCDEFGH';
21 | break;
22 | case 'facetime:':
23 | type = 'text';
24 | pattern = '.*[\\p{L}\\p{N}]+@[a-zA-Z0-9][a-zA-Z0-9.]{2,}[a-zA-Z0-9]|\\+?[\\d \\)\\(\\*\\-]{4,24}';
25 | label = 'Apple ID or phone number'
26 | placeholder = 'username@domain.tld or +1 800 555 1212'
27 | break;
28 | case 'xmpp:': // xmpp:username@domain.tld
29 | type = 'email';
30 | pattern = null;
31 | // pattern = '[\\p{L}\\p{N}]+@[a-zA-Z0-9][a-zA-Z0-9.]{2,}[a-zA-Z0-9]';
32 | label = "Jabber ID";
33 | placeholder = 'username@domain.tld'
34 | break;
35 | case 'skype:': // skype:?[add|call|chat|sendfile|userinfo][&topic=foo]
36 | type = 'text';
37 | pattern = '(live:)?[\\w\\. @\\)\\(\\-]{3,24}';
38 | label = 'Skype ID';
39 | placeholder = 'email, phone number or username';
40 | break;
41 | case 'mailto:':
42 | type = 'email';
43 | pattern = null;
44 | // pattern = '.*[\\p{L}\\p{N}]+@[a-zA-Z0-9][a-zA-Z0-9.]{2,}[a-zA-Z0-9]';
45 | label = 'E-mail address';
46 | placeholder = 'username@domain.tld'
47 | break;
48 | case 'sms:':
49 | case 'mms:':
50 | type = 'tel';
51 | pattern = '\\+?[\\d \\)\\(\\*\\-]{4,24}';
52 | label = 'Phone number'
53 | placeholder = '+1 800 555 1212';
54 | break;
55 | case 'whatsapp:': // whatsapp://send/?phone=447700900123
56 | type = 'tel';
57 | pattern = '\\+?[\\d \\)\\(\\*\\-]{4,24}';
58 | label = 'Phone number'
59 | placeholder = '+1 800 555 1212';
60 | // TODO: support https://wa.me/15551234567
61 | break;
62 | case 'tg:': // tg://msg?to=+1555999
63 | type = 'tel';
64 | pattern = '\\+?[\\d \\)\\(\\*\\-]{4,24}';
65 | // pattern = '(@|https?://t.me/|https?://telegram.me/)?\w{3,24}';
66 | label = 'Phone number'
67 | placeholder = '+1 800 555 1212';
68 | break;
69 | default:
70 | type = 'text';
71 | pattern = '[\\p{L}\\p{N}]{2,}';
72 | label = 'Address'
73 | placeholder = 'At least two letters or digits';
74 | }
75 | const labelElmt = document.querySelector('[for=address]');
76 | labelElmt.innerText = label;
77 | const addressInpt = document.getElementById('address');
78 | addressInpt.setAttribute('type', type);
79 | if (pattern) {
80 | addressInpt.setAttribute('pattern', pattern);
81 | } else {
82 | addressInpt.removeAttribute('pattern');
83 | }
84 | addressInpt.setAttribute('placeholder', placeholder);
85 | }
86 |
--------------------------------------------------------------------------------
/lib/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/lib/assets/login.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-env browser es2022 */
2 |
3 | import {startAuthentication} from './simplewebauthn-browser.js';
4 |
5 | // login().catch(err => {
6 | // err = preprocessError(err);
7 | // console.log(`logging-in on load:`, err, err.code || '', err.cause || '', err.cause?.code || '');
8 | //
9 | // document.getElementById('login').hidden = false;
10 | document.getElementById('login')?.addEventListener('click', loginCatchingErrors);
11 | // });
12 |
13 | async function loginCatchingErrors() {
14 | try {
15 | await login();
16 | } catch (err) {
17 | err = preprocessError(err);
18 | console.error(err, err.code || '', err.cause || '', err.cause?.code || '');
19 |
20 | if (err.name === 'AbortError') {
21 | displayMessage('Validating passkey aborted')
22 | } else {
23 | displayMessage(err.message || err.toString(), true);
24 | }
25 | }
26 | }
27 |
28 | function preprocessError(err) {
29 | document.getElementById('progress').hidden = true;
30 | if (err.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') {
31 | return err.cause;
32 | } else {
33 | return err;
34 | }
35 | }
36 |
37 | async function login() {
38 | displayMessage('Logging in');
39 |
40 | // @simplewebauthn/server -> generateAuthenticationOptions()
41 | const options = JSON.parse(document.getElementById('options').value);
42 | // console.log(`credentials options:`, options)
43 |
44 | // Passes the options to the authenticator and waits for a response
45 | const credential = await startAuthentication(options);
46 | // console.log(`credential:`, credential)
47 |
48 | // POST the response to the endpoint that calls
49 | // @simplewebauthn/server -> verifyAuthenticationResponse()
50 | const verificationResp = await fetch('./verify-authentication', {
51 | method: 'POST',
52 | headers: { 'Content-Type': 'application/json',},
53 | body: JSON.stringify(credential),
54 | });
55 |
56 | // Wait for the results of verification
57 | const verificationJSON = await verificationResp.json();
58 |
59 | // Show UI appropriate for the `verified` status
60 | if (verificationJSON?.verified) {
61 | displayMessage(`Logged in as ${verificationJSON.username}`);
62 | document.getElementById('login').hidden = true;
63 | if (document.location.pathname.startsWith('/admin')) {
64 | document.location = '/admin/users';
65 | } else {
66 | document.location = '/account/';
67 | }
68 | } else {
69 | displayMessage(verificationJSON?.msg || JSON.stringify(verificationJSON), true);
70 | }
71 | }
72 |
73 | function displayMessage(msg, isError) {
74 | const elmtMsg = document.getElementById('message');
75 | elmtMsg.innerText = msg;
76 | if (isError) {
77 | elmtMsg.classList.add('error');
78 | } else {
79 | elmtMsg.classList.remove('error');
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/lib/assets/oauth.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-env browser es2022 */
2 |
3 | import {startAuthentication} from './simplewebauthn-browser.js';
4 |
5 | document.querySelector('form')?.addEventListener('submit', submit);
6 |
7 | async function submit(evt) {
8 | try {
9 | evt.preventDefault();
10 | if (evt.submitter.name !== 'allow') {
11 | const redirect = document.querySelector('input[name=redirect_uri]').value;
12 | console.info(`authorization denied; redirecting to`, redirect);
13 | document.location = redirect;
14 | return;
15 | }
16 |
17 | displayMessage('authorizing');
18 |
19 | // @simplewebauthn/server -> generateAuthenticationOptions()
20 | const options = JSON.parse(document.getElementById('options').value);
21 | // console.log(`credentials options:`, options);
22 | if (Object.keys(options).length === 0) {
23 | throw new Error("Reload this page");
24 | }
25 |
26 | // Passes the options to the authenticator and waits for a response
27 | const credential = await startAuthentication(options);
28 | // console.log(`credential:`, credential);
29 | document.getElementById('credential').value = JSON.stringify(credential);
30 |
31 | // POSTs the response to the endpoint that calls
32 | // @simplewebauthn/server -> verifyAuthenticationResponse()
33 | evt.target.submit();
34 | } catch (err) {
35 | document.getElementById('progress').hidden = true;
36 | if (err.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') {
37 | err = err.cause;
38 | }
39 | console.error(err, err.code || '', err.cause || '', err.cause?.code || '');
40 |
41 | if (err.name === 'AbortError') {
42 | displayMessage('Validating passkey aborted')
43 | } else {
44 | displayMessage(err.message || err.toString(), true);
45 | }
46 | }
47 | }
48 |
49 |
50 | function displayMessage(msg, isError) {
51 | const elmtMsg = document.getElementById('message');
52 | elmtMsg.innerText = msg;
53 | if (isError) {
54 | elmtMsg.classList.add('error');
55 | } else {
56 | elmtMsg.classList.remove('error');
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/lib/assets/outfit-variablefont_wght.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remotestorage/armadietto/c4e5bc0dde5dacc35518c878ce3d431f7268eaee/lib/assets/outfit-variablefont_wght.woff2
--------------------------------------------------------------------------------
/lib/assets/passkeymajor-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/lib/assets/register.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-env browser es2022 */
2 |
3 | import {startRegistration} from './simplewebauthn-browser.js';
4 |
5 | const usernameInpt = document.getElementById('username');
6 | usernameInpt.addEventListener('change', getOptions);
7 | let username = usernameInpt.value;
8 | if (username) {
9 | getOptions().catch(console.error);
10 | }
11 |
12 | let options; // global variables are the least-bad solution for simple pages
13 |
14 | async function getOptions() {
15 | try {
16 | username = usernameInpt.value;
17 | const response = await fetch('/admin/getRegistrationOptions', {
18 | method: 'POST',
19 | headers: {'Content-type': 'application/json'},
20 | body: JSON.stringify({username})
21 | });
22 | if (response.ok) {
23 | options = await response.json()
24 |
25 | usernameInpt.readOnly = true;
26 | const btn = document.querySelector('button#register')
27 | btn.hidden = false;
28 | btn.addEventListener('click', generateCatchingErrors);
29 | displayMessage('Click the button below to create a passkey for this device');
30 | } else {
31 | const body = await response.json();
32 | if (body.error) {
33 | displayMessage(body.error, true);
34 | } else {
35 | displayMessage(`Something went wrong. Request another invite using the link above.`, true);
36 | }
37 | }
38 | } catch (err) {
39 | console.error(`while fetching options:`, err);
40 | displayMessage(`Something went wrong. Request another invite using the link above.`, true);
41 | await cancelInvite()
42 | }
43 | }
44 |
45 | async function generateCatchingErrors() {
46 | try {
47 | await generateAndRegisterPasskey();
48 | } catch (err) {
49 | document.getElementById('progress').hidden = true;
50 | if (err.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') { err = err.cause; }
51 | console.error(err, err.code || '', err.cause || '', err.cause?.code || '');
52 |
53 | if (err.code === "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED") {
54 | document.querySelector('button#register').hidden = true;
55 | displayMessage('You have already created a passkey for this device. Just log in, using the “Log in” link above!');
56 | await cancelInvite()
57 | } else if (err.name === 'AbortError') {
58 | displayMessage('Creating passkey aborted')
59 | } else if (err.name === 'InvalidStateError') {
60 | displayMessage('You already have a passkey:' + err.message, false);
61 | await cancelInvite()
62 | } else {
63 | displayMessage(err.message || err.toString(), true);
64 | await cancelInvite();
65 | }
66 | }
67 | }
68 |
69 | function preprocessError(err) {
70 | document.getElementById('progress').hidden = true;
71 | if (err.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') {
72 | return err.cause;
73 | } else {
74 | return err;
75 | }
76 | }
77 |
78 | async function generateAndRegisterPasskey() {
79 | displayMessage('Creating passkey');
80 |
81 | // console.log(`credentials options:`, options)
82 |
83 | document.getElementById('progress').hidden = false;
84 | // Passes the options to the authenticator and waits for a response
85 | const attResp = await startRegistration(options);
86 | // console.log(`attResp:`, attResp)
87 |
88 | // POST the response to the endpoint that calls
89 | // @simplewebauthn/server -> verifyRegistrationResponse()
90 | const searchParams = new URLSearchParams(document.location.search)
91 | const verificationUrl = new URL('/admin/verifyRegistration?token=' + searchParams.get('token'), document.location.origin)
92 | const verificationResp = await fetch(verificationUrl, {
93 | method: 'POST',
94 | headers: {'Content-Type': 'application/json'},
95 | body: JSON.stringify(attResp),
96 | });
97 | document.getElementById('progress').hidden = true;
98 |
99 | if (verificationResp.ok) {
100 | // Waits for the results of verification
101 | const verificationJSON = await verificationResp.json();
102 | // console.log(`verificationJSON:`, verificationJSON)
103 |
104 | if (verificationJSON?.verified) {
105 | displayMessage('Verified and saved on server!', false);
106 | document.location = '/account';
107 | } else {
108 | displayMessage(`Something went wrong! ${JSON.stringify(verificationJSON)}`, true);
109 | }
110 | } else {
111 | let msg = 'Check your connection';
112 | try {
113 | const failJSON = await verificationResp.json();
114 | msg = failJSON.error || msg;
115 | } catch (err) {
116 | console.error(`while parsing fail JSON:`, err);
117 | }
118 | displayMessage(msg, true);
119 | }
120 | }
121 |
122 | async function cancelInvite() {
123 | const searchParams = new URLSearchParams(document.location.search)
124 | const cancelUrl = new URL('/admin/cancelInvite?token=' + searchParams.get('token'), document.location.origin)
125 | const cancelResp = await fetch(cancelUrl, {method: 'POST'});
126 | if (!cancelResp.ok) {
127 | console.error(`while cancelling invite:`, await cancelResp.text());
128 | }
129 | }
130 |
131 |
132 | function displayMessage(msg, isError) {
133 | const elmtMsg = document.getElementById('message');
134 | elmtMsg.innerText = msg;
135 | if (isError) {
136 | elmtMsg.classList.add('error');
137 | } else {
138 | elmtMsg.classList.remove('error');
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/lib/controllers/assets.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const Controller = require('./base');
3 | const assetDir = path.join(__dirname, '..', 'assets');
4 | const { logRequest } = require('../logger');
5 |
6 | const TYPES = {
7 | '.css': 'text/css; charset=utf-8',
8 | '.js': 'application/javascript; charset=utf-8',
9 | '.svg': 'image/svg+xml; charset=utf-8',
10 | '.woff2': 'font/woff2'
11 | };
12 |
13 | class Assets extends Controller {
14 | serve (filename) {
15 | const content = this.readFile(path.join(assetDir, filename));
16 | const type = TYPES[path.extname(filename)];
17 |
18 | if (content) {
19 | this.response.writeHead(200, {
20 | 'Content-Length': content.length,
21 | 'Content-Type': type,
22 | 'X-Content-Type-Options': 'nosniff'
23 | });
24 | this.response.end(content);
25 | logRequest(this.request, '-', 200, content.length);
26 | } else {
27 | const locals = { title: 'Not found', message: `“${path.join('assets', filename)}” doesn't exist` };
28 | this.errorPage(404, locals, `no file '${path.join(assetDir, filename)}'`);
29 | }
30 | }
31 | }
32 |
33 | module.exports = Assets;
34 |
--------------------------------------------------------------------------------
/lib/controllers/oauth.js:
--------------------------------------------------------------------------------
1 | const qs = require('querystring');
2 | const accessStrings = { r: 'Read', rw: 'Read/write' };
3 | const Controller = require('./base');
4 | const { logRequest } = require('../logger');
5 |
6 | class OAuth extends Controller {
7 | showForm (username) {
8 | if (this.redirectToSSL()) return;
9 | if (this.invalidUser(username)) return;
10 | if (this.invalidOAuthRequest()) return;
11 |
12 | this.renderHTML(200, 'auth.html', {
13 | title: 'Authorize',
14 | client_host: this.getRedirectUri().host,
15 | client_id: this.params.client_id,
16 | redirect_uri: this.params.redirect_uri,
17 | response_type: this.params.response_type,
18 | scope: this.params.scope || '',
19 | state: this.params.state || '',
20 | permissions: this.parseScope(this.params.scope || ''),
21 | username,
22 | access_strings: accessStrings
23 | });
24 | }
25 |
26 | async authenticate () {
27 | if (this.blockUnsecureRequest()) return;
28 | if (this.invalidUser(this.params.username)) return;
29 | if (this.invalidOAuthRequest()) return;
30 |
31 | const params = this.params;
32 | const username = params.username.split('@')[0];
33 | const permissions = this.parseScope(params.scope);
34 |
35 | if (params.deny) return this.error('access_denied', 'The user did not grant permission');
36 |
37 | try {
38 | await this.server._store.authenticate({ username, password: params.password });
39 | const token = await this.server._store.authorize(params.client_id, username, permissions);//, (error, token) => {
40 | const args = {
41 | access_token: token,
42 | token_type: 'bearer'
43 | };
44 | if (params.state !== undefined) args.state = params.state;
45 | this.redirect(args);
46 | } catch (error) {
47 | params.title = 'Authorization Failure';
48 | params.client_host = this.getRedirectUri().host;
49 | params.error = error.message;
50 | params.permissions = permissions;
51 | params.access_strings = accessStrings;
52 | params.state = params.state || '';
53 |
54 | this.renderHTML(401, 'auth.html', params);
55 | }
56 | }
57 |
58 | invalidOAuthRequest () {
59 | if (!this.params.client_id) return this.error('invalid_request', 'Required parameter "client_id" is missing');
60 | if (!this.params.response_type) return this.error('invalid_request', 'Required parameter "response_type" is missing');
61 | if (!this.params.scope) return this.error('invalid_scope', 'Parameter "scope" is invalid');
62 |
63 | if (!this.params.redirect_uri) return this.error('invalid_request', 'Required parameter "redirect_uri" is missing');
64 | const uri = this.getRedirectUri();
65 | if (!uri.protocol || !uri.hostname) return this.error('invalid_request', 'Parameter "redirect_uri" must be a valid URL');
66 |
67 | if (this.params.response_type !== 'token') {
68 | return this.error('unsupported_response_type', 'Response type "' + this.params.response_type + '" is not supported');
69 | }
70 |
71 | return false;
72 | }
73 |
74 | error (type, description) {
75 | this.redirect({ error: type, error_description: description },
76 | `${this.params.username} ${description} ${this.params.client_id}`);
77 | return true;
78 | }
79 |
80 | redirect (args, logNote) {
81 | const hash = qs.stringify(args);
82 | if (this.params.redirect_uri) {
83 | const location = this.params.redirect_uri + '#' + hash;
84 | this.response.writeHead(302, { Location: location });
85 | this.response.end();
86 | if (logNote) {
87 | logRequest(this.request, this.params.username || '-', 302, 0, logNote, 'warning');
88 | } else {
89 | logRequest(this.request, this.params.username || '-', 302, 0, '-> ' + this.params.redirect_uri, 'notice');
90 | }
91 | } else {
92 | this.response.writeHead(400, { 'Content-Type': 'text/plain' });
93 | this.response.end(hash);
94 | logRequest(this.request, this.params.username || '-', 400, hash.length, 'no redirect_uri');
95 | }
96 | }
97 |
98 | // OAuth.prototype.accessStrings = {r: 'Read', rw: 'Read/write'};
99 | parseScope (scope) {
100 | const parts = scope.split(/\s+/);
101 | const scopes = {};
102 | let pieces;
103 |
104 | for (let i = 0, n = parts.length; i < n; i++) {
105 | pieces = parts[i].split(':');
106 | pieces[0] = pieces[0].replace(/(.)\/*$/, '$1');
107 | if (pieces[0] === 'root') pieces[0] = '/';
108 |
109 | scopes[pieces[0]] = (pieces.length > 1)
110 | ? pieces.slice(1).join(':').split('')
111 | : ['r', 'w'];
112 | }
113 | return scopes;
114 | }
115 |
116 | getRedirectUri () {
117 | if (!this._redirect_uri) {
118 | this._redirect_uri = new URL(this.params.redirect_uri);
119 | }
120 | return this._redirect_uri;
121 | }
122 | }
123 |
124 | module.exports = OAuth;
125 |
--------------------------------------------------------------------------------
/lib/controllers/users.js:
--------------------------------------------------------------------------------
1 | const DISABLED_LOCALS = { title: 'Forbidden', message: 'Signing up is not allowed currently' };
2 | const DISABLED_LOG_NOTE = 'signups disabled';
3 | const Controller = require('./base');
4 |
5 | class Users extends Controller {
6 | showForm () {
7 | if (!this.server._allow.signup) return this.errorPage(403, DISABLED_LOCALS, DISABLED_LOG_NOTE);
8 | if (this.redirectToSSL()) return;
9 | this.renderHTML(200, 'signup.html', { title: 'Signup', params: this.params, error: null });
10 | }
11 |
12 | async register () {
13 | if (!this.server._allow.signup) return this.errorPage(403, DISABLED_LOCALS, DISABLED_LOG_NOTE);
14 | if (this.blockUnsecureRequest()) return;
15 |
16 | try {
17 | await this.server._store.createUser(this.params);
18 | this.renderHTML(201, 'signup-success.html', {
19 | title: 'Signup Success',
20 | params: this.params,
21 | host: this.getHost()
22 | }, `created user '${this.params.username}' w/ email '${this.params.email}'`, 'notice');
23 | } catch (error) {
24 | this.renderHTML(409, 'signup.html', {
25 | title: 'Signup Failure',
26 | params: this.params,
27 | error,
28 | message: error.message
29 | }, `while signing up: ${error.message || error.code || error.name || error.toString()}`);
30 | }
31 | }
32 |
33 | async showLoginForm () {
34 | if (this.redirectToSSL()) return;
35 | this.renderHTML(200, 'login.html', { title: 'Login', params: this.params, error: null });
36 | }
37 |
38 | async showAccountPage () {
39 | if (this.blockUnsecureRequest()) return;
40 |
41 | const expandedPermissions = {
42 | r: 'Read',
43 | w: 'Write'
44 | };
45 |
46 | try {
47 | await this.server._store.authenticate(this.params);
48 | const authData = await this.server._store.readAuth(this.params.username);
49 | // this is a bit of a monster but it formats the somewhat unwieldy auth.json
50 | // for a user into something that looks like:
51 | // {
52 | // "params": {"username": string},
53 | // "host": string,
54 | // "sessions: [
55 | // "clientId": string, <- the url for the app as per the spec
56 | // "permissions": [
57 | // {
58 | // "folder": string,
59 | // "permissions": ["Read", "Write"] <- the permission array may contain one/both
60 | // }
61 | // ]
62 | // ]
63 | // }
64 | //
65 | // We're doing this transform just to make it easier on the view side to
66 | // iterate over things.
67 | this.renderHTML(200, 'account.html', {
68 | title: 'Account',
69 | params: { username: this.params.username },
70 | host: this.getHost(),
71 | sessions: authData.sessions
72 | ? Object.values(authData.sessions).map(session => {
73 | return {
74 | clientId: session.clientId,
75 | appName: /https?:\/\/([a-zA-Z\d-]+)[.:$]/.exec(session.clientId)?.[1] || '',
76 | permissions: Object.entries(session.permissions).map(([folder, perms]) => {
77 | return {
78 | folder,
79 | permissions: Object.keys(perms).filter(perm => {
80 | return perms[perm];
81 | }).map(v => expandedPermissions[v])
82 | };
83 | })
84 | };
85 | })
86 | : []
87 | });
88 | } catch (error) {
89 | this.renderHTML(409, 'login.html', { title: 'Login', params: this.params, error },
90 | `while showing account: ${error.message || error.code || error.name || error.toString()}`);
91 | }
92 | }
93 | }
94 |
95 | module.exports = Users;
96 |
--------------------------------------------------------------------------------
/lib/controllers/web_finger.js:
--------------------------------------------------------------------------------
1 | const Controller = require('./base');
2 |
3 | class WebFinger extends Controller {
4 | hostMeta (pathname, extension) {
5 | const resource = this.params.resource;
6 | const origin = this.getOrigin();
7 | const json = (extension === '.json');
8 | const useJRD = (pathname === 'webfinger');
9 | const useJSON = useJRD || (pathname === 'host-meta' && json);
10 | let response;
11 |
12 | if (!resource) {
13 | response = {
14 | links: [{
15 | rel: 'lrdd',
16 | template: origin + '/webfinger/' + (useJSON ? 'jrd' : 'xrd') + '?resource={uri}'
17 | }]
18 | };
19 | if (useJSON) {
20 | this.renderJSON(response, useJRD ? 'jrd+json' : 'json');
21 | } else {
22 | this.renderXRD('host.xml', response);
23 | }
24 | return;
25 | }
26 |
27 | const user = resource.replace(/^acct:/, '').split('@')[0];
28 |
29 | response = {
30 | links: [{
31 | href: origin + '/storage/' + user,
32 | rel: 'remotestorage',
33 | type: WebFinger.PROTOCOL_VERSION,
34 | properties: {
35 | 'auth-method': WebFinger.OAUTH_VERSION,
36 | 'auth-endpoint': origin + '/oauth/' + user,
37 | 'http://remotestorage.io/spec/version': WebFinger.PROTOCOL_VERSION,
38 | 'http://tools.ietf.org/html/rfc6750#section-2.3': true
39 | }
40 | }]
41 | };
42 |
43 | response.links[0].properties[WebFinger.OAUTH_VERSION] =
44 | response.links[0].properties['auth-endpoint'];
45 |
46 | if (useJSON) {
47 | this.renderJSON(response, 'json');
48 | } else {
49 | this.renderXRD('resource.xml', response);
50 | }
51 | }
52 |
53 | account (type) {
54 | const resource = this.params.resource;
55 | const user = resource.replace(/^acct:/, '').split('@')[0];
56 | const origin = this.getOrigin();
57 |
58 | const response = {
59 | links: [{
60 | rel: 'remoteStorage',
61 | api: 'simple',
62 | auth: origin + '/oauth/' + user,
63 | template: origin + '/storage/' + user + '/{category}'
64 | }]
65 | };
66 |
67 | if (type === 'xrd') {
68 | this.renderXRD('account.xml', response);
69 | } else {
70 | this.renderJSON(response, 'jrd+json');
71 | }
72 | }
73 |
74 | getOrigin () {
75 | const scheme = (this.request.secure || this.server._forceSSL) ? 'https' : 'http';
76 | const host = this.request.headers['x-forwarded-host'] || this.request.headers.host;
77 | return scheme + '://' + host + this.server._basePath;
78 | }
79 | }
80 |
81 | WebFinger.OAUTH_VERSION = 'http://tools.ietf.org/html/rfc6749#section-4.2';
82 | WebFinger.PROTOCOL_VERSION = 'draft-dejong-remotestorage-01';
83 | module.exports = WebFinger;
84 |
--------------------------------------------------------------------------------
/lib/logger.js:
--------------------------------------------------------------------------------
1 | const winston = require('winston');
2 | const { format } = winston;
3 | const { printf } = format;
4 | const path = require('path');
5 | const getOriginator = require('./util/getOriginator');
6 | const shorten = require('./util/shorten');
7 |
8 | const LOG_NOTE_MAX_LEN = 150;
9 |
10 | let logger;
11 |
12 | function configureLogger (options) {
13 | const defaults = {
14 | log_dir: '',
15 | stdout: [],
16 | log_files: []
17 | };
18 | options = Object.assign(defaults, options);
19 |
20 | // provided log_dir should be an absolute path where logs
21 | // will reside. Otherwise it just logs them to the root dir
22 | // of your app
23 | const loggerPath = options.log_dir || '';
24 |
25 | let fileTransports = [];
26 | if (options.log_files) {
27 | fileTransports = options.log_files.map(level => {
28 | // if we pass "combined", it's a special strategy that just
29 | // logs everything to a single file
30 | const settings = {
31 | filename: path.join(loggerPath, `${level}.log`)
32 | };
33 |
34 | if (level !== 'combined') {
35 | settings.level = level;
36 | }
37 |
38 | return new winston.transports.File(settings);
39 | });
40 | }
41 |
42 | // journald will supply the date
43 | const minimalFormat = printf(({ level, message }) => {
44 | return `${level}: ${message}`;
45 | });
46 |
47 | let consoleTransports = [];
48 | if (options.stdout) {
49 | consoleTransports = options.stdout.map(level => {
50 | // if we pass "combined", it's a special strategy that just
51 | // logs everything to a single file
52 | const settings = {};
53 |
54 | if (level !== 'combined') {
55 | settings.level = level;
56 | }
57 | if (!settings.format) {
58 | settings.format = minimalFormat;
59 | }
60 |
61 | return new winston.transports.Console(settings);
62 | });
63 | }
64 |
65 | if (fileTransports.length === 0 && consoleTransports.length === 0) {
66 | consoleTransports.push(new winston.transports.Console({
67 | level: 'notice',
68 | format: minimalFormat
69 | }));
70 | }
71 |
72 | const transports = [...fileTransports, ...consoleTransports];
73 |
74 | logger = winston.createLogger({
75 | levels: winston.config.syslog.levels,
76 | level: 'debug',
77 | format: winston.format.combine(
78 | // we want to be able to pass an error object to the logger
79 | // and have it include a mini stack trace
80 | winston.format.errors({ stack: true }),
81 | winston.format.timestamp(),
82 | winston.format.json()
83 | ),
84 | transports,
85 | exceptionHandlers: transports,
86 | rejectionHandlers: transports
87 | });
88 |
89 | return logger;
90 | }
91 |
92 | function getLogger () {
93 | return logger;
94 | }
95 |
96 | function loggingMiddleware (req, res, next) {
97 | res.logNotes = new Set();
98 | res.on('finish', () => {
99 | let names = new Set([req.body?.username, req.query?.username, req.params?.username, req.username, req.session?.oauthParams?.username, req.session?.user?.username].filter(n => Boolean(n)));
100 | if (names.size === 0) {
101 | names = new Set([req.contactURL, req.session?.user?.contactURL].filter(n => Boolean(n)));
102 | }
103 | const username = names.size > 0 ? Array.from(names).join('|') : '-';
104 | logRequest(req, username, res.statusCode,
105 | res._contentLength ?? res.get('Content-Length') ?? '-', shorten(Array.from(res.logNotes).join(' '), LOG_NOTE_MAX_LEN), res.logLevel);
106 | });
107 | next();
108 | }
109 |
110 | function logRequest (req, username, status, numBytesWritten, logNote, logLevel) {
111 | let level;
112 | if (logLevel) {
113 | level = logLevel;
114 | } else if ([502, 504, 507].includes(status)) { // Bad Gateway, Gateway Timeout, Insufficient Storage
115 | level = 'error'; // problem with backing store
116 | } else if (status === 503) { // Service Unavailable
117 | level = 'warning';
118 | } else if (!status || status >= 500) { // presumably a coding error
119 | level = 'crit';
120 | } else if ([401, 201].includes(status)) { // user authentication & blob creation are notable
121 | level = 'notice';
122 | } else if (req.originalUrl === '/favicon.ico') { // browsers request this too often
123 | level = 'debug';
124 | } else if (status >= 400) { // client error
125 | level = 'warning';
126 | } else if ([301, 302, 307, 308].includes(status)) { // redirects are boring
127 | level = 'debug';
128 | } else {
129 | level = 'info'; // OK and Not Modified are routine
130 | }
131 |
132 | const clientAddress = req.headers['x-forwarded-for'] || req.socket.address().address;
133 |
134 | const appHost = getOriginator(req);
135 |
136 | let url = req.originalUrl ?? req.url;
137 | if (url.length > 100) {
138 | url = url.slice(0, 99) + '…';
139 | }
140 | let line = `${clientAddress} ${appHost} ${username} ${req.method} ${url} ${status} ${numBytesWritten}`;
141 | if (logNote) {
142 | if (logNote instanceof Error) {
143 | logNote = logNote.message || logNote.code || logNote.name || logNote.toString();
144 | }
145 | line += ' «' + logNote + '»';
146 | }
147 | logger.log(level, line);
148 | }
149 |
150 | module.exports = { configureLogger, getLogger, loggingMiddleware, logRequest };
151 |
--------------------------------------------------------------------------------
/lib/middleware/formOrQueryData.js:
--------------------------------------------------------------------------------
1 | /** Sets req.data to form data if form-urlencoded, otherwise to query parameters */
2 | module.exports = function (req, res, next) {
3 | if (req.is('application/x-www-form-urlencoded')) {
4 | req.data = req.body;
5 | } else {
6 | req.data = req.query;
7 | }
8 | next();
9 | };
10 |
--------------------------------------------------------------------------------
/lib/middleware/rateLimiterMiddleware.js:
--------------------------------------------------------------------------------
1 | const { RateLimiterMemory, BurstyRateLimiter } = require('rate-limiter-flexible');
2 |
3 | const SUSTAINED_REQ_PER_SEC = 8;
4 | const MAX_BURST = 50; // remotestorage.js appears to keep requesting until 10 failures
5 |
6 | const rateLimiterSustained = new RateLimiterMemory({
7 | points: SUSTAINED_REQ_PER_SEC,
8 | duration: 1
9 | });
10 |
11 | const rateLimiterBurst = new RateLimiterMemory({
12 | keyPrefix: 'burst',
13 | points: MAX_BURST - SUSTAINED_REQ_PER_SEC,
14 | duration: 10
15 | });
16 |
17 | async function rateLimiterPenalty (key, points = 1) {
18 | await rateLimiterSustained.penalty(key, points);
19 | await rateLimiterBurst.penalty(key, points);
20 | }
21 |
22 | async function rateLimiterBlock (key, secDuration) {
23 | await rateLimiterSustained.block(key, secDuration);
24 | await rateLimiterBurst.block(key, secDuration);
25 | }
26 |
27 | const rateLimiter = new BurstyRateLimiter(
28 | rateLimiterSustained,
29 | rateLimiterBurst
30 | );
31 |
32 | const rateLimiterMiddleware = async (req, res, next) => {
33 | try {
34 | await rateLimiter.consume(req.ip);
35 | next();
36 | } catch (err) {
37 | res.set({ 'Retry-After': Math.ceil(err.msBeforeNext / 1000) });
38 | res.status(429).end();
39 | }
40 | };
41 |
42 | module.exports = { rateLimiterPenalty, rateLimiterBlock, rateLimiterMiddleware };
43 |
--------------------------------------------------------------------------------
/lib/middleware/redirectToSSL.js:
--------------------------------------------------------------------------------
1 | const isSecureRequest = require('../util/isSecureRequest');
2 | const { getHost } = require('../util/getHost');
3 |
4 | /** redirects to HTTPS server if needed */
5 | module.exports = function redirectToSSL (req, res, next) {
6 | if (isSecureRequest(req) || (process.env.NODE_ENV !== 'production' && !req.app.get('forceSSL'))) {
7 | return next();
8 | }
9 |
10 | const host = getHost(req).split(':')[0] + (req.app.get('httpsPort') ? ':' + req.app.get('httpsPort') : '');
11 | const newUrl = 'https://' + host + req.url;
12 |
13 | res.logNotes.add('-> ' + newUrl);
14 | res.redirect(302, newUrl);
15 | };
16 |
--------------------------------------------------------------------------------
/lib/middleware/sanityCheckUsername.js:
--------------------------------------------------------------------------------
1 | /** sanity check of username, to defend against ".." and whatnot */
2 | const { rateLimiterBlock } = require('./rateLimiterMiddleware');
3 | module.exports = async function sanityCheckUsername (req, res, next) {
4 | const username = req.params.username || req.data.username || '';
5 | if (username.length > 0 && !/\/|^\.+$/.test(username) && /[\p{L}\p{Nd}]{1,63}/u.test(username)) {
6 | return next();
7 | }
8 |
9 | res.logNotes.add('invalid username; blocking');
10 | await rateLimiterBlock(req.ip, 61);
11 | res.status(400).end();
12 | };
13 |
--------------------------------------------------------------------------------
/lib/middleware/secureRequest.js:
--------------------------------------------------------------------------------
1 | const isSecureRequest = require('../util/isSecureRequest');
2 |
3 | /** ensures request is secure, if required */
4 | module.exports = function secureRequest (req, res, next) {
5 | if (isSecureRequest(req) || (process.env.NODE_ENV !== 'production' && !req.app.get('forceSSL'))) {
6 | return next();
7 | }
8 |
9 | res.logNotes.add('blocked insecure');
10 | res.status(400).type('text/plain').send('The request did not use HTTPS.');
11 | };
12 |
--------------------------------------------------------------------------------
/lib/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 | Allow: /$
4 | Allow: /signup$
5 | Allow: /assets/
6 | Allow: /storage/*/public/
7 |
--------------------------------------------------------------------------------
/lib/routes/account.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const { getHost } = require('../util/getHost');
3 | const errToMessages = require('../util/errToMessages');
4 | const removeUserDataFromSession = require('../util/removeUserDataFromSession');
5 |
6 | module.exports = async function (hostIdentity, jwtSecret, accountMgr) {
7 | const router = express.Router();
8 |
9 | // ----------------------- user account -------------------------------------------
10 |
11 | router.get('/',
12 | // csrfCheck,
13 | async (req, res) => {
14 | try {
15 | if (!req.session.user?.username) {
16 | res.logNotes.add('-> /account/login');
17 | removeUserDataFromSession(req.session);
18 | res.redirect(307, '/account/login');
19 | return;
20 | }
21 | req.session.user = await accountMgr.getUser(req.session.user.username, res.logNotes); // get latest values
22 |
23 | res.set('Cache-Control', 'private, no-cache');
24 | res.render('account/account.html', {
25 | title: 'Your Account',
26 | host: getHost(req),
27 | privileges: req.session.privileges || {},
28 | accountPrivileges: req.session.user?.privileges || {},
29 | username: req.session.user?.username,
30 | contactURL: req.session.user?.contactURL,
31 | credentials: Object.values(req.session.user?.credentials || {})
32 | });
33 | res.logNotes.add(`${req.session.user?.username} ${req.session.user?.contactURL} ${Object.keys(req.session.user?.privileges).join('&')} ${Object.keys(req.session.user?.credentials)?.length} credential(s)`);
34 | } catch (err) {
35 | errToMessages(err, res.logNotes);
36 | res.status(401).render('login/error.html', {
37 | title: 'Your Account',
38 | host: getHost(req),
39 | privileges: req.session.privileges || {},
40 | accountPrivileges: req.session.user?.privileges || {},
41 | subtitle: '',
42 | message: 'There was an error displaying your info'
43 | });
44 | }
45 | }
46 | );
47 |
48 | return router;
49 | };
50 |
--------------------------------------------------------------------------------
/lib/routes/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const { getHost } = require('../util/getHost');
4 |
5 | /* GET home page. */
6 | router.get('/', function (req, res) {
7 | res.set('Cache-Control', 'public, max-age=1500');
8 | res.render('index2.html', {
9 | title: 'Welcome',
10 | host: getHost(req),
11 | privileges: req.session.privileges || {},
12 | accountPrivileges: req.session.user?.privileges || {}
13 | });
14 | });
15 |
16 | module.exports = router;
17 |
--------------------------------------------------------------------------------
/lib/routes/request-invite.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const { assembleContactURL } = require('../../lib/util/protocols');
3 | const { getHost } = require('../util/getHost');
4 | const errToMessages = require('../util/errToMessages');
5 | const errorPage = require('../util/errorPage');
6 | const path = require('path');
7 | const { protocolOptions } = require('../util/protocols');
8 |
9 | const DISABLED_LOCALS = { title: 'Forbidden', message: 'Requesting invite is not allowed currently' };
10 | const DISABLED_LOG_NOTE = 'requesting invites disabled';
11 | const INVITE_REQUEST_DIR = 'inviteRequests';
12 |
13 | module.exports = function (storeRouter) {
14 | const router = express.Router();
15 |
16 | /* initial entry */
17 | router.get('/', function (req, res) {
18 | try {
19 | if (req.app?.locals?.signup) {
20 | res.set('Cache-Control', 'public, max-age=1500');
21 | res.render('login/request-invite.html', {
22 | title: 'Request an Invitation',
23 | host: getHost(req),
24 | privileges: {},
25 | accountPrivileges: {},
26 | protocolOptions: protocolOptions(),
27 | params: { submitName: 'Request invite' },
28 | privilegeGrant: {},
29 | error: null
30 | });
31 | } else {
32 | errorPage(req, res, 403, DISABLED_LOCALS, DISABLED_LOG_NOTE);
33 | }
34 | } catch (err) {
35 | errToMessages(err, res.logNotes);
36 | res.status(401).render('login/error.html', {
37 | title: 'Error requesting invite',
38 | host: getHost(req),
39 | privileges: req.session.privileges || {},
40 | accountPrivileges: {},
41 | subtitle: '',
42 | message: 'There was an error displaying your info'
43 | });
44 | }
45 | });
46 |
47 | /* submission or re-submission */
48 | router.post('/',
49 | async function (req, res) {
50 | if (!req.app?.locals?.signup) {
51 | errorPage(req, res, 403, DISABLED_LOCALS, DISABLED_LOG_NOTE);
52 | return;
53 | }
54 | try {
55 | req.body.address = req.body.address.trim();
56 |
57 | const contactURL = req.contactURL = assembleContactURL(req.body.protocol, req.body.address).href;
58 |
59 | await storeRouter.upsertAdminBlob(path.join(INVITE_REQUEST_DIR, encodeURIComponent(contactURL) + '.yaml'), 'application/yaml', contactURL);
60 |
61 | res.status(201).render('login/request-invite-success.html', {
62 | title: 'Invitation Requested',
63 | host: getHost(req),
64 | privileges: {},
65 | accountPrivileges: {},
66 | params: { contactURL }
67 | });
68 | } catch (err) {
69 | errToMessages(err, res.logNotes.add(`while requesting invite “${req.body}”:`));
70 |
71 | const statusCode = err.name === 'ParameterError' ? 400 : 409;
72 | const errChain = [err, ...(err.errors || []), ...(err.cause ? [err.cause] : []), new Error('indescribable error')];
73 | res.status(statusCode).render('login/request-invite.html', {
74 | title: 'Request Failure',
75 | host: getHost(req),
76 | privileges: {},
77 | accountPrivileges: {},
78 | protocolOptions: protocolOptions(),
79 | params: Object.assign(req.body, { submitName: 'Request invite' }),
80 | privilegeGrant: {},
81 | error: errChain.find(e => e.message)
82 | });
83 | }
84 | });
85 |
86 | return router;
87 | };
88 |
--------------------------------------------------------------------------------
/lib/routes/webfinger.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const { corsAllowPrivate } = require('../util/corsMiddleware');
4 | const cors = require('cors');
5 | const { getHostBaseUrl } = require('../util/getHost');
6 | const PROTOCOL_VERSION = 'draft-dejong-remotestorage-22';
7 |
8 | // /.well-known
9 | router.options(['/webfinger', '/host-meta(.json)?'], corsAllowPrivate, cors());
10 |
11 | router.get(['/webfinger', '/host-meta(.json)?'], corsAllowPrivate, cors(), wellKnown);
12 |
13 | function wellKnown (req, res) {
14 | const resource = req.query.resource;
15 | const hostBaseUrl = getHostBaseUrl(req);
16 | const jsonRequested = req.path.endsWith('.json');
17 | const useJRD = req.url.startsWith('/webfinger');
18 | const useJSON = useJRD || (req.url.startsWith('/host-meta') && jsonRequested);
19 |
20 | let content;
21 | if (!resource) {
22 | content = {
23 | links: [{
24 | rel: 'lrdd',
25 | template: hostBaseUrl + '/webfinger/' + (useJSON ? 'jrd' : 'xrd') + '?resource={uri}'
26 | }]
27 | };
28 | if (useJSON) {
29 | res.type(useJRD ? 'application/jrd+json' : 'application/json').json(content);
30 | } else {
31 | res.type('application/xrd+xml').render('host.xml', content);
32 | }
33 | } else {
34 | const user = resource.replace(/^acct:/, '').split('@')?.[0];
35 | const authEndpoint = hostBaseUrl + '/oauth/' + user;
36 |
37 | content = {
38 | links: [{
39 | href: hostBaseUrl + '/storage/' + user,
40 | rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage',
41 | type: PROTOCOL_VERSION, // for compatability with old versions of spec
42 | properties: {
43 | 'http://remotestorage.io/spec/version': PROTOCOL_VERSION,
44 | 'http://tools.ietf.org/html/rfc6749#section-4.2': authEndpoint,
45 | // 'http://tools.ietf.org/html/rfc7233': 'GET', // Range requests
46 | // for compatability with old versions of spec
47 | 'auth-method': 'http://tools.ietf.org/html/rfc6749#section-4.2',
48 | 'auth-endpoint': authEndpoint
49 | }
50 | }]
51 | };
52 |
53 | if (useJSON) {
54 | res.type('application/json').json(content);
55 | } else {
56 | res.type('application/xrd+xml').render('resource.xml', content);
57 | }
58 | }
59 | }
60 |
61 | router.get('/change-password', cors(), changePassword);
62 |
63 | function changePassword (_req, res, _next) {
64 | res.redirect(303, '/signup');
65 | }
66 |
67 | // /webfinger
68 | router.get(['/jrd', '/xrd'], corsAllowPrivate, cors(), webfinger);
69 |
70 | function webfinger (req, res) {
71 | const resource = req.query.resource;
72 | const user = resource?.replace(/^acct:/, '').split('@')?.[0];
73 | const hostBaseUrl = getHostBaseUrl(req);
74 |
75 | const content = {
76 | links: [{
77 | rel: 'remoteStorage',
78 | api: 'simple',
79 | auth: hostBaseUrl + '/oauth/' + user,
80 | template: hostBaseUrl + '/storage/' + user + '/{category}'
81 | }]
82 | };
83 |
84 | if (req.path.startsWith('/xrd')) {
85 | res.type('application/xrd+xml').render('account.xml', content);
86 | } else {
87 | res.type('application/jrd+json').json(content);
88 | }
89 | }
90 |
91 | module.exports = router;
92 |
--------------------------------------------------------------------------------
/lib/stores/core.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 | const promisify = require('util').promisify;
3 | const path = require('path');
4 |
5 | const pbkdf2 = promisify(crypto.pbkdf2);
6 |
7 | const core = {
8 | VALID_PATH: /^\/([a-z0-9%.\-_]+\/?)*$/i,
9 | VALID_NAME: /^[a-z0-9%.\-_]+$/,
10 | hashRounds: 10000,
11 |
12 | traversePath (currentPath) {
13 | let upperBasename;
14 | const paths = [];
15 | while (currentPath !== '') {
16 | upperBasename = path.basename(currentPath);
17 | currentPath = currentPath.substring(0, currentPath.length - upperBasename.length - 1);
18 | paths.push({ currentPath, upperBasename });
19 | }
20 | return paths;
21 | },
22 |
23 | generateToken () {
24 | return crypto.randomBytes(160 / 8).toString('base64');
25 | },
26 |
27 | async hashPassword (password, config) {
28 | config = config || {
29 | salt: crypto.randomBytes(16).toString('base64'),
30 | work: core.hashRounds,
31 | keylen: 64
32 | };
33 |
34 | const key = await pbkdf2(password, config.salt,
35 | parseInt(config.work, 10),
36 | parseInt(config.keylen, 10), 'sha512');
37 |
38 | config.key = key.toString('base64');
39 | return config;
40 | },
41 |
42 | parents (path, includeSelf) {
43 | const query = core.parsePath(path);
44 | const parents = [];
45 |
46 | if (includeSelf) parents.push(query.join(''));
47 | query.pop();
48 |
49 | while (query.length > 0) {
50 | parents.push(query.join(''));
51 | query.pop();
52 | }
53 | return parents;
54 | },
55 |
56 | parsePath (path) {
57 | const query = path.match(/[^/]*(\/|$)/g);
58 | return query.slice(0, query.length - 1);
59 | },
60 |
61 | validateUser (params) {
62 | const errors = [];
63 | const username = params.username || '';
64 | const email = params.email || '';
65 | const password = params.password || '';
66 | if (username.length < 2) { errors.push(new Error('Username must be at least 2 characters long')); }
67 |
68 | if (!core.isValidUsername(username)) { errors.push(new Error('Usernames may only contain lowercase letters, numbers, dots, dashes and underscores')); }
69 |
70 | if (!email) { errors.push(new Error('Email must not be blank')); }
71 |
72 | if (!/^.+@.+\..+$/.test(email)) { errors.push(new Error('Email is not valid')); }
73 |
74 | if (!password) { errors.push(new Error('Password must not be blank')); }
75 |
76 | return errors;
77 | },
78 |
79 | isValidUsername (username) {
80 | if ((!username) || (username === '..')) return false;
81 | return core.VALID_NAME.test(username);
82 | }
83 | };
84 |
85 | module.exports = core;
86 |
--------------------------------------------------------------------------------
/lib/util/EndResponseError.js:
--------------------------------------------------------------------------------
1 | /** Thrown to tell the top-level handler what to respond with. */
2 |
3 | module.exports = class EndResponseError extends Error {
4 | constructor (message, options, statusCode, logLevel = undefined) {
5 | super(...arguments);
6 | this.name = 'EndResponseError';
7 | this.statusCode = statusCode;
8 | this.logLevel = logLevel;
9 | }
10 | };
11 |
--------------------------------------------------------------------------------
/lib/util/NoSuchBlobError.js:
--------------------------------------------------------------------------------
1 | module.exports = class NoSuchBlobError extends Error {
2 | constructor (message, options) {
3 | super(message, options);
4 | this.name = 'NoSuchBlobError';
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/lib/util/NoSuchUserError.js:
--------------------------------------------------------------------------------
1 | module.exports = class NoSuchUserError extends Error {
2 | constructor () {
3 | super(...arguments);
4 | this.name = 'NoSuchUserError';
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/lib/util/ParameterError.js:
--------------------------------------------------------------------------------
1 | module.exports = class ParameterError extends Error {
2 | constructor () {
3 | super(...arguments);
4 | this.name = 'ParameterError';
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/lib/util/corsMiddleware.js:
--------------------------------------------------------------------------------
1 | const cors = require('cors');
2 |
3 | module.exports = {
4 | corsAllowPrivate: function corsAllowPrivate (req, res, next) {
5 | res.set('Access-Control-Allow-Private-Network', 'true');
6 | next();
7 | },
8 | corsRS: cors({ origin: true, allowedHeaders: 'Content-Type, Authorization, Content-Length, If-Match, If-None-Match, Origin, X-Requested-With', methods: 'GET, HEAD, PUT, DELETE', exposedHeaders: 'ETag', credentials: true, maxAge: 7200 })
9 | };
10 |
--------------------------------------------------------------------------------
/lib/util/errToMessages.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Extracts & de-dupes payloads of error tree, for terse logging
3 | * @param {Error} err
4 | * @param {Set} messages
5 | * @returns {Set} the messages object, for chaining
6 | */
7 | module.exports = function errToMessages (err, messages) {
8 | try {
9 | if (!(err instanceof Object)) { return messages; }
10 |
11 | if (err.name !== 'AggregateError') {
12 | if (err.name && !err.message?.includes(err.name) &&
13 | !Array.from(messages).some(msg => typeof msg === 'string' && msg?.includes(err.name))) {
14 | messages.add(err.name);
15 | }
16 | if (err.message) {
17 | messages.add(err.message?.replace(/\r\n|\n|\r/, ' '));
18 | }
19 | if (err.code && !Array.from(messages).some(msg => typeof msg === 'string' && msg?.includes(err.code))) {
20 | messages.add(err.code);
21 | }
22 | const errno = err.errno ? String(err.errno) : '';
23 | if (errno && !Array.from(messages).some(msg => typeof msg === 'string' && msg?.includes(errno))) {
24 | messages.add(errno);
25 | }
26 | const statusCode = err.$metadata?.httpStatusCode ? String(err.$metadata.httpStatusCode) : '';
27 | if (statusCode) {
28 | messages.add(statusCode);
29 | }
30 | if (err.$metadata?.attempts > 1) {
31 | messages.add(`${err.$metadata.attempts} attempts`); // eslint-disable-line no-irregular-whitespace
32 | }
33 | }
34 | if (err.errors?.[Symbol.iterator]) {
35 | for (const e of err.errors) {
36 | errToMessages(e, messages);
37 | }
38 | }
39 | if (err.cause) {
40 | errToMessages(err.cause, messages);
41 | }
42 | } catch (err2) {
43 | messages.add(err2);
44 | }
45 | return messages;
46 | };
47 |
--------------------------------------------------------------------------------
/lib/util/errorPage.js:
--------------------------------------------------------------------------------
1 | const { getHost } = require('./getHost');
2 |
3 | function errorPage (req, res, status = 500, messageOrLocals, logNote = '', logLevel = undefined) {
4 | res.status(status);
5 |
6 | const locals = {
7 | title: 'Something went wrong',
8 | status,
9 | params: {},
10 | host: getHost(req),
11 | ...(typeof messageOrLocals === 'string' ? { message: messageOrLocals } : messageOrLocals)
12 | };
13 |
14 | if (res.logNotes.size === 0 && (logNote || locals.error || locals.message)) {
15 | res.logNotes.add(logNote || locals.error || locals.message);
16 | }
17 | res.logLevel = logLevel;
18 |
19 | res.render('error.html', locals);
20 | }
21 |
22 | module.exports = errorPage;
23 |
--------------------------------------------------------------------------------
/lib/util/getHost.js:
--------------------------------------------------------------------------------
1 | const isSecureRequest = require('./isSecureRequest');
2 |
3 | function getHost (req) {
4 | return req.get('x-forwarded-host') || req.get('host') || req.app.locals.host || '';
5 | }
6 |
7 | function getHostBaseUrl (req) {
8 | const scheme = (isSecureRequest(req) || req.app.get('forceSSL') || process.env.NODE_ENV === 'production') ? 'https' : 'http';
9 | return scheme + '://' + getHost(req) + req.app.locals.basePath;
10 | }
11 |
12 | module.exports = { getHost, getHostBaseUrl };
13 |
--------------------------------------------------------------------------------
/lib/util/getOriginator.js:
--------------------------------------------------------------------------------
1 | module.exports = function getOriginator (req) {
2 | try {
3 | if (req.headers.origin) {
4 | return req.headers.origin;
5 | } else if (req.headers.referer) {
6 | return new URL(req.headers.referer).origin;
7 | } else if (req.body?.redirect_uri) {
8 | return new URL(req.body.redirect_uri).origin;
9 | } else if (req.query?.redirect_uri) {
10 | return new URL(req.query.redirect_uri).origin;
11 | } else if (req.query.client_id) {
12 | return new URL(req.query.client_id).origin;
13 | } else {
14 | return '-';
15 | }
16 | } catch (err) {
17 | return '???';
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/lib/util/isSecureRequest.js:
--------------------------------------------------------------------------------
1 | module.exports = function isSecureRequest (req) {
2 | return req.secure ||
3 | req.get('x-forwarded-ssl') === 'on' ||
4 | req.get('x-forwarded-scheme') === 'https' ||
5 | req.get('x-forwarded-proto') === 'https';
6 | };
7 |
--------------------------------------------------------------------------------
/lib/util/loginOptsWCreds.js:
--------------------------------------------------------------------------------
1 | const { generateAuthenticationOptions } = require('@simplewebauthn/server');
2 |
3 | module.exports = async function loginOptsWCreds (username = undefined, user = undefined, accountMgr, rpID, logNotes) {
4 | let allowCredentials = []; // user selects from browser-generated list
5 | if (username && !user) {
6 | user = await accountMgr.getUser(username, logNotes);
7 | }
8 | if (user) {
9 | allowCredentials = Object.values(user.credentials || {}).map(
10 | cred => ({
11 | id: Buffer.from(cred.credentialID, 'base64url'),
12 | transports: cred.transports,
13 | type: cred.credentialType
14 | })
15 | );
16 | }
17 | return await generateAuthenticationOptions({
18 | rpID,
19 | allowCredentials,
20 | userVerification: 'preferred', // Typically will ask for biometric, but not password.
21 | timeout: 5 * 60 * 1000
22 | });
23 | };
24 |
--------------------------------------------------------------------------------
/lib/util/nameFromUseragent.js:
--------------------------------------------------------------------------------
1 | const parser = require('ua-parser-js');
2 |
3 | module.exports = function nameFromUseragent (authenticatorAttachment = '', useragent = '', transports = []) {
4 | let ua, nameParts;
5 | switch (authenticatorAttachment) {
6 | case 'platform':
7 | ua = parser(useragent);
8 | nameParts = [...(ua?.device?.vendor ? [ua?.device?.vendor] : []),
9 | ...(ua?.device?.model ? [ua?.device?.model] : []),
10 | ...(ua?.os?.name ? [ua?.os?.name] : []),
11 | ...(ua?.browser?.name ? [ua?.browser?.name] : [])];
12 | return nameParts.length > 0 ? nameParts.join(' ') : new Date().toLocaleString();
13 | case 'cross-platform':
14 | return `security key (${transports.join(', ')})`;
15 | default:
16 | return 'unknown';
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/lib/util/normalizeETag.js:
--------------------------------------------------------------------------------
1 | module.exports = function normalizeETag (ETag) {
2 | if (!/^("|W\/")/.test(ETag)) { // AWS is careless
3 | ETag = '"' + ETag;
4 | }
5 |
6 | if (!/"$/.test(ETag)) {
7 | ETag += '"';
8 | }
9 |
10 | return ETag.toLowerCase(); // OpenIO is careless
11 | };
12 |
--------------------------------------------------------------------------------
/lib/util/protocols.js:
--------------------------------------------------------------------------------
1 | const { format } = require('node:util');
2 | const ParameterError = require('./ParameterError');
3 |
4 | const protocols = {
5 | 'sgnl:': {
6 | name: 'Signal',
7 | contactTemplate: '%s//signal.me/#p/%s',
8 | contactHasHash: true
9 | },
10 | 'threema:': {
11 | name: 'Threema',
12 | contactTemplate: '%s//compose?id=%s',
13 | contactAllowedSearchKeys: ['id']
14 | },
15 | 'facetime:': {
16 | name: 'FaceTime',
17 | contactTemplate: '%s%s'
18 | },
19 | 'xmpp:': {
20 | name: 'Jabber',
21 | contactTemplate: '%s%s'
22 | },
23 | 'skype:': {
24 | name: 'Skype',
25 | contactTemplate: '%s%s?chat',
26 | contactRequiredSearchKeys: ['chat']
27 | },
28 | 'mailto:': {
29 | name: 'e-mail',
30 | contactTemplate: '%s%s'
31 | },
32 | 'sms:': {
33 | name: 'SMS',
34 | contactTemplate: '%s%s'
35 | },
36 | 'mms:': {
37 | name: 'MMS',
38 | actualProtocol: 'sms:',
39 | contactTemplate: '%s%s'
40 | },
41 | 'whatsapp:': {
42 | name: 'WhatsApp',
43 | contactTemplate: '%s//send/?phone=%s',
44 | contactAllowedSearchKeys: ['phone']
45 | },
46 | 'tg:': {
47 | name: 'Telegram',
48 | contactTemplate: '%s//resolve?domain=%s',
49 | contactTemplatePhone: '%s//resolve?phone=%s',
50 | contactAllowedSearchKeys: ['phone', 'domain']
51 | }
52 | };
53 |
54 | async function initProtocols (storeRouter) {
55 | // TODO: load configured set of protocols
56 | }
57 |
58 | function assembleContactURL (protocol, address) {
59 | address = address?.trim();
60 | if (!address) {
61 | throw new ParameterError('Missing address');
62 | }
63 |
64 | const protocolAttr = protocols[protocol];
65 | if (!protocolAttr) {
66 | throw new ParameterError(`Protocol “${protocol}” not supported`);
67 | }
68 | let { actualProtocol, contactTemplate, contactTemplatePhone, addressIsNeverPhone } = protocolAttr;
69 | if (!contactTemplate) {
70 | throw new Error(`No contactTemplate for protocol “${protocol}”`);
71 | }
72 | if (actualProtocol) {
73 | protocol = actualProtocol;
74 | }
75 |
76 | if (/^\+?[\d\s)(*x-]{4,20}$/.test(address) && !addressIsNeverPhone) {
77 | address = normalizePhoneNumber(address);
78 | if (contactTemplatePhone) {
79 | contactTemplate = contactTemplatePhone;
80 | }
81 | }
82 |
83 | const str = format(contactTemplate, protocol, address);
84 |
85 | return new URL(str);
86 | }
87 |
88 | function normalizePhoneNumber (raw) {
89 | const number = (raw.split('x')[0]).replace(/\D/g, '');
90 |
91 | if (raw.startsWith('+')) {
92 | return '+' + number;
93 | } else {
94 | if (number.length === 10 && !['0', '1'].includes(number[0])) { // matches North American pattern
95 | return '+1' + number;
96 | } else {
97 | return number;
98 | }
99 | }
100 | }
101 |
102 | function calcContactURL (contactStr) {
103 | contactStr = contactStr?.trim();
104 |
105 | let contactURL;
106 | try {
107 | contactURL = new URL(contactStr);
108 | } catch (err) {
109 | if (/[\p{L}\p{N}]@(?:[A-Z0-9-]+\.)+[A-Z]{2,6}\b/iu.test(contactStr)) {
110 | contactURL = new URL('mailto:' + contactStr);
111 | } else {
112 | throw new ParameterError(`“${contactStr}” is neither a URL nor email address`);
113 | }
114 | }
115 |
116 | const protocolAttr = protocols[contactURL.protocol];
117 | if (!protocolAttr) {
118 | throw new ParameterError(`Protocol “${contactURL.protocol}” not supported`);
119 | }
120 | const { actualProtocol, contactHasHash, contactAllowedSearchKeys = [], contactRequiredSearchKeys = [] } = protocolAttr;
121 |
122 | if (actualProtocol) {
123 | contactURL.protocol = actualProtocol;
124 | }
125 |
126 | const newSearchParams = new URLSearchParams();
127 | for (const key of contactRequiredSearchKeys) {
128 | newSearchParams.set(key, contactURL.searchParams.get(key) || '');
129 | }
130 | for (const [key, value] of contactURL.searchParams.entries()) {
131 | if (contactAllowedSearchKeys.includes(key)) {
132 | newSearchParams.set(key, value);
133 | }
134 | }
135 | newSearchParams.sort();
136 | contactURL.search = Array.from(newSearchParams.entries()).map(
137 | ([key, value]) => value ? key + '=' + value : key)
138 | .join('&');
139 |
140 | if (!contactHasHash) {
141 | contactURL.hash = '';
142 | }
143 |
144 | return contactURL;
145 | }
146 |
147 | function protocolOptions () {
148 | return Object.entries(protocols).map(
149 | entry =>
150 | ({ protocol: entry[0], name: entry[1].name })
151 | );
152 | }
153 |
154 | module.exports = { initProtocols, assembleContactURL, calcContactURL, protocolOptions };
155 |
--------------------------------------------------------------------------------
/lib/util/removeUserDataFromSession.js:
--------------------------------------------------------------------------------
1 | module.exports = function removeUserDataFromSession (session) {
2 | delete session?.privileges;
3 | delete session?.user;
4 | delete session?.regChallenge;
5 | delete session?.loginChallenge;
6 | delete session?.oauthParams;
7 | delete session?.isUserSynthetic;
8 | };
9 |
--------------------------------------------------------------------------------
/lib/util/replaceUint8.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 |
3 | module.exports = function replaceUint8 (key, value) {
4 | if (value instanceof Uint8Array) {
5 | return Buffer.from(value.buffer).toString('base64url');
6 | } else {
7 | return value;
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/lib/util/shorten.js:
--------------------------------------------------------------------------------
1 | function shorten (str, maxLength = 50) {
2 | if (typeof str !== 'string') {
3 | return '';
4 | }
5 | str = str.trim();
6 | if (str.length <= maxLength) {
7 | return str;
8 | } else {
9 | return str.slice(0, maxLength - 1) + '…';
10 | }
11 | }
12 |
13 | module.exports = shorten;
14 |
--------------------------------------------------------------------------------
/lib/util/timeoutError.js:
--------------------------------------------------------------------------------
1 | /** TODO: replace this with EndResponseError */
2 |
3 | module.exports = class TimeoutError extends Error {
4 | constructor () {
5 | super(...arguments);
6 | this.name = 'TimeoutError';
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/lib/util/updateSessionPrivileges.js:
--------------------------------------------------------------------------------
1 | module.exports = async function updateSessionPrivileges (req, user, isAdminLogin) {
2 | const oauthParams = req.session.oauthParams;
3 | // removes any privileges the user no longer has
4 | const oldPrivileges = {};
5 | for (const name of Object.keys(user.privileges)) {
6 | if (req.session.privileges?.[name]) {
7 | oldPrivileges[name] = true;
8 | }
9 | }
10 |
11 | // Privilege level has changed, so the session must be regenerated.
12 | await new Promise((resolve, reject) => {
13 | req.session.regenerate(err => { if (err) { reject(err); } else { resolve(); } });
14 | });
15 |
16 | const newPrivileges = { ...user.privileges };
17 | if (!isAdminLogin) {
18 | delete newPrivileges.ADMIN;
19 | delete newPrivileges.OWNER;
20 | }
21 |
22 | req.session.privileges = { ...oldPrivileges, ...newPrivileges };
23 | req.session.oauthParams = oauthParams;
24 | };
25 |
--------------------------------------------------------------------------------
/lib/util/validations.js:
--------------------------------------------------------------------------------
1 | /** each streaming store decides what user names are valid, and may use its own password validation if desired. */
2 |
3 | module.exports = {
4 | EMAIL_PATTERN: /[\p{L}\p{N}]@[a-zA-Z0-9][a-zA-Z0-9.]/u,
5 | EMAIL_ERROR: 'Email must contain an alphanumeric, followed by an @-sign, followed by two ASCII alphanumerics',
6 | PASSWORD_PATTERN: /\S{8,}/,
7 | PASSWORD_ERROR: 'Password must contain at least 8 non-space characters'
8 | };
9 |
--------------------------------------------------------------------------------
/lib/util/verifyCredential.js:
--------------------------------------------------------------------------------
1 | const { verifyAuthenticationResponse } = require('@simplewebauthn/server');
2 |
3 | /**
4 | * Returns authenticationInfo or throws error if not valid
5 | * @param user
6 | * @param expectedChallenge
7 | * @param expectedOrigin
8 | * @param expectedRPID
9 | * @param presentedCredential
10 | * @returns {Promise