diff --git a/.dockerignore b/.dockerignore index f05a7ed6..36bc9c49 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,11 @@ # Ignore everything. ** -# Allow `/src` and `Dockerfile`. +# Allow `/ui-frontend`, /ui-backend` and `Dockerfile`. !/Dockerfile -!/src/** +!/cli/** +# !/ui-backend/** +# !/ui-frontend/** ################################################################################ # Ignore things inside of `/src`. (esentially Node .gitignore) diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..e69de29b diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..82a4cb50 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,78 @@ +const allExtensions = [".ts", ".tsx", ".d.ts", ".js", ".jsx"]; + +module.exports = { + root: true, + extends: "react-app", + plugins: ["import"], + rules: { + "import/newline-after-import": ["warn", { count: 1 }], + "import/no-extraneous-dependencies": [ + "warn", + { + devDependencies: false, + optionalDependencies: false, + peerDependencies: false, + bundledDependencies: true, + }, + ], + "import/no-relative-parent-imports": "warn", + "import/order": [ + "warn", + { + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + "newlines-between": "always", + groups: [ + "builtin", + "external", + "internal", + ["parent", "sibling", "index"], + "object", + ], + pathGroups: [ + { + pattern: "react?(-dom)", + group: "external", + position: "before", + }, + { + pattern: "@iris/**", + group: "external", + position: "after", + }, + ], + pathGroupsExcludedImportTypes: ["builtin"], + }, + ], + }, + overrides: [ + { + files: ["webpack.*.js"], + rules: { + "import/no-extraneous-dependencies": [ + "warn", + { + devDependencies: true, + optionalDependencies: false, + peerDependencies: false, + bundledDependencies: true, + }, + ], + }, + }, + ], + settings: { + "import/extensions": allExtensions, + "import/external-module-folders": ["node_modules", "node_modules/@types"], + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx", ".d.ts"], + }, + "import/resolver": { + node: { + extensions: allExtensions, + }, + }, + }, +}; diff --git a/.gitignore b/.gitignore index 0307817a..0955e205 100644 --- a/.gitignore +++ b/.gitignore @@ -1,68 +1,25 @@ -local_keys +# See https://help.github.com/ignore-files/ for more about ignoring files. -tmp.mp4 -tmp.webm +/sample-projects -secret.yaml -.env +# dependencies +**/node_modules -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript +# testing +**/coverage -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release +# production +**/build +**/dist -# Dependency directories -node_modules/ -jspm_packages/ +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local -# TypeScript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# next.js build output -.next - -secret.yaml +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* diff --git a/.kubeconfig b/.kubeconfig deleted file mode 100644 index 0b2e4809..00000000 --- a/.kubeconfig +++ /dev/null @@ -1 +0,0 @@ -export KUBECONFIG=/Users/niko/.bluemix/plugins/container-service/clusters/bpnvi8vw0nkktonrr20g/kube-config-wdc04-annotations.yml \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..3f6fff7b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +dist +coverage \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index fd496a82..00000000 --- a/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "semi": false -} diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b71a3a4f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: node_js -node_js: stable -before_deploy: -- curl -sL https://ibm.biz/idt-installer | bash -script: echo "No tests to run." -deploy: -- provider: script - skip_cleanup: true - script: export DEPLOY_TO=production && ./deploy.sh - on: - branch: master -- provider: script - skip_cleanup: true - script: export DEPLOY_TO=staging && ./deploy.sh - on: - branch: niko-dev \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a73a41b..67eb2a20 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1,13 @@ { + "workbench.editor.labelFormat": "short", + "[typescript]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + }, + "[typescriptreact]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + } } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f78f0fdd..2480bd24 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,10 @@ -FROM node:10.15.0 as nodebuild -COPY src . -RUN npm install && cd client && npm install && npm run build - -FROM node:10.15.0-alpine -COPY --from=nodebuild server.js . -COPY --from=nodebuild package.json . -COPY --from=nodebuild package-lock.json . -COPY --from=nodebuild client/build client +FROM node:12.18.4 -RUN npm install --only=production &&\ - apk update &&\ - apk upgrade -ENV PORT 8080 -ENV NODE_ENV production +WORKDIR /iris -ARG CLIENT_ID -ENV CLIENT_ID $CLIENT_ID +COPY cli . -ARG CLIENT_SECRET -ENV CLIENT_SECRET $CLIENT_SECRET +RUN npm install -g . --unsafe-perm -EXPOSE 8080 -CMD node server.js \ No newline at end of file +ENTRYPOINT [ "iris" ] +CMD [ "dev" ] \ No newline at end of file diff --git a/Dockerfile2 b/Dockerfile2 new file mode 100755 index 00000000..9ca04cbf --- /dev/null +++ b/Dockerfile2 @@ -0,0 +1,30 @@ +############################ +# Stage 1 +############################ +# @sha256:3d5b19ec04fd3600df7fe014c7301a53a5437c9677c27baeee4247b6a1670ed3 +FROM node:12.18.4-slim as ui-builder + +# Build the UI +COPY src/client . +RUN yarn && yarn build + + +############################ +# Stage 2 +############################ +FROM node:12.18.4-slim + +# Install packages +COPY src/package.json src/yarn.lock ./ +RUN yarn install --only=production + +# Copy in source files +COPY --from=ui-builder build client +COPY src/server.js ./ + +# Set server env +ENV PORT 8080 +ENV NODE_ENV production + +EXPOSE 8080 +CMD node server.js \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..da21cc27 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +ORG=cloudannotations +REPO=cloudannotations +TAG=0.0.1 + +.PHONY: docker-publish +docker-publish: + # DOCKER_BUILDKIT=1 + @docker build -t ${ORG}/${REPO}:${TAG} . + @docker push ${ORG}/${REPO}:${TAG} + +.PHONY: clean +clean: + yarn lerna exec "rm -rf node_modules dist build" + rm -rf node_modules + +.PHONY: install +install: + yarn install + yarn lerna bootstrap + +.PHONY: build +build: + yarn lerna run build --stream + +.PHONY: start +start: + yarn lerna run build --stream --ignore @iris/app + FORCE_COLOR=true yarn lerna run start --parallel --stream + + + +# Add this back to the package.json to get storybook working again... +# installs took wayyy to long so temporary dissabled (30s to 3m) +# "nohoist": [ +# "**" +# ] +.PHONY: storybook +storybook: + yarn lerna exec "rm -rf node_modules/html-webpack-plugin/node_modules/webpack" --scope @iris/components + # build the theme and hooks, storybook watches components already + yarn lerna run build --stream --scope @iris/theme --scope @iris/hooks + # exec makes progress bar work + yarn lerna exec "yarn storybook" --scope @iris/components \ No newline at end of file diff --git a/README.md b/README.md index 5757fb7b..e69de29b 100644 --- a/README.md +++ b/README.md @@ -1,42 +0,0 @@ -[![Build Status](https://travis-ci.org/cloud-annotations/cloud-annotations-client.svg?branch=master)](https://travis-ci.org/cloud-annotations/cloud-annotations-client) - -

Cloud Annotations

- -

- Logo -

- -

- Launch Cloud Annotations 🚀 -

- -## Project setup -`git clone` the repo and `cd` into it by running the following command: - -```bash -git clone https://github.com/bourdakos1/cloud-annotations-client.git -cd cloud-annotations-client/src -``` - -Install the server dependencies: -``` -npm install -``` - -Install the client dependencies: -``` -cd client && npm install -```` - -> **Note: You’ll need to have Node 8.10.0 or later on your local development machine.** You can use [nvm](https://github.com/creationix/nvm#installation) (macOS/Linux) or [nvm-windows](https://github.com/coreybutler/nvm-windows#node-version-manager-nvm-for-windows) to easily switch Node versions between different projects. - -## Local development -Start the app: -``` -npm start -``` - -Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -## Issues -Please file any issues on the [training repo](https://github.com/cloud-annotations/training/issues) diff --git a/cert-manager-prod/certificate.yaml b/cert-manager-prod/certificate.yaml deleted file mode 100644 index 97776d52..00000000 --- a/cert-manager-prod/certificate.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: cert-manager.io/v1alpha2 -kind: Certificate -metadata: - name: cloud-annotations-ai -spec: - secretName: certbot-annotations-secret - issuerRef: - name: letsencrypt - commonName: cloud.annotations.ai - dnsNames: - - cloud.annotations.ai \ No newline at end of file diff --git a/cert-manager-prod/issuer.yaml b/cert-manager-prod/issuer.yaml deleted file mode 100644 index 03b76be5..00000000 --- a/cert-manager-prod/issuer.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: cert-manager.io/v1alpha2 -kind: Issuer -metadata: - name: letsencrypt -spec: - acme: - # You must replace this email address with your own. - # Let's Encrypt will use this to contact you about expiring - # certificates, and issues related to your account. - email: bourdakos1@gmail.com - server: https://acme-v02.api.letsencrypt.org/directory - privateKeySecretRef: - # Secret resource that will be used to store the account's private key. - name: issuer-account-key - # Add a single challenge solver, HTTP01 using nginx - solvers: - - http01: - ingress: - class: nginx \ No newline at end of file diff --git a/cert-manager-stage/certificate.yaml b/cert-manager-stage/certificate.yaml deleted file mode 100644 index 98a57d5a..00000000 --- a/cert-manager-stage/certificate.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: cert-manager.io/v1alpha2 -kind: Certificate -metadata: - name: test-cloud-annotations-ai -spec: - secretName: stage-annotations-secret - issuerRef: - name: letsencrypt - commonName: test.cloud.annotations.ai - dnsNames: - - test.cloud.annotations.ai \ No newline at end of file diff --git a/cert-manager-stage/issuer.yaml b/cert-manager-stage/issuer.yaml deleted file mode 100644 index c5c24822..00000000 --- a/cert-manager-stage/issuer.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: cert-manager.io/v1alpha2 -kind: Issuer -metadata: - name: letsencrypt -spec: - acme: - # You must replace this email address with your own. - # Let's Encrypt will use this to contact you about expiring - # certificates, and issues related to your account. - email: bourdakos1@gmail.com - server: https://acme-v02.api.letsencrypt.org/directory - privateKeySecretRef: - # Secret resource that will be used to store the account's private key. - name: stage-issuer-account-key - # Add a single challenge solver, HTTP01 using nginx - solvers: - - http01: - ingress: - class: nginx \ No newline at end of file diff --git a/cli/bin/index.js b/cli/bin/index.js new file mode 100755 index 00000000..08292b29 --- /dev/null +++ b/cli/bin/index.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +"use strict"; + +var currentNodeVersion = process.versions.node; +var semver = currentNodeVersion.split("."); +var major = semver[0]; + +if (major < 10) { + console.error( + "You are running Node " + + currentNodeVersion + + ".\n" + + "Iris requires Node 10 or higher. \n" + + "Please update your version of Node." + ); + process.exit(1); +} + +const { init } = require("./irisCLI"); + +init(); diff --git a/cli/bin/irisCLI.js b/cli/bin/irisCLI.js new file mode 100644 index 00000000..130f8e6e --- /dev/null +++ b/cli/bin/irisCLI.js @@ -0,0 +1,54 @@ +"use strict"; + +const spawn = require("child_process").spawn; + +function init() { + if (process.argv[2] === "start") { + console.log("starting..."); + + const iris = spawn("node", ["/usr/local/lib/iris/iris/dist/index.js"], { + env: { + ...process.env, + SPA_ROOT: "/usr/local/lib/iris/packages/iris-app/build", + }, + }); + + iris.stdout.on("data", (data) => { + console.log(`stdout: ${data}`); + }); + } + + if (process.argv[2] === "dev") { + console.log("starting in dev mode..."); + } + + if (process.argv[2] === "build") { + console.log("building..."); + } + + if ( + process.argv[2] === "ui" && + process.argv[3] === "install" && + process.argv[4] + ) { + console.log(`installing UI plugin: ${process.argv[4]}...`); + if (process.argv[5] !== "--no-build") { + console.log("building..."); + } + } + + if ( + process.argv[2] === "server" && + process.argv[3] === "install" && + process.argv[4] + ) { + console.log(`installing server plugin: ${process.argv[4]}...`); + if (process.argv[5] !== "--no-build") { + console.log("building..."); + } + } +} + +module.exports = { + init, +}; diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..bfef9e6c --- /dev/null +++ b/cli/package.json @@ -0,0 +1,14 @@ +{ + "name": "@iris/cli", + "version": "0.0.8", + "bin": { + "iris": "./bin/index.js" + }, + "scripts": { + "install": "rm -rf /usr/local/lib/iris && git clone https://github.com/cloud-annotations/iris.git /usr/local/lib/iris && cd /usr/local/lib/iris && git checkout helm && yarn install && make build", + "uninstall": "rm -rf /usr/local/lib/iris" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/cloud-annotations.png b/cloud-annotations.png deleted file mode 100644 index 5b3a0280..00000000 Binary files a/cloud-annotations.png and /dev/null differ diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 4715802d..00000000 --- a/deploy.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -trap 'echo "The deployment was aborted. Message -- "; exit 1' ERR - -CLUSTER="bso3qn4w0imtmamdicu0" -IMAGE_NAME="us.icr.io/cloud-annotations/frontend:$(git rev-parse HEAD)" - -# Log in -echo "Logging in..." -ibmcloud config --check-version=false -ibmcloud login -a cloud.ibm.com -r us-east -g prod - -# Download cluster config -echo Downloading config for $CLUSTER ... -ibmcloud ks cluster config --cluster $CLUSTER - -# Build image -echo Building $IMAGE_NAME ... -ibmcloud cr build --no-cache --pull --build-arg CLIENT_ID=$CLIENT_ID --build-arg CLIENT_SECRET=$CLIENT_SECRET -t $IMAGE_NAME . - -# Apply kubernetes yamls -echo Container build completed, updating $DEPLOYMENT ... -sed -i "s,\(^.*image: \)\(.*$\),\1"$IMAGE_NAME"," k8s-base/frontend.yaml - -if [ "$DEPLOY_TO" = "production" ] -then - # PRODUCTION: - kubectl apply -f k8s-base - kubectl apply -f k8s-prod -else - # STAGING: - kubectl apply -f k8s-base -n stage - kubectl apply -f k8s-stage -n stage -fi - -echo "Deployment complete" diff --git a/generate-secret.sh b/generate-secret.sh deleted file mode 100755 index a5241dcb..00000000 --- a/generate-secret.sh +++ /dev/null @@ -1,27 +0,0 @@ -# sudo certbot certonly --manual --preferred-challenges=dns -# *.annotations.ai, annotations.ai -# nslookup -type=TXT _acme-challenge.annotations.ai - -# run this script, then: -# ./generate-secret.sh - -# ibmcloud api https://cloud.ibm.com -# ibmcloud login -sso -# ibmcloud ks cluster config annotations -# export KUBECONFIG="/Users/niko/.bluemix/plugins/container-service/clusters/annotations/kube-config-wdc04-annotations.yml" -# kl apply -f secret.yaml - -cert=$(sudo openssl base64 -in /etc/letsencrypt/live/annotations.ai/fullchain.pem) -key=$(sudo openssl base64 -in /etc/letsencrypt/live/annotations.ai/privkey.pem) - -stripped_cert=$(tr -d '\n' <<< "$cert") -stripped_key=$(tr -d '\n' <<< "$key") - -echo "apiVersion: v1 -kind: Secret -metadata: - name: annotations-secret -type: Opaque -data: - tls.crt: ""$stripped_cert"" - tls.key: ""$stripped_key""" > k8s-prod/secret.yaml diff --git a/iris/package.json b/iris/package.json new file mode 100644 index 00000000..6345e1cf --- /dev/null +++ b/iris/package.json @@ -0,0 +1,45 @@ +{ + "name": "@iris/server", + "private": true, + "version": "0.0.1", + "bin": { + "iris": "./bin/index.js" + }, + "scripts": { + "start": "tsc -w", + "build": "tsc" + }, + "dependencies": { + "@types/busboy": "^0.2.3", + "@types/compression": "^1.7.0", + "@types/express": "^4.17.8", + "@types/fs-extra": "^9.0.4", + "@types/node": "^14.14.2", + "@types/pino-http": "^5.0.5", + "@types/proper-lockfile": "^4.1.1", + "@types/redis": "^2.8.28", + "@types/socket.io": "^2.1.11", + "@types/uuid": "^8.3.0", + "busboy": "^0.3.1", + "clean-webpack-plugin": "^3.0.0", + "compression": "^1.7.4", + "express": "^4.17.1", + "fs-extra": "^9.0.1", + "helmet": "^4.1.1", + "ibm-cos-sdk": "^1.8.0", + "pino-colada": "^2.1.0", + "pino-http": "^5.3.0", + "proper-lockfile": "^4.1.1", + "redis": "^3.0.2", + "socket.io": "^3.0.1", + "socket.io-redis": "^6.0.1", + "start-server-webpack-plugin": "^2.2.5", + "ts-loader": "^8.0.6", + "tsconfig-paths-webpack-plugin": "^3.3.0", + "typescript": "^4.0.3", + "webpack": "^5.2.0", + "webpack-cli": "^4.1.0", + "webpack-merge": "^5.2.0", + "webpack-node-externals": "^2.5.2" + } +} diff --git a/iris/src/handlers/error.ts b/iris/src/handlers/error.ts new file mode 100644 index 00000000..fd5c575a --- /dev/null +++ b/iris/src/handlers/error.ts @@ -0,0 +1,20 @@ +import { NextFunction, Request, Response } from "express"; + +function errorHandler( + err: Error, + _req: Request, + res: Response, + next: NextFunction +) { + if (res.headersSent) { + return next(err); + } + // Don't leak internal errors in production. + if (process.env.NODE_ENV === "production") { + return res.sendStatus(500); + } + + return res.status(500).json({ error: err.message }); +} + +export default errorHandler; diff --git a/iris/src/handlers/not-found.ts b/iris/src/handlers/not-found.ts new file mode 100644 index 00000000..077eb9b5 --- /dev/null +++ b/iris/src/handlers/not-found.ts @@ -0,0 +1,7 @@ +import { Request, Response } from "express"; + +function notFoundHandler(_req: Request, res: Response) { + res.sendStatus(404); +} + +export default notFoundHandler; diff --git a/iris/src/index.ts b/iris/src/index.ts new file mode 100644 index 00000000..79ee2e41 --- /dev/null +++ b/iris/src/index.ts @@ -0,0 +1,36 @@ +import http from "http"; + +import express from "express"; + +import errorHandler from "./handlers/error"; +import notFoundHandler from "./handlers/not-found"; +import gzip from "./middleware/gzip"; +import logger from "./middleware/logger"; +import security from "./middleware/security"; +import multiuser from "./multiuser"; +import spaRouter from "./routes/spa"; +import v2Router from "./routes/v2"; + +const app = express(); +const server = http.createServer(app); +const port = process.env.PORT || 9000; + +app.enable("trust proxy"); +app.disable("x-powered-by"); + +multiuser(server); + +app.use(gzip()); +app.use(security()); +app.use(logger()); +app.use(express.json()); + +app.use("/api/v2", v2Router); +app.use("/", spaRouter); + +app.use(notFoundHandler); +app.use(errorHandler); + +server.listen(port, () => { + console.log("listening on port " + port); +}); diff --git a/iris/src/middleware/gzip.ts b/iris/src/middleware/gzip.ts new file mode 100644 index 00000000..932b6f44 --- /dev/null +++ b/iris/src/middleware/gzip.ts @@ -0,0 +1,17 @@ +import compression from "compression"; + +function gzip() { + return compression({ + filter: (req, res) => { + // don't compress responses asking explicitly not + if (req.headers["x-no-compression"]) { + return false; + } + + // use compression filter function + return compression.filter(req, res); + }, + }); +} + +export default gzip; diff --git a/iris/src/middleware/logger.ts b/iris/src/middleware/logger.ts new file mode 100644 index 00000000..e9ca6f89 --- /dev/null +++ b/iris/src/middleware/logger.ts @@ -0,0 +1,12 @@ +import pinoColada from "pino-colada"; +import pino from "pino-http"; + +function logger() { + return pino({ + level: process.env.LOG_LEVEL || "info", + prettyPrint: process.env.NODE_ENV !== "production", + prettifier: pinoColada, + }); +} + +export default logger; diff --git a/iris/src/middleware/pino-colada.d.ts b/iris/src/middleware/pino-colada.d.ts new file mode 100644 index 00000000..1f46dd26 --- /dev/null +++ b/iris/src/middleware/pino-colada.d.ts @@ -0,0 +1 @@ +declare module "pino-colada"; diff --git a/iris/src/middleware/security.ts b/iris/src/middleware/security.ts new file mode 100644 index 00000000..d9cd018d --- /dev/null +++ b/iris/src/middleware/security.ts @@ -0,0 +1,7 @@ +import helmet from "helmet"; + +function security() { + return helmet({ contentSecurityPolicy: false }); +} + +export default security; diff --git a/iris/src/multiuser.ts b/iris/src/multiuser.ts new file mode 100644 index 00000000..e48d5dfb --- /dev/null +++ b/iris/src/multiuser.ts @@ -0,0 +1,64 @@ +import http from "http"; + +import { RedisClient } from "redis"; +import { Server, Socket } from "socket.io"; +import { createAdapter } from "socket.io-redis"; + +interface CustomSocket extends Socket { + room?: string; +} + +function multiuser(server: http.Server) { + const io = new Server(server); + + const pubClient = new RedisClient({ host: "redis", port: 6379 }); + + pubClient.on("ready", () => { + console.log("pub client ready"); + const subClient = pubClient.duplicate(); + subClient.on("ready", () => { + console.log("sub client ready"); + console.log("creating addapter"); + io.adapter(createAdapter({ pubClient, subClient })); + }); + subClient.on("error", () => { + // do nothing + }); + }); + pubClient.on("error", () => { + // do nothing + }); + + function broadcastRoomCount(room: string) { + const namespace = io.in(room); + const people = namespace.sockets.sockets.size; + namespace.emit("roomSize", people); + } + + io.on("connection", (socket: CustomSocket) => { + socket.on("patch", (res: any) => { + socket.broadcast.emit("patch", res); + }); + + socket.on("join", async (res: any) => { + const newRoom = res.image; + + for (const room of socket.rooms) { + socket.leave(room); + broadcastRoomCount(room); + } + + socket.room = newRoom; + socket.join(newRoom); + broadcastRoomCount(newRoom); + }); + + socket.on("disconnect", () => { + if (socket.room) { + broadcastRoomCount(socket.room); + } + }); + }); +} + +export default multiuser; diff --git a/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts b/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts new file mode 100644 index 00000000..c5c06e87 --- /dev/null +++ b/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts @@ -0,0 +1,63 @@ +import COS from "ibm-cos-sdk"; + +interface IOptions { + name: string; + projectID?: string; +} + +const connections = [ + { + id: "718b4cc1-1c5d-400b-b056-08244db75225", + providerID: "cos", + name: "bee-travels", + credentials: { + apikey: "Fczphmlve6ANRIrTKNgiEHVex29FFCdw2lv8g67XWqhO", + resource_instance_id: + "crn:v1:bluemix:public:cloud-object-storage:global:a/9b13b857a32341b7167255de717172f5:718b4cc1-1c5d-400b-b056-08244db75225::", + }, + }, + { + id: "48b970b2-c8c8-4b4e-bb0c-85b6338cd295", + providerID: "cos", + name: "Cloud Object Storage-nick", + credentials: { + apikey: "aSz22rBc1hZEnw-EwtsOyFvXz1fmWAl6iAJtr9X4JMF0", + resource_instance_id: + "crn:v1:bluemix:public:cloud-object-storage:global:a/9b13b857a32341b7167255de717172f5:48b970b2-c8c8-4b4e-bb0c-85b6338cd295::", + }, + }, +]; +class COSProvider { + async getConnections() { + return Promise.resolve(connections); + } + + async getProjects({ connectionID }: { connectionID: string }) { + const connection = connections.find((c) => c.id === connectionID); + const config = { + endpoint: "https://s3.us.cloud-object-storage.appdomain.cloud", + apiKeyId: connection?.credentials.apikey, + ibmAuthEndpoint: "https://iam.cloud.ibm.com/identity/token", + serviceInstanceId: connection?.credentials.resource_instance_id, + }; + + const cosClient = new COS.S3(config); + const list = await cosClient.listBucketsExtended().promise(); + return list.Buckets?.map((b) => ({ + name: b.Name, + modified: b.CreationDate, // TODO: this should be created + })); + } + + async getProject(options: Pick) {} + + async persist(annotations: any, options: Pick) {} + + async getImage(imageID: string, options: Pick) {} + + async deleteImage(imageID: string, options: Pick) {} + + async saveImage(file: NodeJS.ReadableStream, options: IOptions) {} +} + +export default COSProvider; diff --git a/iris/src/plugins/iris-server-plugin-cos/index.ts b/iris/src/plugins/iris-server-plugin-cos/index.ts new file mode 100644 index 00000000..de7cb3c7 --- /dev/null +++ b/iris/src/plugins/iris-server-plugin-cos/index.ts @@ -0,0 +1,10 @@ +import COSProvider from "./COSProvider"; + +export default { + activate: (iris: any) => { + iris.providers.register({ + id: "cos", + provider: new COSProvider(), + }); + }, +}; diff --git a/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts b/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts new file mode 100644 index 00000000..d7cac1a2 --- /dev/null +++ b/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts @@ -0,0 +1,188 @@ +import path from "path"; + +import fs from "fs-extra"; +import lockfile from "proper-lockfile"; + +interface IImage { + id: string; + date: string; +} + +interface IProject { + id?: string; + name: string; + created: Date; + annotations: { + version: string; + labels: string[]; + annotations: {}; // TODO + images: IImage[]; + }; +} + +interface IOptions { + name: string; + projectID?: string; +} + +class FileSystemProvider { + private _dir(projectID: string | undefined) { + if (projectID) { + return path.join(process.cwd(), projectID); + } + return process.cwd(); + } + + async getConnections() { + return Promise.resolve([ + { + id: "default", + providerID: "file-system", + name: "File System", + icon: ` + + + `, + }, + ]); + } + + async getProjects() { + const x = await fs.readdir(process.cwd(), { withFileTypes: true }); + + return await Promise.all( + x + .filter((dir) => dir.isDirectory()) + .map(async (dir) => { + const stats: { created?: Date; modified?: Date; opened?: Date } = { + created: undefined, + modified: undefined, + opened: undefined, + }; + + const files = await fs.readdir(dir.name); + + const images = files.filter( + (f) => + f.toLowerCase().endsWith(".jpg") || + f.toLowerCase().endsWith(".jpeg") + ); + + let labels: string[] = []; + try { + const a = await fs.readFile( + path.join(dir.name, "_annotations.json"), + "utf-8" + ); + labels = JSON.parse(a).labels; + } catch {} + + try { + const s = await fs.stat(path.join(dir.name, "_annotations.json")); + stats.created = s.birthtime; + stats.modified = s.mtime; + stats.opened = s.atime; + } catch {} + + return { + id: dir.name, + name: dir.name, + created: stats.created, + modified: stats.modified, + opened: stats.opened, + labels: labels, + images: images.length, + }; + }) + ); + } + + async getProject(options: Pick) { + const { projectID } = options; + + const project: IProject = { + id: projectID, + name: projectID ?? path.basename(process.cwd()), + created: new Date(), + annotations: { + version: "v2", + labels: [], + annotations: {}, + images: [], + }, + }; + + try { + const annotationsString = await fs.readFile( + path.join(this._dir(projectID), "_annotations.json"), + "utf-8" + ); + project.annotations = JSON.parse(annotationsString); + } catch { + // we don't care if there's no annotations file. + } + + const files = await fs.readdir(this._dir(projectID)); + + project.annotations.images = files + .filter( + (f) => + f.toLowerCase().endsWith(".jpg") || f.toLowerCase().endsWith(".jpeg") + ) + .map((i) => ({ id: i, date: "" })); + + return project; + } + + async persist(annotations: any, options: Pick) { + const { projectID } = options; + + const output = path.join(this._dir(projectID), "_annotations.json"); + + // TODO: This probably isn't safe: + // "It is unsafe to call fsPromises.writeFile() multiple times on the same + // file without waiting for the Promise to be resolved (or rejected)." + await fs.writeFile(output, JSON.stringify(annotations), "utf-8"); + } + + async getImage(imageID: string, options: Pick) { + const { projectID } = options; + + const output = path.join(this._dir(projectID), imageID); + + const isLocked = await lockfile.check(output); + if (!isLocked) { + return fs.createReadStream(output); + } + + throw new Error("file is locked"); + } + + async deleteImage(imageID: string, options: Pick) { + const { projectID } = options; + const output = path.join(this._dir(projectID), imageID); + await fs.unlink(output); + } + + async saveImage(file: NodeJS.ReadableStream, options: IOptions) { + const { projectID, name } = options; + const output = path.join(this._dir(projectID), name); + + await fs.ensureFile(output); + const release = await lockfile.lock(output); + const writeStream = fs.createWriteStream(output); + file.pipe(writeStream); + return new Promise((resolve, reject) => { + writeStream.on("error", async (e) => { + await release(); + reject(e); + }); + writeStream.on("finish", async () => { + await release(); + resolve(); + }); + }); + } +} + +export default FileSystemProvider; diff --git a/iris/src/plugins/iris-server-plugin-file-system/index.ts b/iris/src/plugins/iris-server-plugin-file-system/index.ts new file mode 100644 index 00000000..80b96888 --- /dev/null +++ b/iris/src/plugins/iris-server-plugin-file-system/index.ts @@ -0,0 +1,10 @@ +import FileSystemProvider from "./FileSystemProvider"; + +export default { + activate: (iris: any) => { + iris.providers.register({ + id: "file-system", + provider: new FileSystemProvider(), + }); + }, +}; diff --git a/iris/src/routes/spa.ts b/iris/src/routes/spa.ts new file mode 100644 index 00000000..7159172a --- /dev/null +++ b/iris/src/routes/spa.ts @@ -0,0 +1,16 @@ +import path from "path"; + +import express, { Router } from "express"; + +const root = process.env.SPA_ROOT; + +const router = Router(); + +if (root) { + router.use(express.static(root, { maxAge: 31536000 })); + router.get("*", (_req, res) => { + res.sendFile(path.join(root, "index.html")); + }); +} + +export default router; diff --git a/iris/src/routes/v2/api.ts b/iris/src/routes/v2/api.ts new file mode 100644 index 00000000..67989290 --- /dev/null +++ b/iris/src/routes/v2/api.ts @@ -0,0 +1,256 @@ +import Busboy from "busboy"; +import { Request, Router } from "express"; +import fs from "fs-extra"; + +//////////////////////////////////////////////////////////////////////////////// +interface IImage { + id: string; + date: string; +} + +interface IProject { + id: string; + name: string; + created?: Date; + modified?: Date; + opened?: Date; + labels?: string[]; + images?: number; +} + +interface IProjectDetails { + id?: string; + name: string; + created: Date; + annotations: { + version: string; + labels: string[]; + annotations: {}; // TODO + images: IImage[]; + }; +} + +interface IOptions { + name: string; + projectID?: string; +} + +interface IConnection { + id: string; + name: string; + icon?: string; +} + +interface Provider { + getConnections: () => Promise; + getProjects: ({ + connectionID, + }: { + connectionID: string; + }) => Promise; + getProject: ( + options: Pick + ) => Promise; + persist: (annotations: any, options: Pick) => any; + getImage: ( + imageID: string, + options: Pick + ) => Promise; + deleteImage: (imageID: string, options: Pick) => any; + saveImage: (file: NodeJS.ReadableStream, options: IOptions) => any; +} + +// TODO: pull from package.json +let extensions = [ + "../../plugins/iris-server-plugin-file-system", + "../../plugins/iris-server-plugin-cos", +]; +let providers: { [key: string]: Provider } = {}; +const iris = { + providers: { + register: ({ id, provider }: any) => { + providers[id] = provider; + }, + }, +}; +for (const extension of extensions) { + try { + const provider = require(extension).default; + provider.activate(iris); + } catch (e) { + console.log(e); + } +} +//////////////////////////////////////////////////////////////////////////////// + +const router = Router(); + +function getProjectID(req: Request) { + const { projectID } = req.query; + if (process.env.SINGLE_DOCUMENT_MODE && projectID === undefined) { + return projectID; + } + if (!process.env.SINGLE_DOCUMENT_MODE && typeof projectID === "string") { + return projectID; + } + throw new Error( + `projectID "${projectID}" of type "${typeof projectID}" is not allowed in ${ + process.env.SINGLE_DOCUMENT_MODE + ? "single document mode" + : "projects mode" + }` + ); +} + +function requiredQuery(req: Request, param: string) { + const x = req.query[param]; + if (x === undefined) { + throw new Error(`${param} is undefined`); + } + if (typeof x === "string") { + return x; + } + throw new Error(`${param} "${x}" of type "${typeof x}" is not allowed`); +} + +function ensureProjectMode() { + if (process.env.SINGLE_DOCUMENT_MODE) { + throw new Error("Projects not available in single document mode"); + } +} + +/** + * GET /api/mode + */ +router.get("/mode", async (_req, res) => { + res.json({ singleDocument: !!process.env.SINGLE_DOCUMENT_MODE }); +}); + +/** + * GET /api/connections + */ +router.get("/connections", async (_req, res, next) => { + try { + ensureProjectMode(); + let connections: IConnection[] = []; + for (const provider of Object.values(providers)) { + const c = await provider.getConnections(); + connections.push(...c); + } + res.json(connections); + } catch (e) { + next(e); + } +}); + +/** + * GET /api/projects + */ +router.get("/projects", async (req, res, next) => { + try { + ensureProjectMode(); + const providerID = requiredQuery(req, "providerID"); + const connectionID = requiredQuery(req, "connectionID"); + const projects = await providers[providerID].getProjects({ connectionID }); + res.json(projects); + } catch (e) { + next(e); + } +}); + +/** + * GET /api/project + * @queryParam {string} [projectID] + */ +router.get("/project", async (req, res, next) => { + try { + const projectID = getProjectID(req); + const project = await providers["file-system"].getProject({ projectID }); + res.json(project); + } catch (e) { + next(e); + } +}); + +/** + * PUT /api/project + * @queryParam {string} [projectID] + */ +router.put("/project", async (req, res, next) => { + try { + const projectID = getProjectID(req); + await providers["file-system"].persist(req.body, { projectID }); + res.sendStatus(200); + } catch (e) { + next(e); + } +}); + +/** + * GET /api/images/:imageID + * @pathParam {string} imageID + * @queryParam {string} [projectID] + */ +router.get("/images/:imageID", async (req, res, next) => { + const { imageID } = req.params; + + try { + const projectID = getProjectID(req); + const image = await providers["file-system"].getImage(imageID, { + projectID, + }); + + image.on("error", (e) => { + throw e; + }); + + res.set("Content-Type", "image/jpeg"); + image.pipe(res); + } catch (e) { + next(e); + } +}); + +/** + * DELETE /api/images/:imageID + * @pathParam {string} imageID + * @queryParam {string} [projectID] + */ +router.delete("/images/:imageID", async (req, res, next) => { + const { imageID } = req.params; + + try { + const projectID = getProjectID(req); + await providers["file-system"].deleteImage(imageID, { projectID }); + res.sendStatus(200); + } catch (e) { + next(e); + } +}); + +/** + * POST /api/images + * @queryParam {string} [projectID] + */ +router.post("/images", async (req, res, next) => { + try { + const projectID = getProjectID(req); + const busboy = new Busboy({ + headers: req.headers, + limits: { + files: 1, + }, + }); + + busboy.on("file", async (name, file) => { + await providers["file-system"].saveImage(file, { name, projectID }); + res.sendStatus(200); + }); + + req.pipe(busboy); + } catch (e) { + next(e); + } +}); + +export default router; diff --git a/iris/src/routes/v2/auth.ts b/iris/src/routes/v2/auth.ts new file mode 100644 index 00000000..503cf205 --- /dev/null +++ b/iris/src/routes/v2/auth.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; + +const router = Router(); + +router.get("/status", (_req, res) => { + res.sendStatus(200); +}); + +export default router; diff --git a/iris/src/routes/v2/index.ts b/iris/src/routes/v2/index.ts new file mode 100644 index 00000000..f2f86a04 --- /dev/null +++ b/iris/src/routes/v2/index.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; + +import apiRouter from "./api"; +import authRouter from "./auth"; + +const router = Router(); + +router.use("/auth", authRouter); +router.use("/", apiRouter); + +export default router; diff --git a/iris/tsconfig.json b/iris/tsconfig.json new file mode 100644 index 00000000..ab20b976 --- /dev/null +++ b/iris/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "declarationMap": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + + "target": "es2017", + "lib": ["es2017", "es2019.array"], + "module": "commonjs" + }, + "include": ["src"] +} diff --git a/k8s-base/frontend.yaml b/k8s-base/frontend.yaml deleted file mode 100644 index 27f52d68..00000000 --- a/k8s-base/frontend.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: frontend - labels: - app: annotations - tier: frontend -spec: - ports: - - port: 443 - targetPort: 8080 - protocol: TCP - selector: - app: annotations - tier: frontend ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: frontend -spec: - selector: - matchLabels: - app: annotations - tier: frontend - replicas: 6 - template: - metadata: - labels: - app: annotations - tier: frontend - spec: - containers: - - name: frontend - image: us.icr.io/cloud-annotations/frontend:em-patch-2 - imagePullPolicy: Always - ports: - - containerPort: 8080 diff --git a/k8s-base/redis.yaml b/k8s-base/redis.yaml deleted file mode 100644 index eaf52a62..00000000 --- a/k8s-base/redis.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - run: redis - name: redis -spec: - ports: - - port: 6379 - targetPort: 6379 - selector: - run: redis ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: redis -spec: - replicas: 1 - selector: - matchLabels: - run: redis - template: - metadata: - labels: - run: redis - - spec: - containers: - - name: redis - image: redis - ports: - - containerPort: 6379 \ No newline at end of file diff --git a/k8s-prod/ingress.yaml b/k8s-prod/ingress.yaml deleted file mode 100644 index 19cab825..00000000 --- a/k8s-prod/ingress.yaml +++ /dev/null @@ -1,73 +0,0 @@ -apiVersion: networking.k8s.io/v1beta1 -kind: Ingress -metadata: - name: ingress - annotations: - ingress.bluemix.net/sticky-cookie-services: "serviceName=frontend name=sticky expires=5h path=/ hash=sha1" - ingress.bluemix.net/client-max-body-size: "size=10m" - # nginx.ingress.kubernetes.io/affinity: "cookie" - # nginx.ingress.kubernetes.io/proxy-body-size: "10m" -spec: - tls: - - hosts: - - cloud.annotations.ai - secretName: certbot-annotations-secret - - hosts: - - annotations2-442dbba0442be6c8c50f31ed96b00601-0000.us-east.containers.appdomain.cloud - secretName: annotations2-442dbba0442be6c8c50f31ed96b00601-0000 - rules: - - host: cloud.annotations.ai - http: - paths: - - path: / - backend: - serviceName: frontend - servicePort: 443 - - path: /docs - backend: - serviceName: docs - servicePort: 80 - - path: /workshops - backend: - serviceName: docs - servicePort: 80 - - path: /demos - backend: - serviceName: docs - servicePort: 80 - - path: /sdks - backend: - serviceName: docs - servicePort: 80 - - path: /docs-assets - backend: - serviceName: docs - servicePort: 80 - - - host: annotations2-442dbba0442be6c8c50f31ed96b00601-0000.us-east.containers.appdomain.cloud - http: - paths: - - path: / - backend: - serviceName: frontend - servicePort: 443 - - path: /docs - backend: - serviceName: docs - servicePort: 80 - - path: /workshops - backend: - serviceName: docs - servicePort: 80 - - path: /demos - backend: - serviceName: docs - servicePort: 80 - - path: /sdks - backend: - serviceName: docs - servicePort: 80 - - path: /docs-assets - backend: - serviceName: docs - servicePort: 80 \ No newline at end of file diff --git a/k8s-stage/ingress.yaml b/k8s-stage/ingress.yaml deleted file mode 100644 index 20c90dd8..00000000 --- a/k8s-stage/ingress.yaml +++ /dev/null @@ -1,40 +0,0 @@ -apiVersion: networking.k8s.io/v1beta1 -kind: Ingress -metadata: - name: ingress - annotations: - nginx.ingress.kubernetes.io/affinity: "cookie" - nginx.ingress.kubernetes.io/proxy-body-size: "10m" -spec: - tls: - - hosts: - - test.cloud.annotations.ai - secretName: stage-annotations-secret - rules: - - host: test.cloud.annotations.ai - http: - paths: - - path: / - backend: - serviceName: frontend - servicePort: 443 - - path: /docs - backend: - serviceName: docs - servicePort: 80 - - path: /workshops - backend: - serviceName: docs - servicePort: 80 - - path: /demos - backend: - serviceName: docs - servicePort: 80 - - path: /sdks - backend: - serviceName: docs - servicePort: 80 - - path: /docs-assets - backend: - serviceName: docs - servicePort: 80 diff --git a/lerna.json b/lerna.json new file mode 100644 index 00000000..c08ce8ae --- /dev/null +++ b/lerna.json @@ -0,0 +1,9 @@ +{ + "packages": [ + "packages/*", + "iris" + ], + "npmClient": "yarn", + "useWorkspaces": true, + "version": "0.0.0" +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..1fcb9260 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "workspaces": { + "packages": [ + "packages/*", + "iris" + ] + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "2.x", + "@typescript-eslint/parser": "2.x", + "babel-eslint": "10.x", + "eslint": "6.x", + "eslint-config-react-app": "^5.2.1", + "eslint-plugin-flowtype": "4.x", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-jsx-a11y": "6.x", + "eslint-plugin-react": "7.x", + "eslint-plugin-react-hooks": "2.x", + "lerna": "^3.22.1", + "typescript": "^4.0.5" + }, + "name": "iris" +} diff --git a/packages/iris-api/package.json b/packages/iris-api/package.json new file mode 100644 index 00000000..86b51053 --- /dev/null +++ b/packages/iris-api/package.json @@ -0,0 +1,14 @@ +{ + "name": "@iris/api", + "version": "0.0.1", + "main": "dist/index.js", + "license": "MIT", + "scripts": { + "start": "tsc -w", + "build": "tsc" + }, + "dependencies": { + "@types/uuid": "^8.3.0", + "swr": "^0.3.6" + } +} diff --git a/packages/iris-api/src/api.ts b/packages/iris-api/src/api.ts new file mode 100644 index 00000000..7ef1791b --- /dev/null +++ b/packages/iris-api/src/api.ts @@ -0,0 +1,57 @@ +import endpoint from "./endpoint"; +import { APIOptions, HttpError, MethodOptions } from "./types"; +import { stripEmptyKeys } from "./utils"; + +export async function fetcher(endpoint: RequestInfo, options?: RequestInit) { + const res = await fetch(endpoint, options); + + if (!res.ok) { + const error = new Error(res.statusText) as HttpError; + error.status = res.status; + throw error; + } + + // swallow json parse errors + // TODO: handle this better, what about `res.blob()` etc... + try { + return await res.json(); + } catch {} +} + +export async function request(route: string, options: APIOptions) { + const { body, json, headers, method, signal, ...rest } = options; + + const url = endpoint(route, rest); + + return await fetcher(url, { + signal, + method, + headers: headers ? stripEmptyKeys(headers) : undefined, + body: json ? JSON.stringify(json) : body, + }); +} + +async function get(route: string, options: MethodOptions = {}) { + return await request(route, { ...options, method: "GET" }); +} + +async function post(route: string, options: MethodOptions = {}) { + return await request(route, { ...options, method: "POST" }); +} + +async function put(route: string, options: MethodOptions = {}) { + return await request(route, { ...options, method: "PUT" }); +} + +async function del(route: string, options: MethodOptions = {}) { + return await request(route, { ...options, method: "DELETE" }); +} + +const api = { + get, + post, + put, + del, +}; + +export default api; diff --git a/packages/iris-api/src/endpoint.ts b/packages/iris-api/src/endpoint.ts new file mode 100644 index 00000000..10172640 --- /dev/null +++ b/packages/iris-api/src/endpoint.ts @@ -0,0 +1,22 @@ +import { EndpointOptions, PathParams, QueryParams } from "./types"; +import { stripEmptyKeys } from "./utils"; + +function injectPathParams(route: string, path?: PathParams) { + return route.replace(/\/:(\w+)/gi, (_match, group1) => `/${path?.[group1]}`); +} + +function queryString(query?: QueryParams) { + if (query !== undefined) { + const safe = stripEmptyKeys(query); + return "?" + new URLSearchParams(safe).toString(); + } + return ""; +} + +function endpoint(route: string, options: EndpointOptions = {}) { + const { baseUrl = "/api/v2", path, query } = options; + + return baseUrl + injectPathParams(route, path) + queryString(query); +} + +export default endpoint; diff --git a/packages/iris-api/src/index.ts b/packages/iris-api/src/index.ts new file mode 100644 index 00000000..14ccc0a4 --- /dev/null +++ b/packages/iris-api/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./api"; +export { default as api } from "./api"; +export { default as endpoint } from "./endpoint"; diff --git a/packages/iris-api/src/types.ts b/packages/iris-api/src/types.ts new file mode 100644 index 00000000..7305fe4e --- /dev/null +++ b/packages/iris-api/src/types.ts @@ -0,0 +1,39 @@ +export interface PathParams { + [key: string]: string | undefined; +} + +export interface QueryParams { + [key: string]: string | undefined; +} + +export interface Headers { + [key: string]: string | undefined; +} + +export interface EndpointOptions { + baseUrl?: string; + path?: PathParams; + query?: QueryParams; +} + +export interface MethodOptions extends EndpointOptions { + headers?: Headers; + body?: + | string + | URLSearchParams + | FormData + | Blob + | ArrayBufferView + | ArrayBuffer + | ReadableStream; + json?: any; + signal?: AbortSignal; +} + +export interface APIOptions extends MethodOptions { + method: string; +} + +export interface HttpError extends Error { + status: number; +} diff --git a/packages/iris-api/src/utils.ts b/packages/iris-api/src/utils.ts new file mode 100644 index 00000000..7b0f2879 --- /dev/null +++ b/packages/iris-api/src/utils.ts @@ -0,0 +1,9 @@ +export function stripEmptyKeys(o: { [key: string]: T | undefined }) { + let safe: { [key: string]: T } = {}; + for (const [key, val] of Object.entries(o)) { + if (val !== undefined && val !== null) { + safe[key] = val; + } + } + return safe; +} diff --git a/packages/iris-api/tsconfig.json b/packages/iris-api/tsconfig.json new file mode 100644 index 00000000..d4d4911e --- /dev/null +++ b/packages/iris-api/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declarationMap": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "jsx": "react" + }, + "include": ["src"] +} diff --git a/packages/iris-app/config/env.js b/packages/iris-app/config/env.js new file mode 100644 index 00000000..09ec03c5 --- /dev/null +++ b/packages/iris-app/config/env.js @@ -0,0 +1,101 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve('./paths')]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error( + 'The NODE_ENV environment variable is required but was not specified.' + ); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +const dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + `${paths.dotenv}.${NODE_ENV}`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== 'test' && `${paths.dotenv}.local`, + paths.dotenv, +].filter(Boolean); + +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach(dotenvFile => { + if (fs.existsSync(dotenvFile)) { + require('dotenv-expand')( + require('dotenv').config({ + path: dotenvFile, + }) + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebook/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. +// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || '') + .split(path.delimiter) + .filter(folder => folder && !path.isAbsolute(folder)) + .map(folder => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter(key => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || 'development', + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + // We support configuring the sockjs pathname during development. + // These settings let a developer run multiple simultaneous projects. + // They are used as the connection `hostname`, `pathname` and `port` + // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` + // and `sockPort` options in webpack-dev-server. + WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, + WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, + WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, + } + ); + // Stringify all values so we can feed into webpack DefinePlugin + const stringified = { + 'process.env': Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return { raw, stringified }; +} + +module.exports = getClientEnvironment; diff --git a/packages/iris-app/config/getHttpsConfig.js b/packages/iris-app/config/getHttpsConfig.js new file mode 100644 index 00000000..013d493c --- /dev/null +++ b/packages/iris-app/config/getHttpsConfig.js @@ -0,0 +1,66 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const chalk = require('react-dev-utils/chalk'); +const paths = require('./paths'); + +// Ensure the certificate and key provided are valid and if not +// throw an easy to debug error +function validateKeyAndCerts({ cert, key, keyFile, crtFile }) { + let encrypted; + try { + // publicEncrypt will throw an error with an invalid cert + encrypted = crypto.publicEncrypt(cert, Buffer.from('test')); + } catch (err) { + throw new Error( + `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}` + ); + } + + try { + // privateDecrypt will throw an error with an invalid key + crypto.privateDecrypt(key, encrypted); + } catch (err) { + throw new Error( + `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${ + err.message + }` + ); + } +} + +// Read file and throw an error if it doesn't exist +function readEnvFile(file, type) { + if (!fs.existsSync(file)) { + throw new Error( + `You specified ${chalk.cyan( + type + )} in your env, but the file "${chalk.yellow(file)}" can't be found.` + ); + } + return fs.readFileSync(file); +} + +// Get the https config +// Return cert files if provided in env, otherwise just true or false +function getHttpsConfig() { + const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env; + const isHttps = HTTPS === 'true'; + + if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) { + const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE); + const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE); + const config = { + cert: readEnvFile(crtFile, 'SSL_CRT_FILE'), + key: readEnvFile(keyFile, 'SSL_KEY_FILE'), + }; + + validateKeyAndCerts({ ...config, keyFile, crtFile }); + return config; + } + return isHttps; +} + +module.exports = getHttpsConfig; diff --git a/packages/iris-app/config/jest/cssTransform.js b/packages/iris-app/config/jest/cssTransform.js new file mode 100644 index 00000000..8f651148 --- /dev/null +++ b/packages/iris-app/config/jest/cssTransform.js @@ -0,0 +1,14 @@ +'use strict'; + +// This is a custom Jest transformer turning style imports into empty objects. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process() { + return 'module.exports = {};'; + }, + getCacheKey() { + // The output is always the same. + return 'cssTransform'; + }, +}; diff --git a/packages/iris-app/config/jest/fileTransform.js b/packages/iris-app/config/jest/fileTransform.js new file mode 100644 index 00000000..aab67618 --- /dev/null +++ b/packages/iris-app/config/jest/fileTransform.js @@ -0,0 +1,40 @@ +'use strict'; + +const path = require('path'); +const camelcase = require('camelcase'); + +// This is a custom Jest transformer turning file imports into filenames. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.svg$/)) { + // Based on how SVGR generates a component name: + // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 + const pascalCaseFilename = camelcase(path.parse(filename).name, { + pascalCase: true, + }); + const componentName = `Svg${pascalCaseFilename}`; + return `const React = require('react'); + module.exports = { + __esModule: true, + default: ${assetFilename}, + ReactComponent: React.forwardRef(function ${componentName}(props, ref) { + return { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }; + }), + };`; + } + + return `module.exports = ${assetFilename};`; + }, +}; diff --git a/packages/iris-app/config/modules.js b/packages/iris-app/config/modules.js new file mode 100644 index 00000000..c8efd0dd --- /dev/null +++ b/packages/iris-app/config/modules.js @@ -0,0 +1,141 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); +const chalk = require('react-dev-utils/chalk'); +const resolve = require('resolve'); + +/** + * Get additional module paths based on the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePaths(options = {}) { + const baseUrl = options.baseUrl; + + // We need to explicitly check for null and undefined (and not a falsy value) because + // TypeScript treats an empty string as `.`. + if (baseUrl == null) { + // If there's no baseUrl set we respect NODE_PATH + // Note that NODE_PATH is deprecated and will be removed + // in the next major release of create-react-app. + + const nodePath = process.env.NODE_PATH || ''; + return nodePath.split(path.delimiter).filter(Boolean); + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === '') { + return [paths.appSrc]; + } + + // If the path is equal to the root directory we ignore it here. + // We don't want to allow importing from the root directly as source files are + // not transpiled outside of `src`. We do allow importing them with the + // absolute path (e.g. `src/Components/Button.js`) but we set that up with + // an alias. + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return null; + } + + // Otherwise, throw an error. + throw new Error( + chalk.red.bold( + "Your project's `baseUrl` can only be set to `src` or `node_modules`." + + ' Create React App does not support other values at this time.' + ) + ); +} + +/** + * Get webpack aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getWebpackAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + src: paths.appSrc, + }; + } +} + +/** + * Get jest aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getJestAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + '^src/(.*)$': '/src/$1', + }; + } +} + +function getModules() { + // Check if TypeScript is setup + const hasTsConfig = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (hasTsConfig && hasJsConfig) { + throw new Error( + 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.' + ); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // TypeScript project and set up the config + // based on tsconfig.json + if (hasTsConfig) { + const ts = require(resolve.sync('typescript', { + basedir: paths.appNodeModules, + })); + config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const additionalModulePaths = getAdditionalModulePaths(options); + + return { + additionalModulePaths: additionalModulePaths, + webpackAliases: getWebpackAliases(options), + jestAliases: getJestAliases(options), + hasTsConfig, + }; +} + +module.exports = getModules(); diff --git a/packages/iris-app/config/paths.js b/packages/iris-app/config/paths.js new file mode 100644 index 00000000..280a597e --- /dev/null +++ b/packages/iris-app/config/paths.js @@ -0,0 +1,70 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const getPublicUrlOrPath = require("react-dev-utils/getPublicUrlOrPath"); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath); + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// webpack needs to know it to put the right + +