From 015f8eeced5192e289caa1edce4b1d7a455c23b1 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 31 Oct 2024 16:59:43 -0500 Subject: [PATCH] Prepare first version of the server (#30) This work was created in https://github.com/digidem/comapeo-core/pull/886 and moved to this repo in this commit. Co-Authored-By: Gregor MacLennan --- .dockerignore | 16 ++ .gitignore | 4 +- Dockerfile | 23 ++ README.md | 41 +++ eslint.config.js | 1 - fly.toml | 36 +++ package-lock.json | 354 +++++++++++++++++++++++-- package.json | 36 ++- src/allowed-hosts-plugin.js | 23 ++ src/app.js | 27 +- src/base-url-plugin.js | 19 ++ src/bin/mapeo-server.js | 19 -- src/comapeo-plugin.js | 4 +- src/routes.js | 383 +++++++++++++++++++++++++++- src/server.js | 142 ++++++++--- src/types/types.ts | 3 + src/ws-core-replicator.js | 49 ++++ test/add-project-endpoint.js | 225 ++++++++++++++++ test/allowed-hosts.js | 29 +++ test/fixtures/audio.mp3 | Bin 0 -> 17181 bytes test/fixtures/original.jpg | Bin 0 -> 16308 bytes test/fixtures/preview.jpg | Bin 0 -> 8461 bytes test/fixtures/thumbnail.jpg | Bin 0 -> 1661 bytes test/list-projects-endpoint.js | 75 ++++++ test/observations-endpoint.js | 290 +++++++++++++++++++++ test/sync-endpoint.js | 55 ++++ test/test-helpers.js | 17 +- tsconfig.json => tsconfig.base.json | 3 +- tsconfig.build.json | 9 + tsconfig.dev.json | 7 + 30 files changed, 1794 insertions(+), 96 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 fly.toml create mode 100644 src/allowed-hosts-plugin.js create mode 100644 src/base-url-plugin.js delete mode 100755 src/bin/mapeo-server.js create mode 100644 src/ws-core-replicator.js create mode 100644 test/add-project-endpoint.js create mode 100644 test/allowed-hosts.js create mode 100644 test/fixtures/audio.mp3 create mode 100644 test/fixtures/original.jpg create mode 100644 test/fixtures/preview.jpg create mode 100644 test/fixtures/thumbnail.jpg create mode 100644 test/list-projects-endpoint.js create mode 100644 test/observations-endpoint.js create mode 100644 test/sync-endpoint.js rename tsconfig.json => tsconfig.base.json (91%) create mode 100644 tsconfig.build.json create mode 100644 tsconfig.dev.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5256b32 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# flyctl launch added from .gitignore +**/.DS_Store +**/node_modules +**/coverage +**/.tmp +**/tmp +**/proto/build +dist +!drizzle/**/*.sql +**/.eslintcache +**/docs/api/html/* +**/test/fixtures/config/*.zip + +# flyctl launch added from .husky/_/.gitignore +.husky/_/**/* +fly.toml diff --git a/.gitignore b/.gitignore index 308a9b2..c40d2d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /node_modules/ -/*.tsbuildinfo \ No newline at end of file +/*.tsbuildinfo + +/dist/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..228a8bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Best practices from https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/ + +ARG NODE_VERSION=20.17.0 + +# --------------> The build image__ +FROM node:${NODE_VERSION} AS build +RUN apt-get update && apt-get install -y --no-install-recommends dumb-init +WORKDIR /usr/src/app +COPY package*.json /usr/src/app/ +RUN npm ci --omit=dev + +# --------------> The production image__ +FROM node:${NODE_VERSION}-bullseye-slim + +ENV NODE_ENV production +ENV PORT 8080 +EXPOSE 8080 +COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init +USER node +WORKDIR /usr/src/app +COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules +COPY --chown=node:node . /usr/src/app +CMD ["dumb-init", "npm", "start"] diff --git a/README.md b/README.md index ded9ce3..d2de68b 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ # @comapeo/cloud + +A self-hosted cloud server for CoMapeo. + +## Deploying CoMapeo Cloud + +CoMapeo Cloud comes with a [`Dockerfile`](./Dockerfile) that can be used to build a Docker image. This image can be used to deploy CoMapeo Cloud on a server. + +Server configuration is done using environment variables. The following environment variables are available: + +| Environment Variable | Required | Description | Default Value | +| --------------------- | -------- | -------------------------------------------------------------------- | ---------------- | +| `SERVER_BEARER_TOKEN` | Yes | Token for authenticating API requests. Should be large random string | | +| `PORT` | No | Port on which the server runs | `8080` | +| `SERVER_NAME` | No | Friendly server name, seen by users when adding server | `CoMapeo Server` | +| `ALLOWED_PROJECTS` | No | Number of projects allowed to register with the server | `1` | +| `STORAGE_DIR` | No | Path for storing app & project data | `$CWD/data` | + +### Deploying with fly.io + +CoMapeo Cloud can be deployed on [fly.io](https://fly.io) using the following steps: + +1. Install the flyctl CLI tool by following the instructions [here](https://fly.io/docs/getting-started/installing-flyctl/). +2. Create a new app on fly.io by running `flyctl apps create`, take a note of the app name. +3. Set the SERVER_BEARER_TOKEN secret via: + ```sh + flyctl secrets set SERVER_BEARER_TOKEN= --app + ``` +4. Deploy the app by running (optionally setting the `ALLOWED_PROJECTS` environment variable): + ```sh + flyctl deploy --app -e ALLOWED_PROJECTS=10 + ``` +5. The app should now be running on fly.io. You can access it at `https://.fly.dev`. + +To destroy the app (delete all data and project invites), run: + +> [!WARNING] +> This action is irreversible and will permanently delete all data associated with the app, and projects that have already added the server will no longer be able to sync with it. + +```sh +flyctl destroy --app +``` diff --git a/eslint.config.js b/eslint.config.js index 5256c24..498234b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -83,7 +83,6 @@ export default [ 'prefer-spread': 'error', radix: 'error', 'require-atomic-updates': 'error', - 'require-await': 'error', 'require-unicode-regexp': 'error', strict: 'error', 'symbol-description': 'error', diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..a2e0489 --- /dev/null +++ b/fly.toml @@ -0,0 +1,36 @@ +# fly.toml app configuration file generated for comapeo-cloud on 2024-10-07T20:59:21+01:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'comapeo-cloud' +primary_region = 'iad' + +[env] + STORAGE_DIR = '/data' + +[build] + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'suspend' + auto_start_machines = true + min_machines_running = 0 + max_machines_running = 1 + processes = ['app'] + +[[http_service.checks]] + grace_period = "10s" + interval = "30s" + method = "GET" + timeout = "5s" + path = "/healthcheck" + +[[vm]] + size = 'shared-cpu-1x' + +[mounts] + source = "myapp_data" + destination = "/data" + snapshot_retention = 14 diff --git a/package-lock.json b/package-lock.json index 05748e7..7c50346 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,25 +9,35 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@comapeo/core": "^2.0.1", + "@comapeo/core": "^2.1.0", "@fastify/sensible": "^5.6.0", + "@fastify/websocket": "^10.0.1", + "@mapeo/crypto": "^1.0.0-alpha.10", "@sinclair/typebox": "^0.33.17", - "fastify": "^4.28.1" + "env-schema": "^6.0.0", + "fastify": "^4.28.1", + "string-timing-safe-equal": "^0.1.0", + "ws": "^8.18.0" }, "bin": { "mapeo-server": "src/bin/mapeo-server.js" }, "devDependencies": { + "@comapeo/schema": "^1.2.0", "@eslint/js": "^9.13.0", + "@mapeo/mock-data": "^2.1.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/node": "^22.8.4", + "@types/ws": "^8.5.12", "eslint": "^9.13.0", "globals": "^15.11.0", "husky": "^9.1.6", + "iterpal": "^0.4.0", "lint-staged": "^15.2.10", "npm-run-all": "^4.1.5", "prettier": "^3.3.3", "random-access-memory": "^6.2.1", + "streamx": "^2.20.1", "typescript": "^5.6.3" } }, @@ -419,13 +429,12 @@ } }, "node_modules/@comapeo/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-2.0.1.tgz", - "integrity": "sha512-6ZUb5umzitYKZx28bYFMxQfpE2B6pdC2ukCd9rcM1l+EgNUAcL+1NWeo/YRlXPnbzhltZ7kr0LvmZtXoMb/ing==", - "license": "MIT", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-2.1.0.tgz", + "integrity": "sha512-Fvi/EO1RJIQfpmKFUs4QApM2TsV8JrKw3HbNZ3hmlXiPl1oVVvIce0KkfdPJOoYHbEznTc9dIN1A2vkaoi431A==", "dependencies": { "@comapeo/fallback-smp": "^1.0.0", - "@comapeo/schema": "1.0.0", + "@comapeo/schema": "1.2.0", "@digidem/types": "^2.3.0", "@fastify/error": "^3.4.1", "@fastify/type-provider-typebox": "^4.1.0", @@ -443,7 +452,7 @@ "debug": "^4.3.4", "dot-prop": "^9.0.0", "drizzle-orm": "^0.30.8", - "fastify": ">= 4", + "fastify": "^4.0.0", "fastify-plugin": "^4.5.1", "hyperblobs": "2.3.0", "hypercore": "10.17.0", @@ -453,7 +462,7 @@ "magic-bytes.js": "^1.10.0", "map-obj": "^5.0.2", "mime": "^4.0.3", - "multi-core-indexer": "^1.0.0-alpha.10", + "multi-core-indexer": "^1.0.0", "p-defer": "^4.0.0", "p-event": "^6.0.1", "p-timeout": "^6.1.2", @@ -463,6 +472,7 @@ "sodium-universal": "^4.0.0", "start-stop-state-machine": "^1.2.0", "streamx": "^2.19.0", + "string-timing-safe-equal": "^0.1.0", "styled-map-package": "^2.0.0", "sub-encoder": "^2.1.1", "throttle-debounce": "^5.0.0", @@ -470,6 +480,7 @@ "type-fest": "^4.5.0", "undici": "^6.13.0", "varint": "^6.0.0", + "ws": "^8.18.0", "yauzl-promise": "^4.0.0" } }, @@ -541,12 +552,21 @@ "integrity": "sha512-6wLTtBOdlwtYMyrynBq6ZQ7S1aVABXQSwR/1QENkFkc7WyLLs4wLd9ny7WfSUQdHn6E2zfvA7WfKH7R06Zy3gQ==", "license": "MIT" }, + "node_modules/@comapeo/geometry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@comapeo/geometry/-/geometry-1.0.2.tgz", + "integrity": "sha512-q6zadJA3lr85GZPTZ+lol9F6ERRq2Rt4upON7HhcwPPBiCLN696SY03OJZCE6xkXHxjJY98FF5DxVX3W0IftLQ==", + "dependencies": { + "protobufjs": "^7.4.0" + } + }, "node_modules/@comapeo/schema": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@comapeo/schema/-/schema-1.0.0.tgz", - "integrity": "sha512-dK227I+0yg9D2y5/O5NGywx50tgeNYyUkl1uYnSmNAPlbv+r2KX9aaC9m4dEjIja2aR2VFnYn6z537ERZiahqQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@comapeo/schema/-/schema-1.2.0.tgz", + "integrity": "sha512-LWrUSqtXmrEmE/B9V/zffKBbJmMo37AlvjXczvGx1+BbCAjOYCPDX6GCtnSKNsvtnNS2KQZDm9apg3mp92tFGA==", "license": "MIT", "dependencies": { + "@comapeo/geometry": "^1.0.2", "compact-encoding": "^2.12.0", "protobufjs": "^7.2.5", "type-fest": "^4.26.0" @@ -732,6 +752,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -901,6 +938,17 @@ "@sinclair/typebox": ">=0.26 <=0.33" } }, + "node_modules/@fastify/websocket": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-10.0.1.tgz", + "integrity": "sha512-8/pQIxTPRD8U94aILTeJ+2O3el/r19+Ej5z1O1mXlqplsUH7KzCjAI0sgd5DM/NoPjAi5qLFNIjgM5+9/rGSNw==", + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.2", + "fastify-plugin": "^4.0.0", + "ws": "^8.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1101,6 +1149,7 @@ "version": "1.0.0-alpha.10", "resolved": "https://registry.npmjs.org/@mapeo/crypto/-/crypto-1.0.0-alpha.10.tgz", "integrity": "sha512-TEK8HN1W0XZOOADIMxa4saXtqAZKyBDeVVn3RBCcPaCiOGHeYy43/0rMnBVTbXZCLsLVPnOXwv6vg+vUkasrWQ==", + "license": "ISC", "dependencies": { "@types/b4a": "^1.6.0", "b4a": "^1.6.4", @@ -1115,6 +1164,26 @@ "z32": "^1.0.0" } }, + "node_modules/@mapeo/mock-data": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@mapeo/mock-data/-/mock-data-2.1.1.tgz", + "integrity": "sha512-BBR10Dk+eqlm+r75gj/kvMNFSyAuueT2zrxOvMw/Yj9lVxFF4E1077oKzGDu0JD/4bbthBdQYTDT7Xl463zxNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@faker-js/faker": "^8.3.1", + "dereference-json-schema": "^0.2.1", + "json-schema-faker": "^0.5.3", + "type-fest": "^4.8.0" + }, + "bin": { + "generate-mapeo-data": "bin/generate-mapeo-data.js", + "list-mapeo-schemas": "bin/list-mapeo-schemas.js" + }, + "peerDependencies": { + "@comapeo/schema": "^1.1.1" + } + }, "node_modules/@maplibre/maplibre-gl-style-spec": { "version": "20.4.0", "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", @@ -1595,6 +1664,16 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2236,6 +2315,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2778,6 +2864,13 @@ "node": ">= 0.8" } }, + "node_modules/dereference-json-schema": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/dereference-json-schema/-/dereference-json-schema-0.2.1.tgz", + "integrity": "sha512-uzJsrg225owJyRQ8FNTPHIuBOdSzIZlHhss9u6W8mp7jJldHqGuLv9cULagP/E26QVJDnjtG8U7Dw139mM1ydA==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -2786,6 +2879,27 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/drizzle-orm": { "version": "0.30.10", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.30.10.tgz", @@ -2895,6 +3009,32 @@ } } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2913,6 +3053,45 @@ "once": "^1.4.0" } }, + "node_modules/env-schema": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/env-schema/-/env-schema-6.0.0.tgz", + "integrity": "sha512-/IHp1EmrfubUOfF1wfe8koDWM5/dxUDylHANPNrPyrsYWJ7KRiB8gXbjtqQBujmOhpSpXXOhhnaL+meb+MaGtA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "dotenv": "^16.4.5", + "dotenv-expand": "10.0.0" + } + }, + "node_modules/env-schema/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/env-schema/node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "license": "BSD-3-Clause" + }, + "node_modules/env-schema/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -3191,6 +3370,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", @@ -3592,6 +3785,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/format-util": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz", + "integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==", + "dev": true, + "license": "MIT" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4513,6 +4713,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/iterpal": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/iterpal/-/iterpal-0.4.0.tgz", + "integrity": "sha512-Z/KMdj4T9D9n5FAkY0Y4j46f875PeRddgLrEZh77SBQ6WERvViDNh6pjp6+oA5q30Z+2ShbvVrnJXMTN4JSp2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/jackspeak": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", @@ -4584,6 +4794,57 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-faker": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/json-schema-faker/-/json-schema-faker-0.5.6.tgz", + "integrity": "sha512-u/cFC26/GDxh2vPiAC8B8xVvpXAW+QYtG2mijEbKrimCk8IHtiwQBjCE8TwvowdhALWq9IcdIWZ+/8ocXvdL3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-ref-parser": "^6.1.0", + "jsonpath-plus": "^7.2.0" + }, + "bin": { + "jsf": "bin/gen.cjs" + } + }, + "node_modules/json-schema-ref-parser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-6.1.0.tgz", + "integrity": "sha512-pXe9H1m6IgIpXmE5JSb8epilNTGsmTb2iPohAXpOdhqGFbQjNeHHsZxU+C8w6T81GZxSPFLeUoqDJmzxx5IGuw==", + "deprecated": "Please switch to @apidevtools/json-schema-ref-parser", + "dev": true, + "license": "MIT", + "dependencies": { + "call-me-maybe": "^1.0.1", + "js-yaml": "^3.12.1", + "ono": "^4.0.11" + } + }, + "node_modules/json-schema-ref-parser/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/json-schema-ref-parser/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-schema-ref-resolver": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", @@ -4638,6 +4899,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/jsonpath-plus": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", + "integrity": "sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5693,6 +5964,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ono": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/ono/-/ono-4.0.11.tgz", + "integrity": "sha512-jQ31cORBFE6td25deYeD80wxKBMj+zBmHTrVxnc6CKhx8gho6ipmWM5zj/oeoqioZ99yqBls9Z/9Nss7J26G2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "format-util": "^1.0.3" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6180,9 +6461,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.3.2.tgz", - "integrity": "sha512-RXyHaACeqXeqAKGLDl68rQKbmObRsTIn4TYVUUug1KfS47YWCo5MacGITEryugIgZqORCvJWEk4l449POg5Txg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", "hasInstallScript": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", @@ -7016,6 +7297,13 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/start-stop-state-machine": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/start-stop-state-machine/-/start-stop-state-machine-1.2.0.tgz", @@ -7044,6 +7332,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/streamx": { "version": "2.20.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz", @@ -7076,6 +7370,15 @@ "node": ">=0.6.19" } }, + "node_modules/string-timing-safe-equal": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/string-timing-safe-equal/-/string-timing-safe-equal-0.1.0.tgz", + "integrity": "sha512-AMhfQVC+que87xh7nAW2ShSDK6E3CYFC3zGieewF7OHBW9vGPCatMuVPzZ9afoIOT5q6ldKPOKeuQf9hVZlvhw==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -7961,6 +8264,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xache": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/xache/-/xache-1.2.1.tgz", diff --git a/package.json b/package.json index e7e56a6..9400939 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,31 @@ "mapeo-server": "src/bin/mapeo-server.js" }, "type": "module", + "files": [ + "Dockerfile", + "fly.toml", + "src/**/*.js", + "dist/**/*.d.ts", + "comapeo-core-2.0.1.tgz" + ], + "exports": { + "types": "./dist/app.d.ts", + "import": "./src/app.js" + }, "scripts": { + "start": "node src/server.js", + "build:clean": "rm -rf dist", + "build:typescript": "tsc --project ./tsconfig.build.json", + "build": "npm-run-all --serial build:clean build:typescript", "format": "prettier --write .", "test:prettier": "prettier --check .", "test:eslint": "eslint .", - "test:typescript": "tsc", + "test:typescript": "tsc --project ./tsconfig.dev.json", "test:node": "node --test", "test": "npm-run-all --aggregate-output --print-label --parallel test:*", - "watch:typescript": "tsc --watch", - "prepare": "husky" + "watch:typescript": "tsc --watch --project ./tsconfig.dev.json", + "prepare": "husky || true", + "prepack": "npm run build" }, "repository": { "type": "git", @@ -28,22 +44,32 @@ }, "homepage": "https://github.com/digidem/comapeo-cloud#readme", "devDependencies": { + "@comapeo/schema": "^1.2.0", "@eslint/js": "^9.13.0", + "@mapeo/mock-data": "^2.1.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/node": "^22.8.4", + "@types/ws": "^8.5.12", "eslint": "^9.13.0", "globals": "^15.11.0", "husky": "^9.1.6", + "iterpal": "^0.4.0", "lint-staged": "^15.2.10", "npm-run-all": "^4.1.5", "prettier": "^3.3.3", "random-access-memory": "^6.2.1", + "streamx": "^2.20.1", "typescript": "^5.6.3" }, "dependencies": { - "@comapeo/core": "^2.0.1", + "@comapeo/core": "^2.1.0", "@fastify/sensible": "^5.6.0", + "@fastify/websocket": "^10.0.1", + "@mapeo/crypto": "^1.0.0-alpha.10", "@sinclair/typebox": "^0.33.17", - "fastify": "^4.28.1" + "env-schema": "^6.0.0", + "fastify": "^4.28.1", + "string-timing-safe-equal": "^0.1.0", + "ws": "^8.18.0" } } diff --git a/src/allowed-hosts-plugin.js b/src/allowed-hosts-plugin.js new file mode 100644 index 0000000..f4d0246 --- /dev/null +++ b/src/allowed-hosts-plugin.js @@ -0,0 +1,23 @@ +import createFastifyPlugin from 'fastify-plugin' + +/** @import { FastifyPluginAsync } from 'fastify' */ + +/** + * @internal + * @typedef {object} AllowedHostsPluginOptions + * @property {undefined | string[]} [allowedHosts] + */ + +/** @type {FastifyPluginAsync} */ +const comapeoPlugin = async (fastify, { allowedHosts }) => { + if (!allowedHosts) return + + const allowedHostsSet = new Set(allowedHosts) + fastify.addHook('onRequest', async (req) => { + if (!allowedHostsSet.has(req.hostname)) { + throw fastify.httpErrors.forbidden('Forbidden') + } + }) +} + +export default createFastifyPlugin(comapeoPlugin, { name: 'allowedHosts' }) diff --git a/src/app.js b/src/app.js index be98141..f3b0771 100644 --- a/src/app.js +++ b/src/app.js @@ -1,6 +1,9 @@ import fastifySensible from '@fastify/sensible' +import fastifyWebsocket from '@fastify/websocket' import createFastifyPlugin from 'fastify-plugin' +import allowedHostsPlugin from './allowed-hosts-plugin.js' +import baseUrlPlugin from './base-url-plugin.js' import comapeoPlugin from './comapeo-plugin.js' import routes from './routes.js' @@ -9,22 +12,36 @@ import routes from './routes.js' /** @import { RouteOptions } from './routes.js' */ /** - * @typedef {ComapeoPluginOptions & RouteOptions} ServerOptions + * @internal + * @typedef {object} OtherServerOptions + * @prop {string[]} [allowedHosts] + */ + +/** + * @typedef {ComapeoPluginOptions & OtherServerOptions & RouteOptions} ServerOptions */ /** @type {FastifyPluginAsync} */ -function comapeoServer( +async function comapeoServer( fastify, - { serverBearerToken, serverName, ...comapeoPluginOpts }, + { + serverBearerToken, + serverName, + allowedHosts, + allowedProjects, + ...comapeoPluginOpts + }, ) { + fastify.register(fastifyWebsocket) fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) + fastify.register(allowedHostsPlugin, { allowedHosts }) + fastify.register(baseUrlPlugin) fastify.register(comapeoPlugin, comapeoPluginOpts) fastify.register(routes, { serverBearerToken, serverName, + allowedProjects, }) - - return Promise.resolve() } export default createFastifyPlugin(comapeoServer, { diff --git a/src/base-url-plugin.js b/src/base-url-plugin.js new file mode 100644 index 0000000..305b3ba --- /dev/null +++ b/src/base-url-plugin.js @@ -0,0 +1,19 @@ +import createFastifyPlugin from 'fastify-plugin' + +/** @import { FastifyInstance, FastifyPluginAsync } from 'fastify' */ + +/** @type {FastifyPluginAsync} */ +const baseUrlPlugin = async (fastify) => { + fastify.decorateRequest('baseUrl', null) + fastify.addHook( + 'onRequest', + /** + * @this {FastifyInstance} req + */ + async function (req) { + req.baseUrl = new URL(this.prefix, `${req.protocol}://${req.hostname}`) + }, + ) +} + +export default createFastifyPlugin(baseUrlPlugin, { name: 'baseUrl' }) diff --git a/src/bin/mapeo-server.js b/src/bin/mapeo-server.js deleted file mode 100755 index 9a9dd04..0000000 --- a/src/bin/mapeo-server.js +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -// @ts-check -import process from 'node:process' - -import { Server } from '../server.js' - -const port = 3000 - -const server = new Server() - -await server.listen({ port }) - -/** @param {NodeJS.Signals} signal*/ -async function closeGracefully(signal) { - await server.close() - process.kill(process.pid, signal) -} -process.once('SIGINT', closeGracefully) -process.once('SIGTERM', closeGracefully) diff --git a/src/comapeo-plugin.js b/src/comapeo-plugin.js index 3e8d074..f24dd21 100644 --- a/src/comapeo-plugin.js +++ b/src/comapeo-plugin.js @@ -8,11 +8,9 @@ import createFastifyPlugin from 'fastify-plugin' */ /** @type {FastifyPluginAsync} */ -const comapeoPlugin = (fastify, opts) => { +const comapeoPlugin = async (fastify, opts) => { const comapeo = new MapeoManager({ ...opts, fastify }) fastify.decorate('comapeo', comapeo) - - return Promise.resolve() } export default createFastifyPlugin(comapeoPlugin, { name: 'comapeo' }) diff --git a/src/routes.js b/src/routes.js index 64019cd..603563c 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,21 +1,51 @@ +import { replicateProject } from '@comapeo/core' +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' import { Type } from '@sinclair/typebox' +import timingSafeEqual from 'string-timing-safe-equal' +import assert from 'node:assert/strict' import * as fs from 'node:fs' -/** @import { FastifyInstance, FastifyPluginAsync, RawServerDefault } from 'fastify' */ +import { wsCoreReplicator } from './ws-core-replicator.js' + +/** @import { FastifyInstance, FastifyPluginAsync, FastifyRequest, RawServerDefault } from 'fastify' */ /** @import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' */ +const BEARER_SPACE_LENGTH = 'Bearer '.length + +const HEX_REGEX_32_BYTES = '^[0-9a-fA-F]{64}$' +const HEX_STRING_32_BYTES = Type.String({ pattern: HEX_REGEX_32_BYTES }) +const BASE32_REGEX_32_BYTES = '^[0-9A-Za-z]{52}$' +const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES }) + const INDEX_HTML_PATH = new URL('./static/index.html', import.meta.url) /** * @typedef {object} RouteOptions * @prop {string} serverBearerToken * @prop {string} serverName - * @prop {string[] | number} [allowedProjects=1] + * @prop {undefined | number | string[]} [allowedProjects=1] */ /** @type {FastifyPluginAsync} */ -export default function routes(fastify, { serverName }) { +export default async function routes( + fastify, + { serverBearerToken, serverName, allowedProjects = 1 }, +) { + /** @type {Set | number} */ + const allowedProjectsSetOrNumber = Array.isArray(allowedProjects) + ? new Set(allowedProjects) + : allowedProjects + + /** + * @param {FastifyRequest} req + */ + const verifyBearerAuth = (req) => { + if (!isBearerTokenValid(req.headers.authorization, serverBearerToken)) { + throw fastify.httpErrors.forbidden('Invalid bearer token') + } + } + fastify.get('/', (_req, reply) => { const stream = fs.createReadStream(INDEX_HTML_PATH) reply.header('Content-Type', 'text/html') @@ -48,5 +78,350 @@ export default function routes(fastify, { serverName }) { }, ) - return Promise.resolve() + fastify.get( + '/projects', + { + schema: { + response: { + 200: Type.Object({ + data: Type.Array( + Type.Object({ + projectId: Type.String(), + name: Type.String(), + }), + ), + }), + 403: { $ref: 'HttpError' }, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + }, + }, + /** + * @this {FastifyInstance} + */ + async function () { + const projects = await this.comapeo.listProjects() + return { + data: projects.map((project) => ({ + projectId: project.projectId, + name: project.name, + })), + } + }, + ) + + fastify.put( + '/projects', + { + schema: { + body: Type.Object({ + projectName: Type.String({ minLength: 1 }), + projectKey: HEX_STRING_32_BYTES, + encryptionKeys: Type.Object({ + auth: HEX_STRING_32_BYTES, + config: HEX_STRING_32_BYTES, + data: HEX_STRING_32_BYTES, + blobIndex: HEX_STRING_32_BYTES, + blob: HEX_STRING_32_BYTES, + }), + }), + response: { + 200: Type.Object({ + data: Type.Object({ + deviceId: HEX_STRING_32_BYTES, + }), + }), + 400: { $ref: 'HttpError' }, + }, + }, + }, + /** + * @this {FastifyInstance} + */ + async function (req) { + const { projectName } = req.body + const projectKey = Buffer.from(req.body.projectKey, 'hex') + const projectPublicId = projectKeyToPublicId(projectKey) + + const existingProjects = await this.comapeo.listProjects() + + // This assumes that two projects with the same project key are equivalent, + // and that we don't need to add more. Theoretically, someone could add + // project with ID 1 and keys A, then add project with ID 1 and keys B. + // This would mean a malicious/buggy client, which could cause errors if + // trying to sync with this server--that seems acceptable. + const alreadyHasThisProject = existingProjects.some((p) => + // We don't want people to be able to enumerate the project keys that + // this server has. + timingSafeEqual(p.projectId, projectPublicId), + ) + + if (!alreadyHasThisProject) { + if ( + allowedProjectsSetOrNumber instanceof Set && + !allowedProjectsSetOrNumber.has(projectPublicId) + ) { + throw fastify.httpErrors.forbidden('Project not allowed') + } + + if ( + typeof allowedProjectsSetOrNumber === 'number' && + existingProjects.length >= allowedProjectsSetOrNumber + ) { + throw fastify.httpErrors.forbidden( + 'Server is already linked to the maximum number of projects', + ) + } + } + + const baseUrl = req.baseUrl.toString() + + const existingDeviceInfo = this.comapeo.getDeviceInfo() + // We don't set device info until this point. We trust that `req.hostname` + // is the hostname we want clients to use to sync to the server. + if ( + existingDeviceInfo.deviceType === 'device_type_unspecified' || + existingDeviceInfo.selfHostedServerDetails?.baseUrl !== baseUrl + ) { + await this.comapeo.setDeviceInfo({ + deviceType: 'selfHostedServer', + name: serverName, + selfHostedServerDetails: { baseUrl }, + }) + } + + if (!alreadyHasThisProject) { + const projectId = await this.comapeo.addProject( + { + projectKey, + projectName, + encryptionKeys: { + auth: Buffer.from(req.body.encryptionKeys.auth, 'hex'), + config: Buffer.from(req.body.encryptionKeys.config, 'hex'), + data: Buffer.from(req.body.encryptionKeys.data, 'hex'), + blobIndex: Buffer.from(req.body.encryptionKeys.blobIndex, 'hex'), + blob: Buffer.from(req.body.encryptionKeys.blob, 'hex'), + }, + }, + { waitForSync: false }, + ) + assert.equal( + projectId, + projectPublicId, + 'adding a project should return the same ID as what was passed', + ) + } + + const project = await this.comapeo.getProject(projectPublicId) + project.$sync.start() + + return { + data: { + deviceId: this.comapeo.deviceId, + }, + } + }, + ) + + fastify.get( + '/sync/:projectPublicId', + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + }), + response: { + 404: { $ref: 'HttpError' }, + }, + }, + async preHandler(req) { + await ensureProjectExists(this, req) + }, + websocket: true, + }, + /** + * @this {FastifyInstance} + */ + async function (socket, req) { + // The preValidation hook ensures that the project exists + const project = await this.comapeo.getProject(req.params.projectPublicId) + const replicationStream = replicateProject(project, false) + wsCoreReplicator(socket, replicationStream) + project.$sync.start() + }, + ) + + fastify.get( + '/projects/:projectPublicId/observations', + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + }), + response: { + 200: Type.Object({ + data: Type.Array( + Type.Object({ + docId: Type.String(), + createdAt: Type.String(), + updatedAt: Type.String(), + deleted: Type.Boolean(), + lat: Type.Optional(Type.Number()), + lon: Type.Optional(Type.Number()), + attachments: Type.Array( + Type.Object({ + url: Type.String(), + }), + ), + tags: Type.Record( + Type.String(), + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + Type.Array( + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + ]), + ), + ]), + ), + }), + ), + }), + 403: { $ref: 'HttpError' }, + 404: { $ref: 'HttpError' }, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + await ensureProjectExists(this, req) + }, + }, + /** + * @this {FastifyInstance} + */ + async function (req) { + const { projectPublicId } = req.params + const project = await this.comapeo.getProject(projectPublicId) + + return { + data: (await project.observation.getMany({ includeDeleted: true })).map( + (obs) => ({ + docId: obs.docId, + createdAt: obs.createdAt, + updatedAt: obs.updatedAt, + deleted: obs.deleted, + lat: obs.lat, + lon: obs.lon, + attachments: obs.attachments + // TODO: For now, only photos are supported. + // See . + .filter((attachment) => attachment.type === 'photo') + .map((attachment) => ({ + url: new URL( + `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, + req.baseUrl, + ).href, + })), + tags: obs.tags, + }), + ), + } + }, + ) + + fastify.get( + '/projects/:projectPublicId/attachments/:driveDiscoveryId/:type/:name', + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + driveDiscoveryId: Type.String(), + // TODO: For now, only photos are supported. + // See . + type: Type.Literal('photo'), + name: Type.String(), + }), + querystring: Type.Object({ + variant: Type.Optional( + Type.Union([ + Type.Literal('original'), + Type.Literal('preview'), + Type.Literal('thumbnail'), + ]), + ), + }), + response: { + 403: { $ref: 'HttpError' }, + 404: { $ref: 'HttpError' }, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + await ensureProjectExists(this, req) + }, + }, + /** + * @this {FastifyInstance} + */ + async function (req, reply) { + const project = await this.comapeo.getProject(req.params.projectPublicId) + + const blobUrl = await project.$blobs.getUrl({ + driveId: req.params.driveDiscoveryId, + name: req.params.name, + type: req.params.type, + variant: req.query.variant || 'original', + }) + + const proxiedResponse = await fetch(blobUrl) + reply.code(proxiedResponse.status) + for (const [headerName, headerValue] of proxiedResponse.headers) { + reply.header(headerName, headerValue) + } + return reply.send(proxiedResponse.body) + }, + ) +} + +/** + * @param {undefined | string} headerValue + * @param {string} expectedBearerToken + * @returns {boolean} + */ +function isBearerTokenValid(headerValue = '', expectedBearerToken) { + // This check is not strictly required for correctness, but helps protect + // against long values. + const expectedLength = BEARER_SPACE_LENGTH + expectedBearerToken.length + if (headerValue.length !== expectedLength) return false + + if (!headerValue.startsWith('Bearer ')) return false + const actualBearerToken = headerValue.slice(BEARER_SPACE_LENGTH) + + return timingSafeEqual(actualBearerToken, expectedBearerToken) +} + +/** + * @param {FastifyInstance} fastify + * @param {object} req + * @param {object} req.params + * @param {string} req.params.projectPublicId + * @returns {Promise} + */ +async function ensureProjectExists(fastify, req) { + try { + await fastify.comapeo.getProject(req.params.projectPublicId) + } catch (e) { + if (e instanceof Error && e.message.startsWith('NotFound')) { + throw fastify.httpErrors.notFound('Project not found') + } + throw e + } } diff --git a/src/server.js b/src/server.js index e75afeb..73a2f73 100644 --- a/src/server.js +++ b/src/server.js @@ -1,49 +1,111 @@ -import { MapeoManager } from '@comapeo/core' -import Fastify from 'fastify' -import RAM from 'random-access-memory' +import { Type } from '@sinclair/typebox' +import envSchema from 'env-schema' +import createFastify from 'fastify' -import * as path from 'node:path' +import crypto from 'node:crypto' +import fsPromises from 'node:fs/promises' +import path from 'node:path' -const migrationsFolderPath = new URL( - '../node_modules/@mapeo/core/drizzle', +import comapeoServer from './app.js' + +const DEFAULT_STORAGE = path.join(process.cwd(), 'data') +const CORE_DIR_NAME = 'core' +const DB_DIR_NAME = 'db' +const ROOT_KEY_FILE_NAME = 'root-key' + +const schema = Type.Object({ + PORT: Type.Number({ default: 8080 }), + SERVER_NAME: Type.String({ + description: 'name of the server', + default: 'CoMapeo Server', + }), + SERVER_BEARER_TOKEN: Type.String({ + description: + 'Bearer token for accessing the server, can be any random string', + }), + STORAGE_DIR: Type.String({ + description: 'path to directory where data is stored', + default: DEFAULT_STORAGE, + }), + ALLOWED_PROJECTS: Type.Optional( + Type.Integer({ + minimum: 1, + description: 'number of projects allowed to join the server', + }), + ), +}) + +/** @typedef {import('@sinclair/typebox').Static} Env */ +/** @type {ReturnType>} */ +const config = envSchema({ schema, dotenv: true }) + +const coreStorage = path.join(config.STORAGE_DIR, CORE_DIR_NAME) +const dbFolder = path.join(config.STORAGE_DIR, DB_DIR_NAME) +const rootKeyFile = path.join(config.STORAGE_DIR, ROOT_KEY_FILE_NAME) + +const migrationsFolder = new URL( + '../node_modules/@comapeo/core/drizzle/', import.meta.url, ).pathname +const projectMigrationsFolder = path.join(migrationsFolder, 'project') +const clientMigrationsFolder = path.join(migrationsFolder, 'client') -export class Server { - #fastify = Fastify() - #mapeoManager - - constructor() { - this.#mapeoManager = new MapeoManager({ - // TODO: Don't hard-code this - rootKey: Buffer.from('mWZ5Qi0oay0KInZ9F/pMCQ==', 'base64'), - // TODO: Save database to disk - dbFolder: ':memory:', - clientMigrationsFolder: path.join(migrationsFolderPath, 'client'), - projectMigrationsFolder: path.join(migrationsFolderPath, 'project'), - // TODO: Save data to disk - coreStorage: () => new RAM(), - fastify: this.#fastify, - }) - } +await Promise.all([ + fsPromises.mkdir(coreStorage, { recursive: true }), + fsPromises.mkdir(dbFolder, { recursive: true }), +]) - /** - * @param {object} options - * @param {number} options.port - * @returns {Promise} - */ - listen({ port }) { - // TODO - console.log('Starting server on port ' + port) - console.log(this.#mapeoManager) - return Promise.resolve() +/** @type {Buffer} */ +let rootKey +try { + rootKey = await fsPromises.readFile(rootKeyFile) +} catch (err) { + if ( + typeof err === 'object' && + err && + 'code' in err && + err.code !== 'ENOENT' + ) { + throw err } + rootKey = crypto.randomBytes(16) + await fsPromises.writeFile(rootKeyFile, rootKey) +} - /** - * @returns {Promise} - */ - close() { - // TODO - return Promise.resolve() - } +if (!rootKey || rootKey.length !== 16) { + throw new Error('Root key must be 16 bytes') +} + +const fastify = createFastify({ + logger: true, + trustProxy: true, +}) +fastify.register(comapeoServer, { + serverName: config.SERVER_NAME, + serverBearerToken: config.SERVER_BEARER_TOKEN, + allowedProjects: config.ALLOWED_PROJECTS, + rootKey, + coreStorage, + dbFolder, + projectMigrationsFolder, + clientMigrationsFolder, +}) + +fastify.get('/healthcheck', async () => {}) + +try { + await fastify.listen({ port: config.PORT, host: '0.0.0.0' }) +} catch (err) { + fastify.log.error(err) + process.exit(1) +} + +/** @param {NodeJS.Signals} signal*/ +async function closeGracefully(signal) { + console.log(`Received signal to terminate: ${signal}`) + await fastify.close() + console.log('Gracefully closed fastify') + process.kill(process.pid, signal) } +process.once('SIGINT', closeGracefully) +process.once('SIGTERM', closeGracefully) diff --git a/src/types/types.ts b/src/types/types.ts index 69bd4ff..a12975d 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -9,4 +9,7 @@ declare module 'fastify' { interface FastifyInstance { comapeo: MapeoManager } + interface FastifyRequest { + baseUrl: URL + } } diff --git a/src/ws-core-replicator.js b/src/ws-core-replicator.js new file mode 100644 index 0000000..6892d6f --- /dev/null +++ b/src/ws-core-replicator.js @@ -0,0 +1,49 @@ +import { createWebSocketStream } from 'ws' + +import { Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' + +/** @import { Duplex } from 'streamx' */ +/** @import { replicateProject } from '@comapeo/core' */ + +/** + * @param {import('ws').WebSocket} ws + * @param {ReturnType} replicationStream + * @returns {Promise} + */ +export function wsCoreReplicator(ws, replicationStream) { + // This is purely to satisfy typescript at its worst. `pipeline` expects a + // NodeJS ReadWriteStream, but our replicationStream is a streamx Duplex + // stream. The difference is that streamx does not implement the + // `setEncoding`, `unpipe`, `wrap` or `isPaused` methods. The `pipeline` + // function does not depend on any of these methods (I have read through the + // NodeJS source code at cebf21d (v22.9.0) to confirm this), so we can safely + // cast the stream to a NodeJS ReadWriteStream. + const _replicationStream = /** @type {NodeJS.ReadWriteStream} */ ( + /** @type {unknown} */ (replicationStream) + ) + return pipeline( + _replicationStream, + wsSafetyTransform(ws), + createWebSocketStream(ws), + _replicationStream, + ) +} + +/** + * Avoid writing data to a closing or closed websocket, which would result in an + * error. Instead we drop the data and wait for the stream close/end events to + * propagate and close the streams cleanly. + * + * @param {import('ws').WebSocket} ws + */ +function wsSafetyTransform(ws) { + return new Transform({ + transform(chunk, _encoding, callback) { + if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { + return callback() + } + callback(null, chunk) + }, + }) +} diff --git a/test/add-project-endpoint.js b/test/add-project-endpoint.js new file mode 100644 index 0000000..cd8375c --- /dev/null +++ b/test/add-project-endpoint.js @@ -0,0 +1,225 @@ +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + createTestServer, + randomAddProjectBody, + randomHex, +} from './test-helpers.js' + +test('request missing project name', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: omit(randomAddProjectBody(), 'projectName'), + }) + + assert.equal(response.statusCode, 400) +}) + +test('request with empty project name', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: { ...randomAddProjectBody(), projectName: '' }, + }) + + assert.equal(response.statusCode, 400) +}) + +test('request missing project key', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: omit(randomAddProjectBody(), 'projectKey'), + }) + + assert.equal(response.statusCode, 400) +}) + +test("request with a project key that's too short", async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: { ...randomAddProjectBody(), projectKey: randomHex(31) }, + }) + + assert.equal(response.statusCode, 400) +}) + +test('request missing any encryption keys', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: omit(randomAddProjectBody(), 'encryptionKeys'), + }) + + assert.equal(response.statusCode, 400) +}) + +test('request missing an encryption key', async (t) => { + const server = createTestServer(t) + const body = randomAddProjectBody() + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: { + ...body, + encryptionKeys: omit(body.encryptionKeys, 'config'), + }, + }) + + assert.equal(response.statusCode, 400) +}) + +test("request with an encryption key that's too short", async (t) => { + const server = createTestServer(t) + const body = randomAddProjectBody() + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: { + ...body, + encryptionKeys: { ...body.encryptionKeys, config: randomHex(31) }, + }, + }) + + assert.equal(response.statusCode, 400) +}) + +test('adding a project', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: randomAddProjectBody(), + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + data: { deviceId: server.deviceId }, + }) +}) + +test('adding a second project fails by default', async (t) => { + const server = createTestServer(t) + + const firstAddResponse = await server.inject({ + method: 'PUT', + url: '/projects', + body: randomAddProjectBody(), + }) + assert.equal(firstAddResponse.statusCode, 200) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: randomAddProjectBody(), + }) + assert.equal(response.statusCode, 403) + assert.match(response.json().message, /maximum number of projects/u) +}) + +test('allowing a maximum number of projects', async (t) => { + const server = createTestServer(t, { allowedProjects: 3 }) + + await t.test('adding 3 projects', async () => { + for (let i = 0; i < 3; i++) { + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: randomAddProjectBody(), + }) + assert.equal(response.statusCode, 200) + } + }) + + await t.test('attempting to add 4th project fails', async () => { + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: randomAddProjectBody(), + }) + assert.equal(response.statusCode, 403) + assert.match(response.json().message, /maximum number of projects/u) + }) +}) + +test( + 'allowing a specific list of projects', + { concurrency: true }, + async (t) => { + const body = randomAddProjectBody() + const projectPublicId = projectKeyToPublicId( + Buffer.from(body.projectKey, 'hex'), + ) + const server = createTestServer(t, { + allowedProjects: [projectPublicId], + }) + + await t.test('adding a project in the list', async () => { + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body, + }) + assert.equal(response.statusCode, 200) + }) + + await t.test('trying to add a project not in the list', async () => { + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: randomAddProjectBody(), + }) + assert.equal(response.statusCode, 403) + }) + }, +) + +test('adding the same project twice is idempotent', async (t) => { + const server = createTestServer(t, { allowedProjects: 1 }) + const body = randomAddProjectBody() + + const firstResponse = await server.inject({ + method: 'PUT', + url: '/projects', + body, + }) + assert.equal(firstResponse.statusCode, 200) + + const secondResponse = await server.inject({ + method: 'PUT', + url: '/projects', + body, + }) + assert.equal(secondResponse.statusCode, 200) +}) + +/** + * @template {object} T + * @template {keyof T} K + * @param {T} obj + * @param {K} key + * @returns {Omit} + */ +function omit(obj, key) { + const result = { ...obj } + delete result[key] + return result +} diff --git a/test/allowed-hosts.js b/test/allowed-hosts.js new file mode 100644 index 0000000..81ab64c --- /dev/null +++ b/test/allowed-hosts.js @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createTestServer } from './test-helpers.js' + +test('allowed host', async (t) => { + const allowedHost = 'www.example.com' + const server = createTestServer(t, { allowedHosts: [allowedHost] }) + + const response = await server.inject({ + authority: allowedHost, + method: 'GET', + url: '/info', + }) + + assert.equal(response.statusCode, 200) +}) + +test('disallowed host', async (t) => { + const server = createTestServer(t, { allowedHosts: ['www.example.com'] }) + + const response = await server.inject({ + authority: 'www.invalid-host.example', + method: 'GET', + url: '/info', + }) + + assert.equal(response.statusCode, 403) +}) diff --git a/test/fixtures/audio.mp3 b/test/fixtures/audio.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..19531804b9d3454e99c0fcd44758b6e7fc8cd474 GIT binary patch literal 17181 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/original.jpg b/test/fixtures/original.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5a3b430be3d9d35377d3a29a63c15ece9a66949b GIT binary patch literal 16308 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/thumbnail.jpg b/test/fixtures/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..facee563c04409a1e9e9ab36f9bdce15621a4ae6 GIT binary patch literal 1661 zcmbW!doaFEEY43*n#?wX`o z2BYjMM96j;avMpK+_FiCvL`V~bg@(I**$0f+THJS-uIvPInQ}M=UpFJe+F!HvU9Wp zKo9^xNdfCm0BeAV!xQidM8YP5Dw(K8v)83*Yty`TSsB^~`i34m;tTsnCY5JK#$Jl| zhtIcVU%Gm&vAHoUT{PTPH&kBVSo38FNLE#)Y0}(vb=_-Dz$a?{bJm3bRvNGX{2)*b zKwv=#3$C{VDv~{=z^?)R42Xb`QX5dxXc<{aL&HV@0YVT02}wyIk&^C2$vc3=N-0qE zEH@~+`k~Y!aQf_=QfYOo+EzU4;TsJD|Hx#tjM64$!e&h^ZR%DXLnC7oQ!~1?jjf%% zgQJt1yN9ROK5w7H@R5MPprgUZqfSJ}oQ#c2Idk@0YFc_mZeD&t;RWu+B7RwUg`o1v z)vCH1^$j;0o0@O6-EHsa?CKWv3_KcqJTyEqIyU)yYI^3y?A*&&i%a6S%kNg+udaP@ zfdKT4CAq)B{^7z(TnHo*LZZI7Ktzn>Kv<*{MQ?+Gr7OxWLQzehEse9vDXncqs~fQ1 z;Qb>X$|z|VPHHZGp?#J8J6Q7nlKlny+cgGYAW-so5Ed{8x?(Ljw)L>_$(O5c+!Q(V zmK1E3rAu$if&66@q2kAILi)V$MM1m1J}%eYgrp3!6ws~69e%w0Us6ftJ~mbl*cOV| znXY=1#NPNg8IRQ-u-=@KPGZdY1_rz`PH8lpu?~$o&ACQDp5b77{f62$mK_8qjD}fW z(L{;%f7iydi^!Yz40CcOzlZbUz=H129?aRGtb5t!^h5N(fDFd{7W71e-g|t{; z2DV?uOPifCkd5UT^d-Ks@vr62&WKC}#^n=@=UbdbBqE{^y35Nh>? zVerxR>M_5R{=B{jW{F~^GMt)hbHy@z3+2L~hj;1H__dEoa^o@&FY>nH?;^UBV7WVP zkKR;yVfx(bW;vJ35Cs!1)Vx9juO;GKUqmM(=%}(TAY>#7rZ{hBwjMvozcJ_cO=gd_9!bMi^a@pRM27lV!=9a zfkV#k^vd$!2u4d2$R_q$aymONak&9XPnFr?Pm>wdq^59b5%KMm(J7|<Q;@(f;b0P5JG4tO z>T32Dxa-lHQQ|cZ*@ltHJxn}CfAyJ|wL7|})96qMF_#XnJ+`JyPv@RAE2X4)caPEI zU6_huH#Eu7+{w(yn#5kPH95M>PpaK1n(aN1&T@R&&k`xp7IK;y+-INNAJA=edPZoE zf{1Q0H)jICto`J5z-OCOlLqssk}ExBI5>GCwPN4xLf=VZOTOK%=PRP+<*J?ez13<7 zbX#5d3LIvA^ba?!;5+~T literal 0 HcmV?d00001 diff --git a/test/list-projects-endpoint.js b/test/list-projects-endpoint.js new file mode 100644 index 0000000..c8cc16d --- /dev/null +++ b/test/list-projects-endpoint.js @@ -0,0 +1,75 @@ +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + BEARER_TOKEN, + createTestServer, + randomAddProjectBody, +} from './test-helpers.js' + +test('listing projects', async (t) => { + const server = createTestServer(t, { allowedProjects: 999 }) + + await t.test('with invalid auth', async () => { + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 403) + }) + + await t.test('with no projects', async () => { + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { data: [] }) + }) + + await t.test('with projects', async () => { + const body1 = randomAddProjectBody() + const body2 = randomAddProjectBody() + + await Promise.all( + [body1, body2].map(async (body) => { + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body, + }) + assert.equal(response.statusCode, 200) + }), + ) + + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + + const { data } = response.json() + assert(Array.isArray(data)) + assert.equal(data.length, 2, 'expected 2 projects') + for (const body of [body1, body2]) { + const projectPublicId = projectKeyToPublicId( + Buffer.from(body.projectKey, 'hex'), + ) + /** @type {Record} */ + const project = data.find( + (project) => project.projectId === projectPublicId, + ) + assert(project, `expected ${projectPublicId} to be found`) + assert.equal( + project.name, + body.projectName, + 'expected project name to match', + ) + } + }) +}) diff --git a/test/observations-endpoint.js b/test/observations-endpoint.js new file mode 100644 index 0000000..c202d54 --- /dev/null +++ b/test/observations-endpoint.js @@ -0,0 +1,290 @@ +import { MapeoManager } from '@comapeo/core' +import { valueOf } from '@comapeo/schema' +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' +import { generate } from '@mapeo/mock-data' +import { map } from 'iterpal' + +import assert from 'node:assert/strict' +import * as fs from 'node:fs/promises' +import test from 'node:test' +import { setTimeout as delay } from 'node:timers/promises' + +import { + BEARER_TOKEN, + createTestServer, + getManagerOptions, + randomAddProjectBody, +} from './test-helpers.js' + +/** @import { ObservationValue } from '@comapeo/schema'*/ +/** @import { FastifyInstance } from 'fastify' */ + +const FIXTURES_ROOT = new URL('./fixtures/', import.meta.url) +const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname +const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname +const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname +const FIXTURE_AUDIO_PATH = new URL('audio.mp3', FIXTURES_ROOT).pathname + +test('returns a 403 if no auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/observations`, + }) + assert.equal(response.statusCode, 403) +}) + +test('returns a 403 if incorrect auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/observations`, + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 403) +}) + +test('returning no observations', async (t) => { + const server = createTestServer(t) + const projectKeys = randomAddProjectBody() + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex'), + ) + + const addProjectResponse = await server.inject({ + method: 'PUT', + url: '/projects', + body: projectKeys, + }) + assert.equal(addProjectResponse.statusCode, 200) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectPublicId}/observations`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(await response.json(), { data: [] }) +}) + +test('returning observations with fetchable attachments', async (t) => { + const server = createTestServer(t) + + const serverAddress = await server.listen() + const serverUrl = new URL(serverAddress) + + const manager = new MapeoManager(getManagerOptions()) + const projectId = await manager.createProject({ name: 'CoMapeo project' }) + const project = await manager.getProject(projectId) + + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + + project.$sync.start() + project.$sync.connectServers() + + const observations = await Promise.all([ + (() => { + /** @type {ObservationValue} */ + // @ts-ignore + const noAttachments = { + ...generateObservation(), + attachments: [], + } + return project.observation.create(noAttachments) + })(), + (async () => { + // @ts-ignore + const { docId } = await project.observation.create(generateObservation()) + return project.observation.delete(docId) + })(), + (async () => { + const [imageBlob, audioBlob] = await Promise.all([ + project.$blobs.create( + { + original: FIXTURE_ORIGINAL_PATH, + preview: FIXTURE_PREVIEW_PATH, + thumbnail: FIXTURE_THUMBNAIL_PATH, + }, + { mimeType: 'image/jpeg', timestamp: Date.now() }, + ), + project.$blobs.create( + { original: FIXTURE_AUDIO_PATH }, + { mimeType: 'audio/mpeg', timestamp: Date.now() }, + ), + ]) + /** @type {ObservationValue} */ + // @ts-ignore + const withAttachment = { + ...generateObservation(), + attachments: [blobToAttachment(imageBlob), blobToAttachment(audioBlob)], + } + return project.observation.create(withAttachment) + })(), + ]) + + await project.$sync.waitForSync('full') + + // It's possible that the client thinks it's synced but the server hasn't + // processed everything yet, so we try a few times. + const data = await runWithRetries(3, async () => { + const response = await server.inject({ + authority: serverUrl.host, + method: 'GET', + url: `/projects/${projectId}/observations`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + + const { data } = await response.json() + assert.equal(data.length, 3) + return data + }) + + await Promise.all( + observations.map(async (observation) => { + const observationFromApi = data.find( + (/** @type {{ docId: string }} */ o) => o.docId === observation.docId, + ) + assert(observationFromApi, 'observation found in API response') + assert.equal(observationFromApi.createdAt, observation.createdAt) + assert.equal(observationFromApi.updatedAt, observation.updatedAt) + assert.equal(observationFromApi.lat, observation.lat) + assert.equal(observationFromApi.lon, observation.lon) + assert.equal(observationFromApi.deleted, observation.deleted) + if (!observationFromApi.deleted) { + await assertAttachmentsCanBeFetchedAsJpeg({ + server, + serverAddress, + observationFromApi, + }) + } + assert.deepEqual(observationFromApi.tags, observation.tags) + }), + ) +}) + +function randomProjectPublicId() { + return projectKeyToPublicId( + Buffer.from(randomAddProjectBody().projectKey, 'hex'), + ) +} + +function generateObservation() { + const observationDoc = generate('observation')[0] + assert(observationDoc) + return valueOf(observationDoc) +} + +/** + * @param {object} blob + * @param {string} blob.driveId + * @param {'photo' | 'audio' | 'video'} blob.type + * @param {string} blob.name + * @param {string} blob.hash + */ +function blobToAttachment(blob) { + return { + driveDiscoveryId: blob.driveId, + type: blob.type, + name: blob.name, + hash: blob.hash, + } +} + +/** + * @template T + * @param {number} retries + * @param {() => Promise} fn + * @returns {Promise} + */ +async function runWithRetries(retries, fn) { + for (let i = 0; i < retries - 1; i++) { + try { + return await fn() + } catch { + await delay(500) + } + } + return fn() +} + +/** + * @param {object} options + * @param {FastifyInstance} options.server + * @param {string} options.serverAddress + * @param {Record} options.observationFromApi + * @returns {Promise} + */ +async function assertAttachmentsCanBeFetchedAsJpeg({ + server, + serverAddress, + observationFromApi, +}) { + assert(Array.isArray(observationFromApi.attachments)) + await Promise.all( + observationFromApi.attachments.map( + /** @param {unknown} attachment */ + async (attachment) => { + assert(attachment && typeof attachment === 'object') + assert('url' in attachment && typeof attachment.url === 'string') + await assertAttachmentAndVariantsCanBeFetched( + server, + serverAddress, + attachment.url, + ) + }, + ), + ) +} + +/** + * @param {FastifyInstance} server + * @param {string} serverAddress + * @param {string} url + * @returns {Promise} + */ +async function assertAttachmentAndVariantsCanBeFetched( + server, + serverAddress, + url, +) { + assert(url.startsWith(serverAddress)) + + /** @type {Map} */ + const variantsToCheck = new Map([ + [null, FIXTURE_ORIGINAL_PATH], + ['original', FIXTURE_ORIGINAL_PATH], + ['preview', FIXTURE_PREVIEW_PATH], + ['thumbnail', FIXTURE_THUMBNAIL_PATH], + ]) + + await Promise.all( + map(variantsToCheck, async ([variant, fixturePath]) => { + const expectedResponseBodyPromise = fs.readFile(fixturePath) + const attachmentResponse = await server.inject({ + method: 'GET', + url: url + (variant ? `?variant=${variant}` : ''), + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal( + attachmentResponse.statusCode, + 200, + `expected 200 when fetching ${variant} attachment`, + ) + assert.equal( + attachmentResponse.headers['content-type'], + 'image/jpeg', + `expected ${variant} attachment to be a JPEG`, + ) + assert.deepEqual( + attachmentResponse.rawPayload, + await expectedResponseBodyPromise, + `expected ${variant} attachment to match fixture`, + ) + }), + ) +} diff --git a/test/sync-endpoint.js b/test/sync-endpoint.js new file mode 100644 index 0000000..73f4e66 --- /dev/null +++ b/test/sync-endpoint.js @@ -0,0 +1,55 @@ +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createTestServer, randomAddProjectBody } from './test-helpers.js' + +test('sync endpoint is available after adding a project', async (t) => { + const server = createTestServer(t) + const addProjectBody = randomAddProjectBody() + const projectPublicId = projectKeyToPublicId( + Buffer.from(addProjectBody.projectKey, 'hex'), + ) + + await t.test('sync endpoint not available yet', async () => { + const response = await server.inject({ + method: 'GET', + url: '/sync/' + projectPublicId, + headers: { + connection: 'upgrade', + upgrade: 'websocket', + }, + }) + assert.equal(response.statusCode, 404) + assert.equal(response.json().error, 'Not Found') + }) + + await server.inject({ + method: 'PUT', + url: '/projects', + body: addProjectBody, + }) + + await t.test('sync endpoint available', async (t) => { + const ws = await server.injectWS('/sync/' + projectPublicId) + t.after(() => ws.terminate()) + assert.equal(ws.readyState, ws.OPEN, 'websocket is open') + }) +}) + +test('sync endpoint returns error with an invalid project public ID', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: '/sync/foobidoobi', + headers: { + connection: 'upgrade', + upgrade: 'websocket', + }, + }) + + assert.equal(response.statusCode, 400) + assert.equal(response.json().code, 'FST_ERR_VALIDATION') +}) diff --git a/test/test-helpers.js b/test/test-helpers.js index 9d8657e..a39b128 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -43,7 +43,7 @@ export function getManagerOptions() { /** * @param {TestContext} t * @param {Partial} [serverOptions] - * @returns {import('fastify').FastifyInstance & { deviceId: string }} + * @returns {FastifyInstance & { deviceId: string }} */ export function createTestServer(t, serverOptions) { const managerOptions = getManagerOptions() @@ -63,3 +63,18 @@ export function createTestServer(t, serverOptions) { // @ts-expect-error return server } + +export const randomHex = (length = 32) => + Buffer.from(randomBytes(length)).toString('hex') + +export const randomAddProjectBody = () => ({ + projectName: randomHex(16), + projectKey: randomHex(), + encryptionKeys: { + auth: randomHex(), + config: randomHex(), + data: randomHex(), + blobIndex: randomHex(), + blob: randomHex(), + }, +}) diff --git a/tsconfig.json b/tsconfig.base.json similarity index 91% rename from tsconfig.json rename to tsconfig.base.json index 6f7f088..e32b64a 100644 --- a/tsconfig.json +++ b/tsconfig.base.json @@ -4,12 +4,10 @@ "allowUnreachableCode": false, "allowUnusedLabels": false, "checkJs": true, - "exactOptionalPropertyTypes": true, "incremental": true, "lib": ["es2022"], "module": "nodenext", "moduleResolution": "nodenext", - "noEmit": true, "noErrorTruncation": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": true, @@ -18,6 +16,7 @@ "noImplicitThis": true, "noUncheckedIndexedAccess": true, "noUnusedParameters": true, + "outDir": "./dist", "resolveJsonModule": true, "skipLibCheck": true, "strict": true, diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3926f77 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/tsconfig.dev.json b/tsconfig.dev.json new file mode 100644 index 0000000..69a027b --- /dev/null +++ b/tsconfig.dev.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] +}