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 0000000..1953180 Binary files /dev/null and b/test/fixtures/audio.mp3 differ diff --git a/test/fixtures/original.jpg b/test/fixtures/original.jpg new file mode 100644 index 0000000..5a3b430 Binary files /dev/null and b/test/fixtures/original.jpg differ diff --git a/test/fixtures/preview.jpg b/test/fixtures/preview.jpg new file mode 100644 index 0000000..19bfae4 Binary files /dev/null and b/test/fixtures/preview.jpg differ diff --git a/test/fixtures/thumbnail.jpg b/test/fixtures/thumbnail.jpg new file mode 100644 index 0000000..facee56 Binary files /dev/null and b/test/fixtures/thumbnail.jpg differ 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/**/*"] +}