From 7fe41b1fb26bdbf40abee5dc6c296a1c5bd3456b Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Tue, 16 Aug 2022 16:17:07 +0100 Subject: [PATCH 01/28] perf(Docker images): production images are based on distroless (#899) - perf(server, webhook-service): production images are based on distroless Reduces image file size by >40% for images which can use distroless base image. As well as improving boot-up & restart time (via smaller download & load size), Distroless reduces the attack surface area by removing almost all binaries & packages (e.g. shell, chown) that are not necessary to run node. - ensures distroless node images run tini - removes fonts-dejavu-core and fontconfig from speckle-server - Remove man and doc files if they exist - args hoisted to top of Dockerfile and consolidated - env vars consolidated to prevent additional layers address https://github.com/specklesystems/speckle-server/issues/883 --- packages/fileimport-service/Dockerfile | 60 ++++++++++++++++++-------- packages/frontend/Dockerfile | 3 +- packages/preview-service/Dockerfile | 21 +++++---- packages/server/Dockerfile | 44 +++++++++---------- packages/webhook-service/Dockerfile | 36 +++++++++------- utils/monitor-deployment/Dockerfile | 33 +++++++++++--- utils/test-deployment/Dockerfile | 25 ++++++++--- 7 files changed, 140 insertions(+), 82 deletions(-) diff --git a/packages/fileimport-service/Dockerfile b/packages/fileimport-service/Dockerfile index 3eba977c2..a1b945172 100644 --- a/packages/fileimport-service/Dockerfile +++ b/packages/fileimport-service/Dockerfile @@ -1,33 +1,55 @@ -FROM node:16.15-bullseye-slim as node - -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - --no-install-recommends \ - python3=3.9.2-3 \ - python3-pip=20.3.4-4+deb11u1 \ - tini=0.19.0-1 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait -RUN chmod +x /wait - ARG NODE_ENV=production -ENV NODE_ENV=${NODE_ENV} + +FROM node:16.15-bullseye-slim as build-stage WORKDIR /speckle-server +# add wait +ENV WAIT_VERSION 2.8.0 +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait +RUN chmod +x ./wait + +# Add tini +ENV TINI_VERSION v0.19.0 +ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini +RUN chmod +x ./tini + +# Add python virtual env +WORKDIR /venv +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install \ + --no-install-suggests --no-install-recommends --yes \ + python3-venv=3.9.2-3 && \ + python3 -m venv /venv + +# pip install +COPY packages/fileimport-service/requirements.txt /requirements.txt +RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt + +# yarn install +WORKDIR /speckle-server +ENV NODE_ENV=${NODE_ENV} COPY .yarnrc.yml . COPY .yarn ./.yarn COPY package.json yarn.lock ./ WORKDIR /speckle-server/packages/fileimport-service -COPY packages/fileimport-service/package.json ./ +COPY packages/fileimport-service/package.json . RUN yarn workspaces focus --production -COPY packages/fileimport-service/requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +FROM gcr.io/distroless/nodejs:16 as production-stage +WORKDIR /speckle-server/packages/fileimport-service COPY packages/fileimport-service . +COPY --from=build-stage /speckle-server/wait /wait +COPY --from=build-stage /speckle-server/tini /tini +COPY --from=build-stage /venv /venv +COPY --from=build-stage /speckle-server/node_modules/ ./node_modules/ -CMD ["yarn", "node", "src/daemon.js"] +# Prefixing PATH with our virtual environment should seek required binaries +# from virtual environment first. +# Unsetting python home +ENV PATH=/venv/bin:${PATH}, \ + PYTHONHOME= + +ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "src/daemon.js"] diff --git a/packages/frontend/Dockerfile b/packages/frontend/Dockerfile index c85c7b082..2bd50b291 100644 --- a/packages/frontend/Dockerfile +++ b/packages/frontend/Dockerfile @@ -1,10 +1,9 @@ # NOTE: Docker context should be set to git root directory, to include the viewer +ARG SPECKLE_SERVER_VERSION=custom # build stage FROM node:16.15-bullseye-slim as build-stage -ARG SPECKLE_SERVER_VERSION=custom - WORKDIR /speckle-server COPY .yarnrc.yml . COPY .yarn ./.yarn diff --git a/packages/preview-service/Dockerfile b/packages/preview-service/Dockerfile index f3a86be15..f1d09887c 100644 --- a/packages/preview-service/Dockerfile +++ b/packages/preview-service/Dockerfile @@ -1,19 +1,23 @@ # NOTE: Docker context should be set to git root directory, to include the viewer - +ARG NODE_ENV=production # build stage FROM node:16.15-buster-slim as build-stage -ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} - WORKDIR /speckle-server + +# install wait +ENV WAIT_VERSION 2.8.0 +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait +RUN chmod +x ./wait + COPY .yarnrc.yml . COPY .yarn ./.yarn COPY package.json yarn.lock ./ -# Onyl copy in the relevant package.json files for the dependencies +# Only copy in the relevant package.json files for the dependencies COPY packages/preview-service/package.json ./packages/preview-service/ COPY packages/viewer/package.json ./packages/viewer/ COPY packages/objectloader/package.json ./packages/objectloader/ @@ -28,8 +32,6 @@ COPY packages/preview-service ./packages/preview-service/ # This way the foreach only builds the frontend and its deps RUN yarn workspaces foreach -pt run build - - FROM node:16.15-bullseye-slim as node RUN apt-get update && \ @@ -66,10 +68,8 @@ RUN apt-get update && \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait -RUN chmod +x /wait +COPY --from=build-stage /speckle-server/wait /wait -ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} WORKDIR /speckle-server @@ -89,5 +89,4 @@ COPY --from=build-stage /speckle-server/packages/preview-service ./preview-servi WORKDIR /speckle-server/packages/preview-service RUN yarn workspaces focus --production -ENTRYPOINT [ "tini", "--" ] -CMD ["yarn", "node", "bin/www"] +ENTRYPOINT [ "tini", "--", "node", "bin/www" ] diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index f6e73398e..1831d1e8d 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -1,18 +1,23 @@ -FROM node:16.15-bullseye-slim as build-stage ARG NODE_ENV=production ARG SPECKLE_SERVER_VERSION=custom +ARG FILE_SIZE_LIMIT_MB=100 +ARG NODE_ENV=production -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - --no-install-recommends \ - tini=0.19.0-1 \ - fonts-dejavu-core=2.37-2 \ - fontconfig=2.13.1-4.2 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +FROM node:16.15-bullseye-slim as build-stage WORKDIR /speckle-server +# install wait +ENV WAIT_VERSION 2.8.0 +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait +RUN chmod +x ./wait + +# install tini +ENV TINI_VERSION v0.19.0 +ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini +RUN chmod +x ./tini + +# install node packages COPY .yarnrc.yml . COPY .yarn ./.yarn COPY package.json yarn.lock ./ @@ -25,27 +30,20 @@ COPY packages/server . RUN yarn build && yarn workspaces focus --production -FROM node:16.15-bullseye-slim as production-stage -ARG NODE_ENV=production -ENV NODE_ENV=${NODE_ENV} -ARG SPECKLE_SERVER_VERSION=custom +FROM gcr.io/distroless/nodejs:16 as production-stage -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait -RUN chmod +x /wait +ENV FILE_SIZE_LIMIT_MB=${FILE_SIZE_LIMIT_MB}, \ + NODE_ENV=${NODE_ENV}, \ + SPECKLE_SERVER_VERSION=${SPECKLE_SERVER_VERSION} WORKDIR /speckle-server - -COPY --from=build-stage /speckle-server/.yarnrc.yml . -COPY --from=build-stage /speckle-server/.yarn ./.yarn -COPY --from=build-stage /speckle-server/package.json /speckle-server/yarn.lock ./ +COPY --from=build-stage /speckle-server/wait /wait +COPY --from=build-stage /speckle-server/tini /tini COPY --from=build-stage /speckle-server/node_modules ./node_modules WORKDIR /speckle-server/packages/server - COPY --from=build-stage /speckle-server/packages/server/dist ./dist COPY --from=build-stage /speckle-server/packages/server/assets ./assets COPY --from=build-stage /speckle-server/packages/server/bin ./bin -ENV FILE_SIZE_LIMIT_MB=100 -ENV SPECKLE_SERVER_VERSION=${SPECKLE_SERVER_VERSION} -CMD ["yarn", "node", "bin/www"] +ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "./bin/www"] diff --git a/packages/webhook-service/Dockerfile b/packages/webhook-service/Dockerfile index f7149f544..bea623db6 100644 --- a/packages/webhook-service/Dockerfile +++ b/packages/webhook-service/Dockerfile @@ -1,20 +1,20 @@ -FROM node:16.15-bullseye-slim as node - -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - --no-install-recommends \ - tini=0.19.0-1 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait -RUN chmod +x /wait - ARG NODE_ENV=production -ENV NODE_ENV=${NODE_ENV} + +FROM node:16.15-bullseye-slim as build-stage WORKDIR /speckle-server +ENV WAIT_VERSION 2.8.0 +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait +RUN chmod +x ./wait + +ENV TINI_VERSION v0.19.0 +ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini +RUN chmod +x ./tini + +# yarn install +ENV NODE_ENV=${NODE_ENV} + COPY .yarnrc.yml . COPY .yarn ./.yarn COPY package.json yarn.lock ./ @@ -23,6 +23,12 @@ WORKDIR /speckle-server/packages/webhook-service COPY packages/webhook-service/package.json . RUN yarn workspaces focus --production -COPY packages/webhook-service/src . +FROM gcr.io/distroless/nodejs:16 as production-stage -CMD ["yarn", "node", "main.js"] +WORKDIR /speckle-server/packages/webhook-service +COPY packages/webhook-service/src . +COPY --from=build-stage /speckle-server/wait /wait +COPY --from=build-stage /speckle-server/tini /tini +COPY --from=build-stage /speckle-server/node_modules ./node_modules + +ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "main.js"] diff --git a/utils/monitor-deployment/Dockerfile b/utils/monitor-deployment/Dockerfile index f57b83b5b..9c54159a7 100644 --- a/utils/monitor-deployment/Dockerfile +++ b/utils/monitor-deployment/Dockerfile @@ -1,14 +1,33 @@ -FROM python:3.8-slim +ARG PG_CONNECTION_STRING +ARG NODE_EXTRA_CA_CERTS +FROM debian:11-slim AS build-stage + +WORKDIR /build # Add Tini ENV TINI_VERSION v0.19.0 -ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini -RUN chmod +x /tini -ENTRYPOINT ["/tini", "--"] +ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini +RUN chmod +x ./tini +# Add python virtual env +WORKDIR /venv +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install \ + --no-install-suggests --no-install-recommends --yes \ + python3-venv=3.9.2-3 && \ + python3 -m venv /venv + +COPY utils/monitor-deployment/requirements.txt /requirements.txt +RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt + +FROM gcr.io/distroless/python3-debian11:nonroot as production-stage + +ENV PG_CONNECTION_STRING=${PG_CONNECTION_STRING}, \ + NODE_EXTRA_CA_CERTS=${NODE_EXTRA_CA_CERTS} + +COPY --from=build-stage /venv /venv WORKDIR /app +COPY --from=build-stage /build/tini ./tini COPY utils/monitor-deployment . -RUN pip install --no-cache-dir -r requirements.txt - -CMD ["python", "-u", "src/run.py"] +ENTRYPOINT [ "./tini", "--", "/venv/bin/python3", "-u", "src/run.py"] diff --git a/utils/test-deployment/Dockerfile b/utils/test-deployment/Dockerfile index 1303bd645..09ea8c626 100644 --- a/utils/test-deployment/Dockerfile +++ b/utils/test-deployment/Dockerfile @@ -1,7 +1,22 @@ -FROM python:3.8-slim +ARG SPECKLE_SERVER +ARG SPECKLE_VERSION -WORKDIR /speckle -COPY utils/test-deployment . -RUN pip install --no-cache-dir -r requirements.txt +FROM debian:11-slim AS build-stage +WORKDIR /venv +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install \ + --no-install-suggests --no-install-recommends --yes \ + python3-venv=3.9.2-3 && \ + python3 -m venv /venv -CMD [ "./run_tests.py" ] +COPY utils/test-deployment/requirements.txt /requirements.txt +RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt + +FROM gcr.io/distroless/python3-debian11:nonroot as production-stage + +ENV SPECKLE_SERVER=${SPECKLE_SERVER}, \ + SPECKLE_SERVER=${SPECKLE_VERSION} +COPY --from=build-stage /venv /venv +COPY utils/test-deployment /app +WORKDIR /app +ENTRYPOINT [ "/venv/bin/python3", "-u", "./run_tests.py" ] From 44bd5919f8a36a80bfe31a909cb119758363d7d5 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Tue, 16 Aug 2022 16:42:59 +0100 Subject: [PATCH 02/28] Revert "perf(Docker images): production images are based on distroless (#899)" (#934) This reverts commit 7fe41b1fb26bdbf40abee5dc6c296a1c5bd3456b. --- packages/fileimport-service/Dockerfile | 58 ++++++++------------------ packages/frontend/Dockerfile | 3 +- packages/preview-service/Dockerfile | 21 +++++----- packages/server/Dockerfile | 44 +++++++++---------- packages/webhook-service/Dockerfile | 34 +++++++-------- utils/monitor-deployment/Dockerfile | 33 ++++----------- utils/test-deployment/Dockerfile | 25 +++-------- 7 files changed, 80 insertions(+), 138 deletions(-) diff --git a/packages/fileimport-service/Dockerfile b/packages/fileimport-service/Dockerfile index a1b945172..3eba977c2 100644 --- a/packages/fileimport-service/Dockerfile +++ b/packages/fileimport-service/Dockerfile @@ -1,55 +1,33 @@ -ARG NODE_ENV=production +FROM node:16.15-bullseye-slim as node -FROM node:16.15-bullseye-slim as build-stage - -WORKDIR /speckle-server - -# add wait -ENV WAIT_VERSION 2.8.0 -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait -RUN chmod +x ./wait - -# Add tini -ENV TINI_VERSION v0.19.0 -ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini -RUN chmod +x ./tini - -# Add python virtual env -WORKDIR /venv RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install \ - --no-install-suggests --no-install-recommends --yes \ - python3-venv=3.9.2-3 && \ - python3 -m venv /venv + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends \ + python3=3.9.2-3 \ + python3-pip=20.3.4-4+deb11u1 \ + tini=0.19.0-1 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* -# pip install -COPY packages/fileimport-service/requirements.txt /requirements.txt -RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait +RUN chmod +x /wait -# yarn install -WORKDIR /speckle-server +ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} + +WORKDIR /speckle-server + COPY .yarnrc.yml . COPY .yarn ./.yarn COPY package.json yarn.lock ./ WORKDIR /speckle-server/packages/fileimport-service -COPY packages/fileimport-service/package.json . +COPY packages/fileimport-service/package.json ./ RUN yarn workspaces focus --production -FROM gcr.io/distroless/nodejs:16 as production-stage +COPY packages/fileimport-service/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -WORKDIR /speckle-server/packages/fileimport-service COPY packages/fileimport-service . -COPY --from=build-stage /speckle-server/wait /wait -COPY --from=build-stage /speckle-server/tini /tini -COPY --from=build-stage /venv /venv -COPY --from=build-stage /speckle-server/node_modules/ ./node_modules/ -# Prefixing PATH with our virtual environment should seek required binaries -# from virtual environment first. -# Unsetting python home -ENV PATH=/venv/bin:${PATH}, \ - PYTHONHOME= - -ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "src/daemon.js"] +CMD ["yarn", "node", "src/daemon.js"] diff --git a/packages/frontend/Dockerfile b/packages/frontend/Dockerfile index 2bd50b291..c85c7b082 100644 --- a/packages/frontend/Dockerfile +++ b/packages/frontend/Dockerfile @@ -1,9 +1,10 @@ # NOTE: Docker context should be set to git root directory, to include the viewer -ARG SPECKLE_SERVER_VERSION=custom # build stage FROM node:16.15-bullseye-slim as build-stage +ARG SPECKLE_SERVER_VERSION=custom + WORKDIR /speckle-server COPY .yarnrc.yml . COPY .yarn ./.yarn diff --git a/packages/preview-service/Dockerfile b/packages/preview-service/Dockerfile index f1d09887c..f3a86be15 100644 --- a/packages/preview-service/Dockerfile +++ b/packages/preview-service/Dockerfile @@ -1,23 +1,19 @@ # NOTE: Docker context should be set to git root directory, to include the viewer -ARG NODE_ENV=production + # build stage FROM node:16.15-buster-slim as build-stage +ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} + WORKDIR /speckle-server - -# install wait -ENV WAIT_VERSION 2.8.0 -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait -RUN chmod +x ./wait - COPY .yarnrc.yml . COPY .yarn ./.yarn COPY package.json yarn.lock ./ -# Only copy in the relevant package.json files for the dependencies +# Onyl copy in the relevant package.json files for the dependencies COPY packages/preview-service/package.json ./packages/preview-service/ COPY packages/viewer/package.json ./packages/viewer/ COPY packages/objectloader/package.json ./packages/objectloader/ @@ -32,6 +28,8 @@ COPY packages/preview-service ./packages/preview-service/ # This way the foreach only builds the frontend and its deps RUN yarn workspaces foreach -pt run build + + FROM node:16.15-bullseye-slim as node RUN apt-get update && \ @@ -68,8 +66,10 @@ RUN apt-get update && \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -COPY --from=build-stage /speckle-server/wait /wait +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait +RUN chmod +x /wait +ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} WORKDIR /speckle-server @@ -89,4 +89,5 @@ COPY --from=build-stage /speckle-server/packages/preview-service ./preview-servi WORKDIR /speckle-server/packages/preview-service RUN yarn workspaces focus --production -ENTRYPOINT [ "tini", "--", "node", "bin/www" ] +ENTRYPOINT [ "tini", "--" ] +CMD ["yarn", "node", "bin/www"] diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index 1831d1e8d..f6e73398e 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -1,23 +1,18 @@ +FROM node:16.15-bullseye-slim as build-stage ARG NODE_ENV=production ARG SPECKLE_SERVER_VERSION=custom -ARG FILE_SIZE_LIMIT_MB=100 -ARG NODE_ENV=production -FROM node:16.15-bullseye-slim as build-stage +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends \ + tini=0.19.0-1 \ + fonts-dejavu-core=2.37-2 \ + fontconfig=2.13.1-4.2 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* WORKDIR /speckle-server -# install wait -ENV WAIT_VERSION 2.8.0 -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait -RUN chmod +x ./wait - -# install tini -ENV TINI_VERSION v0.19.0 -ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini -RUN chmod +x ./tini - -# install node packages COPY .yarnrc.yml . COPY .yarn ./.yarn COPY package.json yarn.lock ./ @@ -30,20 +25,27 @@ COPY packages/server . RUN yarn build && yarn workspaces focus --production -FROM gcr.io/distroless/nodejs:16 as production-stage +FROM node:16.15-bullseye-slim as production-stage +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} +ARG SPECKLE_SERVER_VERSION=custom -ENV FILE_SIZE_LIMIT_MB=${FILE_SIZE_LIMIT_MB}, \ - NODE_ENV=${NODE_ENV}, \ - SPECKLE_SERVER_VERSION=${SPECKLE_SERVER_VERSION} +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait +RUN chmod +x /wait WORKDIR /speckle-server -COPY --from=build-stage /speckle-server/wait /wait -COPY --from=build-stage /speckle-server/tini /tini + +COPY --from=build-stage /speckle-server/.yarnrc.yml . +COPY --from=build-stage /speckle-server/.yarn ./.yarn +COPY --from=build-stage /speckle-server/package.json /speckle-server/yarn.lock ./ COPY --from=build-stage /speckle-server/node_modules ./node_modules WORKDIR /speckle-server/packages/server + COPY --from=build-stage /speckle-server/packages/server/dist ./dist COPY --from=build-stage /speckle-server/packages/server/assets ./assets COPY --from=build-stage /speckle-server/packages/server/bin ./bin -ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "./bin/www"] +ENV FILE_SIZE_LIMIT_MB=100 +ENV SPECKLE_SERVER_VERSION=${SPECKLE_SERVER_VERSION} +CMD ["yarn", "node", "bin/www"] diff --git a/packages/webhook-service/Dockerfile b/packages/webhook-service/Dockerfile index bea623db6..f7149f544 100644 --- a/packages/webhook-service/Dockerfile +++ b/packages/webhook-service/Dockerfile @@ -1,20 +1,20 @@ -ARG NODE_ENV=production +FROM node:16.15-bullseye-slim as node -FROM node:16.15-bullseye-slim as build-stage +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends \ + tini=0.19.0-1 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait +RUN chmod +x /wait + +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} WORKDIR /speckle-server -ENV WAIT_VERSION 2.8.0 -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait -RUN chmod +x ./wait - -ENV TINI_VERSION v0.19.0 -ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini -RUN chmod +x ./tini - -# yarn install -ENV NODE_ENV=${NODE_ENV} - COPY .yarnrc.yml . COPY .yarn ./.yarn COPY package.json yarn.lock ./ @@ -23,12 +23,6 @@ WORKDIR /speckle-server/packages/webhook-service COPY packages/webhook-service/package.json . RUN yarn workspaces focus --production -FROM gcr.io/distroless/nodejs:16 as production-stage - -WORKDIR /speckle-server/packages/webhook-service COPY packages/webhook-service/src . -COPY --from=build-stage /speckle-server/wait /wait -COPY --from=build-stage /speckle-server/tini /tini -COPY --from=build-stage /speckle-server/node_modules ./node_modules -ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "main.js"] +CMD ["yarn", "node", "main.js"] diff --git a/utils/monitor-deployment/Dockerfile b/utils/monitor-deployment/Dockerfile index 9c54159a7..f57b83b5b 100644 --- a/utils/monitor-deployment/Dockerfile +++ b/utils/monitor-deployment/Dockerfile @@ -1,33 +1,14 @@ -ARG PG_CONNECTION_STRING -ARG NODE_EXTRA_CA_CERTS +FROM python:3.8-slim -FROM debian:11-slim AS build-stage - -WORKDIR /build # Add Tini ENV TINI_VERSION v0.19.0 -ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini -RUN chmod +x ./tini +ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini +RUN chmod +x /tini +ENTRYPOINT ["/tini", "--"] -# Add python virtual env -WORKDIR /venv -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install \ - --no-install-suggests --no-install-recommends --yes \ - python3-venv=3.9.2-3 && \ - python3 -m venv /venv - -COPY utils/monitor-deployment/requirements.txt /requirements.txt -RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt - -FROM gcr.io/distroless/python3-debian11:nonroot as production-stage - -ENV PG_CONNECTION_STRING=${PG_CONNECTION_STRING}, \ - NODE_EXTRA_CA_CERTS=${NODE_EXTRA_CA_CERTS} - -COPY --from=build-stage /venv /venv WORKDIR /app -COPY --from=build-stage /build/tini ./tini COPY utils/monitor-deployment . +RUN pip install --no-cache-dir -r requirements.txt -ENTRYPOINT [ "./tini", "--", "/venv/bin/python3", "-u", "src/run.py"] + +CMD ["python", "-u", "src/run.py"] diff --git a/utils/test-deployment/Dockerfile b/utils/test-deployment/Dockerfile index 09ea8c626..1303bd645 100644 --- a/utils/test-deployment/Dockerfile +++ b/utils/test-deployment/Dockerfile @@ -1,22 +1,7 @@ -ARG SPECKLE_SERVER -ARG SPECKLE_VERSION +FROM python:3.8-slim -FROM debian:11-slim AS build-stage -WORKDIR /venv -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install \ - --no-install-suggests --no-install-recommends --yes \ - python3-venv=3.9.2-3 && \ - python3 -m venv /venv +WORKDIR /speckle +COPY utils/test-deployment . +RUN pip install --no-cache-dir -r requirements.txt -COPY utils/test-deployment/requirements.txt /requirements.txt -RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt - -FROM gcr.io/distroless/python3-debian11:nonroot as production-stage - -ENV SPECKLE_SERVER=${SPECKLE_SERVER}, \ - SPECKLE_SERVER=${SPECKLE_VERSION} -COPY --from=build-stage /venv /venv -COPY utils/test-deployment /app -WORKDIR /app -ENTRYPOINT [ "/venv/bin/python3", "-u", "./run_tests.py" ] +CMD [ "./run_tests.py" ] From 7c2b92937c34f850341aa25a79803b7f15811d7c Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Wed, 17 Aug 2022 13:21:47 +0300 Subject: [PATCH 03/28] fix(frontend): some styling issues (#936) * fix(frontend): viewer controls blocking mouse input around it * fixed ugly LatestBlogposts positioning & scrollbar * fixed branch edit dialog reloading w/o saving on enter --- .../main/components/feed/LatestBlogposts.vue | 17 +++++++++-------- .../src/main/dialogs/BranchEditDialog.vue | 2 +- packages/frontend/src/main/pages/TheFeed.vue | 6 +++--- .../main/pages/stream/CommitObjectViewer.vue | 3 ++- packages/frontend/src/sass/variables.scss | 7 +++++++ 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/frontend/src/main/components/feed/LatestBlogposts.vue b/packages/frontend/src/main/components/feed/LatestBlogposts.vue index 39a0f6b26..9e0d8dbe1 100644 --- a/packages/frontend/src/main/components/feed/LatestBlogposts.vue +++ b/packages/frontend/src/main/components/feed/LatestBlogposts.vue @@ -1,11 +1,7 @@ + diff --git a/packages/frontend/src/main/dialogs/BranchEditDialog.vue b/packages/frontend/src/main/dialogs/BranchEditDialog.vue index a5e9c7d16..177b10539 100644 --- a/packages/frontend/src/main/dialogs/BranchEditDialog.vue +++ b/packages/frontend/src/main/dialogs/BranchEditDialog.vue @@ -11,7 +11,7 @@ {{ error }} - + Feed - - + + + - From 774176246e459f323b2886574ec918071b04b176 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Wed, 24 Aug 2022 11:59:30 +0300 Subject: [PATCH 14/28] feat: pretty password reset email + bugfixes (#939) --- .../frontend/src/graphql/generated/graphql.ts | 43 ++++- packages/frontend/src/graphql/user.js | 12 ++ .../useValidatablePasswordEntry.ts | 75 +++++++++ .../pages/auth/ResetPasswordFinalization.vue | 66 ++++---- .../src/main/pages/auth/TheRegistration.vue | 57 +++---- packages/frontend/src/main/router/index.js | 4 +- .../server/assets/core/typedefs/user.graphql | 28 +++- .../server/modules/auth/repositories/index.ts | 50 ++++++ packages/server/modules/core/dbSchema.ts | 36 +++++ .../server/modules/core/tests/graph.spec.js | 10 +- .../emails/services/templateFormatting.ts | 13 +- .../emails/tests/emailTemplating.spec.ts | 102 ++++++++++++ .../server/modules/pwdreset/errors/index.ts | 11 ++ packages/server/modules/pwdreset/index.js | 10 -- packages/server/modules/pwdreset/index.ts | 11 ++ .../modules/pwdreset/repositories/index.ts | 68 ++++++++ .../server/modules/pwdreset/rest/index.js | 148 ------------------ .../server/modules/pwdreset/rest/index.ts | 30 ++++ .../modules/pwdreset/services/finalize.ts | 44 ++++++ .../server/modules/pwdreset/services/index.js | 11 -- .../modules/pwdreset/services/request.ts | 110 +++++++++++++ .../modules/pwdreset/tests/pwdrest.spec.js | 1 - .../server/modules/shared/errors/index.ts | 5 + .../modules/shared/helpers/errorHelper.ts | 17 ++ packages/server/package.json | 3 + yarn.lock | 26 +++ 26 files changed, 754 insertions(+), 237 deletions(-) create mode 100644 packages/frontend/src/main/lib/auth/composables/useValidatablePasswordEntry.ts create mode 100644 packages/server/modules/auth/repositories/index.ts create mode 100644 packages/server/modules/emails/tests/emailTemplating.spec.ts create mode 100644 packages/server/modules/pwdreset/errors/index.ts delete mode 100644 packages/server/modules/pwdreset/index.js create mode 100644 packages/server/modules/pwdreset/index.ts create mode 100644 packages/server/modules/pwdreset/repositories/index.ts delete mode 100644 packages/server/modules/pwdreset/rest/index.js create mode 100644 packages/server/modules/pwdreset/rest/index.ts create mode 100644 packages/server/modules/pwdreset/services/finalize.ts delete mode 100644 packages/server/modules/pwdreset/services/index.js create mode 100644 packages/server/modules/pwdreset/services/request.ts create mode 100644 packages/server/modules/shared/helpers/errorHelper.ts diff --git a/packages/frontend/src/graphql/generated/graphql.ts b/packages/frontend/src/graphql/generated/graphql.ts index 56078b7dd..0de850864 100644 --- a/packages/frontend/src/graphql/generated/graphql.ts +++ b/packages/frontend/src/graphql/generated/graphql.ts @@ -797,6 +797,27 @@ export type ObjectCreateInput = { streamId: Scalars['String']; }; +export type PasswordStrengthCheckFeedback = { + __typename?: 'PasswordStrengthCheckFeedback'; + suggestions: Array; + warning?: Maybe; +}; + +export type PasswordStrengthCheckResults = { + __typename?: 'PasswordStrengthCheckResults'; + /** Verbal feedback to help choose better passwords. set when score <= 2. */ + feedback: PasswordStrengthCheckFeedback; + /** + * Integer from 0-4 (useful for implementing a strength bar): + * 0 too guessable: risky password. (guesses < 10^3) + * 1 very guessable: protection from throttled online attacks. (guesses < 10^6) + * 2 somewhat guessable: protection from unthrottled online attacks. (guesses < 10^8) + * 3 safely unguessable: moderate protection from offline slow-hash scenario. (guesses < 10^10) + * 4 very unguessable: strong protection from offline slow-hash scenario. (guesses >= 10^10) + */ + score: Scalars['Int']; +}; + export type PendingStreamCollaborator = { __typename?: 'PendingStreamCollaborator'; id: Scalars['String']; @@ -857,7 +878,8 @@ export type Query = { * If ID is provided, admin access is required */ user?: Maybe; - userPwdStrength?: Maybe; + /** Validate password strength */ + userPwdStrength: PasswordStrengthCheckResults; /** * Search for users and return limited metadata about them, if you have the server:user role. * The query looks for matches in name & email @@ -1780,6 +1802,13 @@ export type UserTimelineQueryVariables = Exact<{ export type UserTimelineQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, timeline?: { __typename?: 'ActivityCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Activity', id: string, actionType: string, info: Record, userId: string, streamId?: string | null, resourceId: string, resourceType: string, time: any, message: string } | null> | null } | null } | null }; +export type ValidatePasswordStrengthQueryVariables = Exact<{ + pwd: Scalars['String']; +}>; + + +export type ValidatePasswordStrengthQuery = { __typename?: 'Query', userPwdStrength: { __typename?: 'PasswordStrengthCheckResults', score: number, feedback: { __typename?: 'PasswordStrengthCheckFeedback', warning?: string | null, suggestions: Array } } }; + export type UserQueryVariables = Exact<{ id: Scalars['String']; }>; @@ -2374,6 +2403,17 @@ export const UserTimeline = gql` } } ${ActivityMainFields}`; +export const ValidatePasswordStrength = gql` + query ValidatePasswordStrength($pwd: String!) { + userPwdStrength(pwd: $pwd) { + score + feedback { + warning + suggestions + } + } +} + `; export const User = gql` query User($id: String!) { user(id: $id) { @@ -2495,6 +2535,7 @@ export const UserSearchDocument = {"kind":"Document","definitions":[{"kind":"Ope export const IsLoggedInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"IsLoggedIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const AdminUsersListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminUsersList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"query"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"query"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"registeredUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"authorizedApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const UserTimelineDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserTimeline"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"timeline"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ActivityMainFields"}}]}}]}}]}}]}},...ActivityMainFieldsFragmentDoc.definitions]} as unknown as DocumentNode; +export const ValidatePasswordStrengthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidatePasswordStrength"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pwd"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userPwdStrength"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pwd"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pwd"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"feedback"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"suggestions"}}]}}]}}]}}]} as unknown as DocumentNode; export const UserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"User"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; export const UserProfileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserProfile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]}}]} as unknown as DocumentNode; export const WebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"webhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"webhookId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"webhooks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"webhookId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"triggers"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"statusInfo"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/frontend/src/graphql/user.js b/packages/frontend/src/graphql/user.js index 8a12f9137..67a2e89a9 100644 --- a/packages/frontend/src/graphql/user.js +++ b/packages/frontend/src/graphql/user.js @@ -154,3 +154,15 @@ export const userTimelineQuery = gql` ${activityMainFieldsFragment} ` + +export const validatePasswordStrengthQuery = gql` + query ValidatePasswordStrength($pwd: String!) { + userPwdStrength(pwd: $pwd) { + score + feedback { + warning + suggestions + } + } + } +` diff --git a/packages/frontend/src/main/lib/auth/composables/useValidatablePasswordEntry.ts b/packages/frontend/src/main/lib/auth/composables/useValidatablePasswordEntry.ts new file mode 100644 index 000000000..b7792c805 --- /dev/null +++ b/packages/frontend/src/main/lib/auth/composables/useValidatablePasswordEntry.ts @@ -0,0 +1,75 @@ +import { ValidatePasswordStrengthDocument } from '@/graphql/generated/graphql' +import { Nullable } from '@/helpers/typeHelpers' +import { useApolloClient } from '@vue/apollo-composable' +import { ref, watch } from 'vue' + +export function useValidatablePasswordEntry() { + const password = ref>(null) + const passwordConfirmation = ref>(null) + /** + * Strength value from 1 to 100. Its PasswordStrengthCheckResults.score times 25. + */ + const passwordStrength = ref>(null) + const passwordSuggestion = ref>(null) + + const apollo = useApolloClient() + + /** + * Re-check password strength + */ + const updatePasswordStrength = async () => { + if (!password.value) { + passwordStrength.value = 1 + passwordSuggestion.value = null + return + } + + const result = await apollo.client.query({ + query: ValidatePasswordStrengthDocument, + variables: { pwd: password.value } + }) + + passwordStrength.value = result.data.userPwdStrength.score * 25 + passwordSuggestion.value = + result.data.userPwdStrength.feedback.suggestions[0] || null + } + + /** + * Do basic validation + */ + const validatePassword = () => { + if (!password.value) { + throw new Error('Password is empty') + } + + if (password.value !== passwordConfirmation.value) { + throw new Error('Passwords do not match') + } + } + + /** + * Asynchronously validate that the password is strong enough + */ + const validatePasswordStrength = async () => { + await updatePasswordStrength() + if ((passwordStrength.value || 0) < 50) { + throw new Error('Password too weak') + } + } + + // Wipe old suggestion, if password is changed + watch(password, () => { + passwordStrength.value = 0 + passwordSuggestion.value = null + }) + + return { + password, + passwordConfirmation, + passwordStrength, + passwordSuggestion, + updatePasswordStrength, + validatePassword, + validatePasswordStrength + } +} diff --git a/packages/frontend/src/main/pages/auth/ResetPasswordFinalization.vue b/packages/frontend/src/main/pages/auth/ResetPasswordFinalization.vue index b202f04a7..81de40b86 100644 --- a/packages/frontend/src/main/pages/auth/ResetPasswordFinalization.vue +++ b/packages/frontend/src/main/pages/auth/ResetPasswordFinalization.vue @@ -20,23 +20,24 @@ @@ -68,16 +69,27 @@ {{ - pwdSuggestions ? pwdSuggestions : form.password ? 'Looks good.' : null + passwordSuggestion + ? passwordSuggestion + : password && password === passwordConfirmation + ? 'Looks good.' + : null }} - +
Passwords do not match. - +
- + Save new password @@ -88,7 +100,7 @@ + diff --git a/packages/frontend/src/main/dialogs/NewStream.vue b/packages/frontend/src/main/dialogs/NewStream.vue index 9bf3eb2bb..b14564fff 100644 --- a/packages/frontend/src/main/dialogs/NewStream.vue +++ b/packages/frontend/src/main/dialogs/NewStream.vue @@ -18,6 +18,7 @@ - -

Invite collaborators

@@ -100,7 +96,7 @@ color="primary" block large - :disabled="!valid" + :disabled="!valid || isLoading" :loading="isLoading" elevation="0" type="submit" @@ -115,10 +111,13 @@ import { gql } from '@apollo/client/core' import { userSearchQuery } from '@/graphql/user' import { AppLocalStorage } from '@/utils/localStorage' +import StreamVisibilityToggle from '@/main/components/stream/editor/StreamVisibilityToggle.vue' +import UserAvatar from '@/main/components/common/UserAvatar.vue' export default { components: { - UserAvatar: () => import('@/main/components/common/UserAvatar') + UserAvatar, + StreamVisibilityToggle }, props: { open: { @@ -156,6 +155,7 @@ export default { search: null, nameRules: [], isPublic: true, + isDiscoverable: false, collabs: [], isLoading: false, users: null @@ -210,6 +210,7 @@ export default { myStream: { name: this.name, isPublic: this.isPublic, + isDiscoverable: this.isDiscoverable, description: this.description, withContributors: collabIds } @@ -223,8 +224,9 @@ export default { this.$eventHub.$emit('notification', { text: e.message }) + } finally { + this.isLoading = false } - this.isLoading = false } } } diff --git a/packages/frontend/src/main/pages/stream/TheSettings.vue b/packages/frontend/src/main/pages/stream/TheSettings.vue index 7b591a5c5..6fe87973e 100644 --- a/packages/frontend/src/main/pages/stream/TheSettings.vue +++ b/packages/frontend/src/main/pages/stream/TheSettings.vue @@ -51,18 +51,10 @@ :disabled="stream.role !== 'stream:owner'" />

Privacy

-

Comments

@@ -179,11 +171,14 @@ import { STANDARD_PORTAL_KEYS, buildPortalStateMixin } from '@/main/utils/portalStateManager' +import SectionCard from '@/main/components/common/SectionCard.vue' +import StreamVisibilityToggle from '@/main/components/stream/editor/StreamVisibilityToggle.vue' export default { name: 'TheSettings', components: { - SectionCard: () => import('@/main/components/common/SectionCard') + SectionCard, + StreamVisibilityToggle }, mixins: [buildPortalStateMixin([STANDARD_PORTAL_KEYS.Toolbar], 'stream-settings', 1)], apollo: { @@ -195,6 +190,7 @@ export default { name description isPublic + isDiscoverable allowPublicComments role } @@ -213,6 +209,7 @@ export default { name: this.name, description: this.description, isPublic: this.isPublic, + isDiscoverable: this.isDiscoverable, allowPublicComments: this.allowPublicComments } = stream) @@ -230,6 +227,7 @@ export default { streamNameConfirm: '', description: null, isPublic: true, + isDiscoverable: false, allowPublicComments: true, validation: { nameRules: [(v) => !!v || 'A stream must have a name!'] @@ -238,13 +236,17 @@ export default { computed: { canSave() { return ( - this.stream.role === 'stream:owner' && + !this.isEditDisabled && this.valid && (this.name !== this.stream.name || this.description !== this.stream.description || this.isPublic !== this.stream.isPublic || - this.allowPublicComments !== this.stream.allowPublicComments) + this.allowPublicComments !== this.stream.allowPublicComments || + this.isDiscoverable !== this.stream.isDiscoverable) ) + }, + isEditDisabled() { + return this.stream?.role !== 'stream:owner' } }, watch: { @@ -272,7 +274,8 @@ export default { name: this.name, description: this.description, isPublic: this.isPublic, - allowPublicComments: this.allowPublicComments + allowPublicComments: this.allowPublicComments, + isDiscoverable: this.isDiscoverable } } }) diff --git a/packages/server/assets/core/typedefs/common.graphql b/packages/server/assets/core/typedefs/common.graphql index e73f1afe1..2ac6201d9 100644 --- a/packages/server/assets/core/typedefs/common.graphql +++ b/packages/server/assets/core/typedefs/common.graphql @@ -26,3 +26,8 @@ type SmartTextEditorValue { """ attachments: [BlobMetadata!] } + +enum SortDirection { + ASC + DESC +} diff --git a/packages/server/assets/core/typedefs/streams.graphql b/packages/server/assets/core/typedefs/streams.graphql index 34e604037..59e0d2c98 100644 --- a/packages/server/assets/core/typedefs/streams.graphql +++ b/packages/server/assets/core/typedefs/streams.graphql @@ -11,6 +11,9 @@ extend type Query { streams(query: String, limit: Int = 25, cursor: String): StreamCollection @hasScope(scope: "streams:read") + """ + All the streams of the server. Available to admins only. + """ adminStreams( offset: Int = 0 query: String @@ -18,13 +21,33 @@ extend type Query { visibility: String limit: Int = 25 ): StreamCollection @hasRole(role: "server:admin") + + """ + All of the discoverable streams of the server + """ + discoverableStreams( + limit: Int! = 25 + cursor: String + """ + Defaults to sorting by creation date in a descending order + """ + sort: DiscoverableStreamsSortingInput + ): StreamCollection } type Stream { id: String! name: String! description: String + """ + Whether the stream can be viewed by non-contributors + """ isPublic: Boolean! + """ + Whether the stream (if public) can be found on public stream exploration pages + and searches + """ + isDiscoverable: Boolean! allowPublicComments: Boolean! """ Your role for this stream. `null` if request is not authenticated, or the stream is not explicitly shared with you. @@ -189,8 +212,16 @@ extend type Subscription { input StreamCreateInput { name: String description: String + """ + Whether the stream can be viewed by non-contributors + """ isPublic: Boolean """ + Whether the stream (if public) can be found on public stream exploration pages + and searches + """ + isDiscoverable: Boolean + """ Optionally specify user IDs of users that you want to invite to be contributors to this stream """ withContributors: [String!] @@ -200,7 +231,15 @@ input StreamUpdateInput { id: String! name: String description: String + """ + Whether the stream can be viewed by non-contributors + """ isPublic: Boolean + """ + Whether the stream (if public) can be found on public stream exploration pages + and searches + """ + isDiscoverable: Boolean allowPublicComments: Boolean } @@ -214,3 +253,13 @@ input StreamRevokePermissionInput { streamId: String! userId: String! } + +input DiscoverableStreamsSortingInput { + type: DiscoverableStreamsSortType! + direction: SortDirection! +} + +enum DiscoverableStreamsSortType { + FAVORITES_COUNT + CREATED_DATE +} diff --git a/packages/server/codegen.yml b/packages/server/codegen.yml index 730539e18..891658442 100644 --- a/packages/server/codegen.yml +++ b/packages/server/codegen.yml @@ -9,12 +9,21 @@ generates: plugins: - 'typescript' - 'typescript-resolvers' + config: + contextType: '@/modules/shared/helpers/typeHelper#GraphQLContext' + scalars: + JSONObject: Record + DateTime: string test/graphql/generated/graphql.ts: plugins: - 'typescript' - 'typescript-operations' documents: - 'test/graphql/*.{js,ts}' + config: + scalars: + JSONObject: Record + DateTime: string require: - ts-node/register - tsconfig-paths/register diff --git a/packages/server/modules/auth/strategies/local.js b/packages/server/modules/auth/strategies/local.js index 3b617da47..7491494c8 100644 --- a/packages/server/modules/auth/strategies/local.js +++ b/packages/server/modules/auth/strategies/local.js @@ -84,7 +84,7 @@ module.exports = async (app, session, sessionAppId, finalizeAuth) => { throw new Error('This server is invite only. Please provide an invite id.') // 2. if you have an invite it must be valid, both for invite only and public servers - /** @type {import('@/modules/serverinvites/repositories').ServerInviteRecord} */ + /** @type {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} */ let invite if (req.session.token) { invite = await validateServerInvite(user.email, req.session.token) diff --git a/packages/server/modules/comments/tests/comments.graph.spec.js b/packages/server/modules/comments/tests/comments.graph.spec.js index fb3e08f50..d27d0bded 100644 --- a/packages/server/modules/comments/tests/comments.graph.spec.js +++ b/packages/server/modules/comments/tests/comments.graph.spec.js @@ -810,7 +810,7 @@ describe('Graphql @comments', () => { }) await updateStream({ ...publicStreamWithPublicComments, - streamId: publicStreamWithPublicComments.id, + id: publicStreamWithPublicComments.id, allowPublicComments: true }) }) diff --git a/packages/server/modules/core/dbSchema.ts b/packages/server/modules/core/dbSchema.ts index 3328b4ab4..b2644ac43 100644 --- a/packages/server/modules/core/dbSchema.ts +++ b/packages/server/modules/core/dbSchema.ts @@ -32,6 +32,11 @@ type InnerSchemaConfig = { col: { [colName in C]: string } + + /** + * All of the column names in an array + */ + cols: string[] } type SchemaConfigParams = { @@ -51,17 +56,21 @@ function buildTableHelper( function buildInnerSchemaConfig( params: SchemaConfigParams = {} ): InnerSchemaConfig { + const colName = (col: string) => + params.withoutTablePrefix ? col : `${tableName}.${col}` + return { name: tableName, knex: () => knex(tableName), col: reduce( columns, (prev, curr) => { - prev[curr] = params.withoutTablePrefix ? curr : `${tableName}.${curr}` + prev[curr] = colName(curr) return prev }, {} as Record - ) + ), + cols: columns.map(colName) } } @@ -71,23 +80,6 @@ function buildTableHelper( } } -/* - * TABLE RECORD TYPES - */ - -export type ServerInviteRecord = { - id: string - target: string - inviterId: string - createdAt?: Date - used?: boolean - message?: string - resourceTarget?: string - resourceId?: string - role?: string - token: string -} - /* * TABLE HELPERS * The generated helpers are used like this: @@ -108,7 +100,8 @@ export const Streams = buildTableHelper('streams', [ 'clonedFrom', 'createdAt', 'updatedAt', - 'allowPublicComments' + 'allowPublicComments', + 'isDiscoverable' ]) export const StreamAcl = buildTableHelper('stream_acl', [ diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index b3f8d43f9..f4875cbef 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -1,4 +1,5 @@ import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql'; +import { GraphQLContext } from '@/modules/shared/helpers/typeHelper'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -13,9 +14,9 @@ export type Scalars = { Int: number; Float: number; BigInt: any; - DateTime: any; + DateTime: string; EmailAddress: any; - JSONObject: any; + JSONObject: Record; }; export type Activity = { @@ -363,6 +364,16 @@ export type CommitUpdateInput = { streamId: Scalars['String']; }; +export enum DiscoverableStreamsSortType { + CreatedDate = 'CREATED_DATE', + FavoritesCount = 'FAVORITES_COUNT' +} + +export type DiscoverableStreamsSortingInput = { + direction: SortDirection; + type: DiscoverableStreamsSortType; +}; + export type FileUpload = { __typename?: 'FileUpload'; branchName?: Maybe; @@ -786,6 +797,7 @@ export type Query = { __typename?: 'Query'; /** Stare into the void. */ _?: Maybe; + /** All the streams of the server. Available to admins only. */ adminStreams?: Maybe; /** * Get all (or search for specific) users, registered or invited, from the server in a paginated view. @@ -803,6 +815,8 @@ export type Query = { * - get the comments targeting any of a set of provided resources (comments/objects): **pass in an array of resources.** */ comments?: Maybe; + /** All of the discoverable streams of the server */ + discoverableStreams?: Maybe; serverInfo: ServerInfo; serverStats: ServerStats; /** @@ -869,6 +883,13 @@ export type QueryCommentsArgs = { }; +export type QueryDiscoverableStreamsArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']; + sort?: InputMaybe; +}; + + export type QueryStreamArgs = { id: Scalars['String']; }; @@ -1045,6 +1066,11 @@ export type SmartTextEditorValue = { version: Scalars['String']; }; +export enum SortDirection { + Asc = 'ASC', + Desc = 'DESC' +} + export type Stream = { __typename?: 'Stream'; /** All the recent activity on this stream in chronological order */ @@ -1079,6 +1105,12 @@ export type Stream = { /** Returns a list of all the file uploads for this stream. */ fileUploads?: Maybe>>; id: Scalars['String']; + /** + * Whether the stream (if public) can be found on public stream exploration pages + * and searches + */ + isDiscoverable: Scalars['Boolean']; + /** Whether the stream can be viewed by non-contributors */ isPublic: Scalars['Boolean']; name: Scalars['String']; object?: Maybe; @@ -1167,6 +1199,12 @@ export type StreamCollection = { export type StreamCreateInput = { description?: InputMaybe; + /** + * Whether the stream (if public) can be found on public stream exploration pages + * and searches + */ + isDiscoverable?: InputMaybe; + /** Whether the stream can be viewed by non-contributors */ isPublic?: InputMaybe; name?: InputMaybe; /** Optionally specify user IDs of users that you want to invite to be contributors to this stream */ @@ -1197,6 +1235,12 @@ export type StreamUpdateInput = { allowPublicComments?: InputMaybe; description?: InputMaybe; id: Scalars['String']; + /** + * Whether the stream (if public) can be found on public stream exploration pages + * and searches + */ + isDiscoverable?: InputMaybe; + /** Whether the stream can be viewed by non-contributors */ isPublic?: InputMaybe; name?: InputMaybe; }; @@ -1593,6 +1637,8 @@ export type ResolversTypes = { CommitReceivedInput: CommitReceivedInput; CommitUpdateInput: CommitUpdateInput; DateTime: ResolverTypeWrapper; + DiscoverableStreamsSortType: DiscoverableStreamsSortType; + DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput; EmailAddress: ResolverTypeWrapper; FileUpload: ResolverTypeWrapper; ID: ResolverTypeWrapper; @@ -1619,6 +1665,7 @@ export type ResolversTypes = { ServerInviteCreateInput: ServerInviteCreateInput; ServerStats: ResolverTypeWrapper; SmartTextEditorValue: ResolverTypeWrapper; + SortDirection: SortDirection; Stream: ResolverTypeWrapper; StreamCollaborator: ResolverTypeWrapper; StreamCollection: ResolverTypeWrapper; @@ -1680,6 +1727,7 @@ export type ResolversParentTypes = { CommitReceivedInput: CommitReceivedInput; CommitUpdateInput: CommitUpdateInput; DateTime: Scalars['DateTime']; + DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput; EmailAddress: Scalars['EmailAddress']; FileUpload: FileUpload; ID: Scalars['ID']; @@ -1733,27 +1781,27 @@ export type HasRoleDirectiveArgs = { role: Scalars['String']; }; -export type HasRoleDirectiveResolver = DirectiveResolverFn; +export type HasRoleDirectiveResolver = DirectiveResolverFn; export type HasScopeDirectiveArgs = { scope: Scalars['String']; }; -export type HasScopeDirectiveResolver = DirectiveResolverFn; +export type HasScopeDirectiveResolver = DirectiveResolverFn; export type HasScopesDirectiveArgs = { scopes: Array>; }; -export type HasScopesDirectiveResolver = DirectiveResolverFn; +export type HasScopesDirectiveResolver = DirectiveResolverFn; export type HasStreamRoleDirectiveArgs = { role: StreamRole; }; -export type HasStreamRoleDirectiveResolver = DirectiveResolverFn; +export type HasStreamRoleDirectiveResolver = DirectiveResolverFn; -export type ActivityResolvers = { +export type ActivityResolvers = { actionType?: Resolver; id?: Resolver; info?: Resolver; @@ -1766,27 +1814,27 @@ export type ActivityResolvers; }; -export type ActivityCollectionResolvers = { +export type ActivityCollectionResolvers = { cursor?: Resolver, ParentType, ContextType>; items?: Resolver>>, ParentType, ContextType>; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type AdminUsersListCollectionResolvers = { +export type AdminUsersListCollectionResolvers = { items?: Resolver, ParentType, ContextType>; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type AdminUsersListItemResolvers = { +export type AdminUsersListItemResolvers = { id?: Resolver; invitedUser?: Resolver, ParentType, ContextType>; registeredUser?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; -export type ApiTokenResolvers = { +export type ApiTokenResolvers = { createdAt?: Resolver; id?: Resolver; lastChars?: Resolver; @@ -1797,14 +1845,14 @@ export type ApiTokenResolvers; }; -export type AppAuthorResolvers = { +export type AppAuthorResolvers = { avatar?: Resolver, ParentType, ContextType>; id?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; -export type AuthStrategyResolvers = { +export type AuthStrategyResolvers = { color?: Resolver, ParentType, ContextType>; icon?: Resolver; id?: Resolver; @@ -1817,7 +1865,7 @@ export interface BigIntScalarConfig extends GraphQLScalarTypeConfig = { +export type BlobMetadataResolvers = { createdAt?: Resolver; fileHash?: Resolver, ParentType, ContextType>; fileName?: Resolver; @@ -1831,7 +1879,7 @@ export type BlobMetadataResolvers; }; -export type BlobMetadataCollectionResolvers = { +export type BlobMetadataCollectionResolvers = { cursor?: Resolver, ParentType, ContextType>; items?: Resolver>, ParentType, ContextType>; totalCount?: Resolver; @@ -1839,7 +1887,7 @@ export type BlobMetadataCollectionResolvers; }; -export type BranchResolvers = { +export type BranchResolvers = { activity?: Resolver, ParentType, ContextType, RequireFields>; author?: Resolver, ParentType, ContextType>; commits?: Resolver, ParentType, ContextType, RequireFields>; @@ -1850,14 +1898,14 @@ export type BranchResolvers; }; -export type BranchCollectionResolvers = { +export type BranchCollectionResolvers = { cursor?: Resolver, ParentType, ContextType>; items?: Resolver>>, ParentType, ContextType>; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type CommentResolvers = { +export type CommentResolvers = { archived?: Resolver; authorId?: Resolver; createdAt?: Resolver, ParentType, ContextType>; @@ -1873,27 +1921,27 @@ export type CommentResolvers; }; -export type CommentActivityMessageResolvers = { +export type CommentActivityMessageResolvers = { comment?: Resolver; type?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type CommentCollectionResolvers = { +export type CommentCollectionResolvers = { cursor?: Resolver, ParentType, ContextType>; items?: Resolver, ParentType, ContextType>; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type CommentThreadActivityMessageResolvers = { +export type CommentThreadActivityMessageResolvers = { data?: Resolver, ParentType, ContextType>; reply?: Resolver, ParentType, ContextType>; type?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type CommitResolvers = { +export type CommitResolvers = { activity?: Resolver, ParentType, ContextType, RequireFields>; authorAvatar?: Resolver, ParentType, ContextType>; authorId?: Resolver, ParentType, ContextType>; @@ -1910,21 +1958,21 @@ export type CommitResolvers; }; -export type CommitCollectionResolvers = { +export type CommitCollectionResolvers = { cursor?: Resolver, ParentType, ContextType>; items?: Resolver>>, ParentType, ContextType>; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type CommitCollectionUserResolvers = { +export type CommitCollectionUserResolvers = { cursor?: Resolver, ParentType, ContextType>; items?: Resolver>>, ParentType, ContextType>; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type CommitCollectionUserNodeResolvers = { +export type CommitCollectionUserNodeResolvers = { branchName?: Resolver, ParentType, ContextType>; commentCount?: Resolver; createdAt?: Resolver, ParentType, ContextType>; @@ -1947,7 +1995,7 @@ export interface EmailAddressScalarConfig extends GraphQLScalarTypeConfig = { +export type FileUploadResolvers = { branchName?: Resolver, ParentType, ContextType>; convertedCommitId?: Resolver, ParentType, ContextType>; convertedLastUpdate?: Resolver; @@ -1968,7 +2016,7 @@ export interface JsonObjectScalarConfig extends GraphQLScalarTypeConfig = { +export type LimitedUserResolvers = { avatar?: Resolver, ParentType, ContextType>; bio?: Resolver, ParentType, ContextType>; company?: Resolver, ParentType, ContextType>; @@ -1978,7 +2026,7 @@ export type LimitedUserResolvers; }; -export type MutationResolvers = { +export type MutationResolvers = { _?: Resolver, ParentType, ContextType>; adminDeleteUser?: Resolver>; apiTokenCreate?: Resolver>; @@ -2027,7 +2075,7 @@ export type MutationResolvers>; }; -export type ObjectResolvers = { +export type ObjectResolvers = { applicationId?: Resolver, ParentType, ContextType>; children?: Resolver>; commentCount?: Resolver; @@ -2039,14 +2087,14 @@ export type ObjectResolvers; }; -export type ObjectCollectionResolvers = { +export type ObjectCollectionResolvers = { cursor?: Resolver, ParentType, ContextType>; objects?: Resolver>, ParentType, ContextType>; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type PendingStreamCollaboratorResolvers = { +export type PendingStreamCollaboratorResolvers = { id?: Resolver; inviteId?: Resolver; invitedBy?: Resolver; @@ -2059,7 +2107,7 @@ export type PendingStreamCollaboratorResolvers; }; -export type QueryResolvers = { +export type QueryResolvers = { _?: Resolver, ParentType, ContextType>; adminStreams?: Resolver, ParentType, ContextType, RequireFields>; adminUsers?: Resolver, ParentType, ContextType, RequireFields>; @@ -2067,6 +2115,7 @@ export type QueryResolvers>>, ParentType, ContextType>; comment?: Resolver, ParentType, ContextType, RequireFields>; comments?: Resolver, ParentType, ContextType, RequireFields>; + discoverableStreams?: Resolver, ParentType, ContextType, RequireFields>; serverInfo?: Resolver; serverStats?: Resolver; stream?: Resolver, ParentType, ContextType, RequireFields>; @@ -2078,26 +2127,26 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; }; -export type ResourceIdentifierResolvers = { +export type ResourceIdentifierResolvers = { resourceId?: Resolver; resourceType?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type RoleResolvers = { +export type RoleResolvers = { description?: Resolver; name?: Resolver; resourceTarget?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type ScopeResolvers = { +export type ScopeResolvers = { description?: Resolver; name?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type ServerAppResolvers = { +export type ServerAppResolvers = { author?: Resolver, ParentType, ContextType>; createdAt?: Resolver; description?: Resolver, ParentType, ContextType>; @@ -2113,7 +2162,7 @@ export type ServerAppResolvers; }; -export type ServerAppListItemResolvers = { +export type ServerAppListItemResolvers = { author?: Resolver, ParentType, ContextType>; description?: Resolver, ParentType, ContextType>; id?: Resolver; @@ -2125,7 +2174,7 @@ export type ServerAppListItemResolvers; }; -export type ServerInfoResolvers = { +export type ServerInfoResolvers = { adminContact?: Resolver, ParentType, ContextType>; authStrategies?: Resolver>>, ParentType, ContextType>; blobSizeLimitBytes?: Resolver; @@ -2141,14 +2190,14 @@ export type ServerInfoResolvers; }; -export type ServerInviteResolvers = { +export type ServerInviteResolvers = { email?: Resolver; id?: Resolver; invitedBy?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type ServerStatsResolvers = { +export type ServerStatsResolvers = { commitHistory?: Resolver>>, ParentType, ContextType>; objectHistory?: Resolver>>, ParentType, ContextType>; streamHistory?: Resolver>>, ParentType, ContextType>; @@ -2160,7 +2209,7 @@ export type ServerStatsResolvers; }; -export type SmartTextEditorValueResolvers = { +export type SmartTextEditorValueResolvers = { attachments?: Resolver>, ParentType, ContextType>; doc?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -2168,7 +2217,7 @@ export type SmartTextEditorValueResolvers; }; -export type StreamResolvers = { +export type StreamResolvers = { activity?: Resolver, ParentType, ContextType, RequireFields>; allowPublicComments?: Resolver; blob?: Resolver, ParentType, ContextType, RequireFields>; @@ -2186,6 +2235,7 @@ export type StreamResolvers, ParentType, ContextType, RequireFields>; fileUploads?: Resolver>>, ParentType, ContextType>; id?: Resolver; + isDiscoverable?: Resolver; isPublic?: Resolver; name?: Resolver; object?: Resolver, ParentType, ContextType, RequireFields>; @@ -2197,7 +2247,7 @@ export type StreamResolvers; }; -export type StreamCollaboratorResolvers = { +export type StreamCollaboratorResolvers = { avatar?: Resolver, ParentType, ContextType>; company?: Resolver, ParentType, ContextType>; id?: Resolver; @@ -2206,14 +2256,14 @@ export type StreamCollaboratorResolvers; }; -export type StreamCollectionResolvers = { +export type StreamCollectionResolvers = { cursor?: Resolver, ParentType, ContextType>; items?: Resolver>, ParentType, ContextType>; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type SubscriptionResolvers = { +export type SubscriptionResolvers = { _?: SubscriptionResolver, "_", ParentType, ContextType>; branchCreated?: SubscriptionResolver, "branchCreated", ParentType, ContextType, RequireFields>; branchDeleted?: SubscriptionResolver, "branchDeleted", ParentType, ContextType, RequireFields>; @@ -2230,7 +2280,7 @@ export type SubscriptionResolvers, "userViewerActivity", ParentType, ContextType, RequireFields>; }; -export type UserResolvers = { +export type UserResolvers = { activity?: Resolver, ParentType, ContextType, RequireFields>; apiTokens?: Resolver>>, ParentType, ContextType>; authorizedApps?: Resolver>>, ParentType, ContextType>; @@ -2252,13 +2302,13 @@ export type UserResolvers; }; -export type UserSearchResultCollectionResolvers = { +export type UserSearchResultCollectionResolvers = { cursor?: Resolver, ParentType, ContextType>; items?: Resolver>>, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; -export type WebhookResolvers = { +export type WebhookResolvers = { description?: Resolver, ParentType, ContextType>; enabled?: Resolver, ParentType, ContextType>; history?: Resolver, ParentType, ContextType, RequireFields>; @@ -2269,13 +2319,13 @@ export type WebhookResolvers; }; -export type WebhookCollectionResolvers = { +export type WebhookCollectionResolvers = { items?: Resolver>>, ParentType, ContextType>; totalCount?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; -export type WebhookEventResolvers = { +export type WebhookEventResolvers = { id?: Resolver; lastUpdate?: Resolver; payload?: Resolver; @@ -2286,13 +2336,13 @@ export type WebhookEventResolvers; }; -export type WebhookEventCollectionResolvers = { +export type WebhookEventCollectionResolvers = { items?: Resolver>>, ParentType, ContextType>; totalCount?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; -export type Resolvers = { +export type Resolvers = { Activity?: ActivityResolvers; ActivityCollection?: ActivityCollectionResolvers; AdminUsersListCollection?: AdminUsersListCollectionResolvers; @@ -2344,7 +2394,7 @@ export type Resolvers = { WebhookEventCollection?: WebhookEventCollectionResolvers; }; -export type DirectiveResolvers = { +export type DirectiveResolvers = { hasRole?: HasRoleDirectiveResolver; hasScope?: HasScopeDirectiveResolver; hasScopes?: HasScopesDirectiveResolver; diff --git a/packages/server/modules/core/graph/resolvers/streams.js b/packages/server/modules/core/graph/resolvers/streams.js index 5643f54e5..4d7f0b022 100644 --- a/packages/server/modules/core/graph/resolvers/streams.js +++ b/packages/server/modules/core/graph/resolvers/streams.js @@ -42,6 +42,10 @@ const { } = require('@/modules/core/services/streams/streamAccessService') const { Roles } = require('@/modules/core/helpers/mainConstants') const { StreamInvalidAccessError } = require('@/modules/core/errors/stream') +const { + getDiscoverableStreams +} = require('@/modules/core/services/streams/discoverableStreams') +const { has } = require('lodash') // subscription events const USER_STREAM_ADDED = StreamPubsubEvents.UserStreamAdded @@ -90,6 +94,9 @@ const _deleteStream = async (parent, args, context) => { return true } +/** + * @type {import('@/modules/core/graph/generated/graphql').Resolvers} + */ module.exports = { Query: { async stream(parent, args, context) { @@ -128,6 +135,10 @@ module.exports = { return { totalCount, cursor, items: streams } }, + async discoverableStreams(parent, args) { + return await getDiscoverableStreams(args) + }, + async adminStreams(parent, args) { if (args.limit && args.limit > 50) throw new UserInputError('Cannot return more than 50 items at a time.') @@ -164,6 +175,21 @@ module.exports = { const { id: streamId } = parent return await getStreamFavoritesCount({ ctx, streamId }) + }, + + async isDiscoverable(parent) { + const { isPublic, isDiscoverable } = parent + + if (!isPublic) return false + return isDiscoverable + }, + + async role(parent, _args, ctx) { + // If role already resolved, return that + if (has(parent, 'role')) return parent.role + + // Otherwise resolve it now through a dataloader + return await ctx.loaders.streams.getRole.load(parent.id) } }, User: { @@ -232,15 +258,9 @@ module.exports = { await authorizeResolver(context.userId, args.stream.id, 'stream:owner') const oldValue = await getStream({ streamId: args.stream.id }) - const update = { - streamId: args.stream.id, - name: args.stream.name, - description: args.stream.description, - isPublic: args.stream.isPublic, - allowPublicComments: args.stream.allowPublicComments - } - await updateStream(update) + const { stream } = args + await updateStream(stream) await saveActivity({ streamId: args.stream.id, @@ -394,8 +414,6 @@ module.exports = { PendingStreamCollaborator: { /** * @param {import('@/modules/serverinvites/services/inviteRetrievalService').PendingStreamCollaboratorGraphQLType} parent - * @param {Object} _args - * @param {import('@/modules/shared/index').GraphQLContext} ctx */ async invitedBy(parent, _args, ctx) { const { invitedById } = parent @@ -406,8 +424,6 @@ module.exports = { }, /** * @param {import('@/modules/serverinvites/services/inviteRetrievalService').PendingStreamCollaboratorGraphQLType} parent - * @param {Object} _args - * @param {import('@/modules/shared/index').GraphQLContext} ctx */ async streamName(parent, _args, ctx) { const { streamId } = parent @@ -416,8 +432,6 @@ module.exports = { }, /** * @param {import('@/modules/serverinvites/services/inviteRetrievalService').PendingStreamCollaboratorGraphQLType} parent - * @param {Object} _args - * @param {import('@/modules/shared/index').GraphQLContext} ctx */ async token(parent, _args, ctx) { const authedUserId = ctx.userId diff --git a/packages/server/modules/core/helpers/types.ts b/packages/server/modules/core/helpers/types.ts index a13f8f76a..2dc9318a0 100644 --- a/packages/server/modules/core/helpers/types.ts +++ b/packages/server/modules/core/helpers/types.ts @@ -40,6 +40,7 @@ export type StreamRecord = { createdAt: Date updatedAt: Date allowPublicComments: boolean + isDiscoverable: boolean } export type StreamAclRecord = { diff --git a/packages/server/modules/core/loaders.js b/packages/server/modules/core/loaders.js deleted file mode 100644 index 5f8db3e6b..000000000 --- a/packages/server/modules/core/loaders.js +++ /dev/null @@ -1,99 +0,0 @@ -const DataLoader = require('dataloader') -const { - getBatchUserFavoriteData, - getBatchStreamFavoritesCounts, - getOwnedFavoritesCountByUserIds, - getStreams -} = require('@/modules/core/repositories/streams') -const { getUsers } = require('@/modules/core/repositories/users') -const { keyBy } = require('lodash') -const { getInvites } = require('@/modules/serverinvites/repositories') - -/** - * All DataLoaders available on the GQL ctx object - * @typedef {Object} RequestDataLoaders - * @property {{ - * getUserFavoriteData: DataLoader, - * getFavoritesCount: DataLoader, - * getOwnedFavoritesCount: DataLoader, - * getStream: DataLoader - * }} streams - * @property {{ - * getUser: DataLoader - * }} users - * @property {{ - * getInvite: DataLoader - * }} invites - */ - -module.exports = { - /** - * Build request-scoped dataloaders - * @param {import('@/modules/shared/index').AuthContextPart} ctx GraphQL context w/o loaders - * @returns {RequestDataLoaders} - */ - buildRequestLoaders(ctx) { - const userId = ctx.userId - - return { - streams: { - /** - * Get favorite metadata for a specific stream and user - */ - getUserFavoriteData: new DataLoader(async (streamIds) => { - if (!userId) { - return streamIds.map(() => null) - } - - const results = await getBatchUserFavoriteData({ userId, streamIds }) - return streamIds.map((k) => results[k]) - }), - - /** - * Get amount of favorites for a specific stream - */ - getFavoritesCount: new DataLoader(async (streamIds) => { - const results = await getBatchStreamFavoritesCounts(streamIds) - return streamIds.map((k) => results[k] || 0) - }), - - /** - * Get total amount of favorites of owned streams - */ - getOwnedFavoritesCount: new DataLoader(async (userIds) => { - const results = await getOwnedFavoritesCountByUserIds(userIds) - return userIds.map((i) => results[i]) - }), - - /** - * Get stream from DB - * - * Note: Considering the difficulty of writing a single query that queries for multiple stream IDs - * and multiple user IDs also, currently this dataloader will only use a single userId - */ - getStream: new DataLoader(async (streamIds) => { - const results = keyBy(await getStreams(streamIds), 'id') - return streamIds.map((i) => results[i]) - }) - }, - users: { - /** - * Get user from DB - */ - getUser: new DataLoader(async (userIds) => { - const results = keyBy(await getUsers(userIds), 'id') - return userIds.map((i) => results[i]) - }) - }, - invites: { - /** - * Get invite from DB - */ - getInvite: new DataLoader(async (inviteIds) => { - const results = keyBy(await getInvites(inviteIds), 'id') - return inviteIds.map((i) => results[i]) - }) - } - } - } -} diff --git a/packages/server/modules/core/loaders.ts b/packages/server/modules/core/loaders.ts new file mode 100644 index 000000000..28b90b744 --- /dev/null +++ b/packages/server/modules/core/loaders.ts @@ -0,0 +1,107 @@ +import DataLoader from 'dataloader' +import { + getBatchUserFavoriteData, + getBatchStreamFavoritesCounts, + getOwnedFavoritesCountByUserIds, + getStreams, + getStreamRoles +} from '@/modules/core/repositories/streams' +import { getUsers } from '@/modules/core/repositories/users' +import { keyBy } from 'lodash' +import { getInvites } from '@/modules/serverinvites/repositories' +import { AuthContext } from '@/modules/shared/authz' +import { + LimitedUserRecord, + StreamFavoriteRecord, + StreamRecord +} from '@/modules/core/helpers/types' +import { Nullable } from '@/modules/shared/helpers/typeHelper' +import { ServerInviteRecord } from '@/modules/serverinvites/helpers/types' + +/** + * Build request-scoped dataloaders + * @param ctx GraphQL context w/o loaders + */ +export function buildRequestLoaders(ctx: AuthContext) { + const userId = ctx.userId + + return { + streams: { + /** + * Get favorite metadata for a specific stream and user + */ + getUserFavoriteData: new DataLoader>( + async (streamIds) => { + if (!userId) { + return streamIds.map(() => null) + } + + const results = await getBatchUserFavoriteData({ + userId, + streamIds: streamIds.slice() + }) + return streamIds.map((k) => results[k]) + } + ), + + /** + * Get amount of favorites for a specific stream + */ + getFavoritesCount: new DataLoader(async (streamIds) => { + const results = await getBatchStreamFavoritesCounts(streamIds.slice()) + return streamIds.map((k) => results[k] || 0) + }), + + /** + * Get total amount of favorites of owned streams + */ + getOwnedFavoritesCount: new DataLoader(async (userIds) => { + const results = await getOwnedFavoritesCountByUserIds(userIds.slice()) + return userIds.map((i) => results[i] || 0) + }), + + /** + * Get stream from DB + * + * Note: Considering the difficulty of writing a single query that queries for multiple stream IDs + * and multiple user IDs also, currently this dataloader will only use a single userId + */ + getStream: new DataLoader>(async (streamIds) => { + const results = keyBy(await getStreams(streamIds.slice()), 'id') + return streamIds.map((i) => results[i] || null) + }), + + /** + * Get stream role from DB + */ + getRole: new DataLoader>(async (streamIds) => { + if (!userId) return streamIds.map(() => null) + + const results = await getStreamRoles(userId, streamIds.slice()) + return streamIds.map((id) => results[id]) + }) + }, + users: { + /** + * Get user from DB + */ + getUser: new DataLoader>(async (userIds) => { + const results = keyBy(await getUsers(userIds.slice()), 'id') + return userIds.map((i) => results[i] || null) + }) + }, + invites: { + /** + * Get invite from DB + */ + getInvite: new DataLoader>( + async (inviteIds) => { + const results = keyBy(await getInvites(inviteIds), 'id') + return inviteIds.map((i) => results[i] || null) + } + ) + } + } +} + +export type RequestDataLoaders = ReturnType diff --git a/packages/server/modules/core/migrations/20220819091523_add_stream_discoverable_field.ts b/packages/server/modules/core/migrations/20220819091523_add_stream_discoverable_field.ts new file mode 100644 index 000000000..5102c77be --- /dev/null +++ b/packages/server/modules/core/migrations/20220819091523_add_stream_discoverable_field.ts @@ -0,0 +1,16 @@ +import { Knex } from 'knex' + +const TABLE_NAME = 'streams' +const COL_NAME = 'isDiscoverable' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, (table) => { + table.boolean(COL_NAME).defaultTo(false).notNullable() + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, (table) => { + table.dropColumn(COL_NAME) + }) +} diff --git a/packages/server/modules/core/migrations/20220823100915_migrate_streams_to_lower_precision_timestamps.ts b/packages/server/modules/core/migrations/20220823100915_migrate_streams_to_lower_precision_timestamps.ts new file mode 100644 index 000000000..bf928d532 --- /dev/null +++ b/packages/server/modules/core/migrations/20220823100915_migrate_streams_to_lower_precision_timestamps.ts @@ -0,0 +1,31 @@ +import { Knex } from 'knex' + +/** + * MIGRATING STREAMS TIMESTAMP FIELDS TO A LOWER PRECISION, CAUSE JS CANT HANDLE + * IT BEING THAT HIGH AND THIS GENERATES BUGS + */ + +const TABLE_NAME = 'streams' +const TIMESTAMP_COLUMNS = ['createdAt', 'updatedAt'] + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, (table) => { + TIMESTAMP_COLUMNS.forEach((col) => { + table + .timestamp(col, { precision: 3, useTz: true }) + .defaultTo(knex.fn.now()) + .alter() + }) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, (table) => { + TIMESTAMP_COLUMNS.forEach((col) => { + table + .timestamp(col, { precision: 6, useTz: true }) + .defaultTo(knex.fn.now()) + .alter() + }) + }) +} diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index 74919418e..c7664df83 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -1,4 +1,4 @@ -import _ from 'lodash' +import _, { isNaN, toNumber } from 'lodash' import { Streams, StreamAcl, @@ -14,10 +14,25 @@ import { StreamFavoriteRecord, StreamRecord } from '@/modules/core/helpers/types' +import { + DiscoverableStreamsSortingInput, + DiscoverableStreamsSortType, + QueryDiscoverableStreamsArgs, + SortDirection +} from '@/modules/core/graph/generated/graphql' +import { Nullable } from '@/modules/shared/helpers/typeHelper' +import { decodeCursor, encodeCursor } from '@/modules/shared/helpers/graphqlHelper' +import dayjs from 'dayjs' export type BasicStream = Pick< StreamRecord, - 'id' | 'name' | 'description' | 'isPublic' | 'createdAt' | 'updatedAt' + | 'id' + | 'name' + | 'description' + | 'isPublic' + | 'isDiscoverable' + | 'createdAt' + | 'updatedAt' > & Pick @@ -37,6 +52,7 @@ export const BASE_STREAM_COLUMNS = [ Streams.col.name, Streams.col.description, Streams.col.isPublic, + Streams.col.isDiscoverable, Streams.col.createdAt, Streams.col.updatedAt, StreamAcl.col.role @@ -324,3 +340,148 @@ export async function getStreamCollaborator(streamId: string, userId: string) { const res = await query return res } + +/** + * Get user's role in all of the specified streams + */ +export async function getStreamRoles(userId: string, streamIds: string[]) { + const q = Streams.knex() + .select<{ id: string; role: Nullable }[]>([ + Streams.col.id, + StreamAcl.col.role + ]) + .leftJoin(StreamAcl.name, (q) => + q + .on(StreamAcl.col.resourceId, '=', Streams.col.id) + .andOnVal(StreamAcl.col.userId, userId) + ) + .whereIn(Streams.col.id, streamIds) + + const results = await q + return _.mapValues( + _.keyBy(results, (r) => r.id), + (v) => v.role + ) +} + +export type GetDiscoverableStreamsParams = Required & { + sort: DiscoverableStreamsSortingInput +} + +function buildDiscoverableStreamsBaseQuery>() { + const q = Streams.knex() + .select(Streams.cols) + .where(Streams.col.isDiscoverable, true) + .andWhere(Streams.col.isPublic, true) + + return q +} + +const decodeDiscoverableStreamsCursor = ( + sortType: DiscoverableStreamsSortType, + cursor: string +): Nullable => { + const decodedCursor = cursor ? decodeCursor(cursor) : null + + switch (sortType) { + case DiscoverableStreamsSortType.CreatedDate: { + let dateCursor: Nullable = null + try { + dateCursor = dayjs(decodedCursor).toISOString() + } catch (e: unknown) { + if (!(e instanceof RangeError)) { + throw e + } + } + + return dateCursor + } + case DiscoverableStreamsSortType.FavoritesCount: { + const numericCursor = toNumber(decodedCursor) + return isNaN(numericCursor) ? null : numericCursor + } + } +} + +export const encodeDiscoverableStreamsCursor = ( + sortType: DiscoverableStreamsSortType, + retrievedStreams: StreamRecord[], + previousCursor: Nullable +): Nullable => { + const decodedPreviousCursor = previousCursor + ? decodeDiscoverableStreamsCursor(sortType, previousCursor) + : null + + let value: Nullable + switch (sortType) { + case DiscoverableStreamsSortType.CreatedDate: { + // Using timestamps for filtering w/ a WHERE clause, + // cause there will never be duplicates + const lastItem = retrievedStreams.length + ? retrievedStreams[retrievedStreams.length - 1] + : null + value = lastItem?.createdAt.toISOString() || null + break + } + case DiscoverableStreamsSortType.FavoritesCount: { + // Using offset based pagination here, cause there will be many rows with + // the same favorite count + const previousOffset: number = (decodedPreviousCursor as number) || 0 + value = `${previousOffset + retrievedStreams.length}` + break + } + } + + return value ? encodeCursor(value) : null +} + +/** + * Counts all discoverable streams + */ +export async function countDiscoverableStreams() { + const q = buildDiscoverableStreamsBaseQuery<{ count: string }[]>() + q.clearSelect() + q.count() + + const [res] = await q + return parseInt(res.count) +} + +/** + * Paginated discoverable stream retrieval with support for multiple sorting approaches + */ +export async function getDiscoverableStreams(params: GetDiscoverableStreamsParams) { + const { cursor, sort, limit } = params + const q = buildDiscoverableStreamsBaseQuery().limit(limit) + + const decodedCursor = cursor + ? decodeDiscoverableStreamsCursor(sort.type, cursor) + : null + const sortOperator = sort.direction === SortDirection.Asc ? '>' : '<' + + switch (sort.type) { + case DiscoverableStreamsSortType.CreatedDate: { + q.orderBy([ + { column: Streams.col.createdAt, order: sort.direction }, + { column: Streams.col.name } + ]) + + if (decodedCursor) { + q.andWhere(Streams.col.createdAt, sortOperator, decodedCursor) + } + + break + } + case DiscoverableStreamsSortType.FavoritesCount: { + q.leftJoin(StreamFavorites.name, StreamFavorites.col.streamId, Streams.col.id) + .groupBy(Streams.col.id) + .orderByRaw(`COUNT("stream_favorites"."streamId") ${sort.direction}`) + .orderBy([{ column: Streams.col.name }]) + + if (decodedCursor) q.offset(decodedCursor as number) + break + } + } + + return await q +} diff --git a/packages/server/modules/core/services/streams.js b/packages/server/modules/core/services/streams.js index 0538ecf61..01d9dd250 100644 --- a/packages/server/modules/core/services/streams.js +++ b/packages/server/modules/core/services/streams.js @@ -18,6 +18,7 @@ const { StreamAccessUpdateError } = require('@/modules/core/errors/stream') const { inviteUsersToStream } = require('@/modules/serverinvites/services/inviteCreationService') +const { omitBy, isNull, isUndefined, has } = require('lodash') /** * Get base query for finding or counting user streams @@ -45,21 +46,26 @@ function getUserStreamsQueryBase({ userId, publicOnly, searchQuery }) { module.exports = { /** - * @param {{ - * name?: string, - * description?: string, - * isPublic?: boolean, - * ownerId: string, - * withContributors?: string[] - * }} param0 + * @param {import('@/modules/core/graph/generated/graphql').StreamCreateInput & {ownerId: string}} param0 * @returns */ - async createStream({ name, description, isPublic, ownerId, withContributors }) { + async createStream({ + name, + description, + isPublic, + ownerId, + withContributors, + isDiscoverable + }) { + const shouldBePublic = isPublic !== false + const shouldBeDiscoverable = isDiscoverable !== false && shouldBePublic + const stream = { id: crs({ length: 10 }), name: name || generateStreamName(), description: description || '', - isPublic: isPublic !== false, + isPublic: shouldBePublic, + isDiscoverable: shouldBeDiscoverable, updatedAt: knex.fn.now() } @@ -89,15 +95,24 @@ module.exports = { getStream, - async updateStream({ streamId, name, description, isPublic, allowPublicComments }) { + /** + * @param {import('@/modules/core/graph/generated/graphql').StreamUpdateInput} update + */ + async updateStream(update) { + const { id: streamId } = update + const validUpdate = omitBy(update, (v) => isNull(v) || isUndefined(v)) + + if (has(validUpdate, 'isPublic') && !validUpdate.isPublic) { + validUpdate.isDiscoverable = false + } + + if (!Object.keys(validUpdate).length) return null + const [{ id }] = await Streams.knex() .returning('id') .where({ id: streamId }) .update({ - name, - description, - isPublic, - allowPublicComments, + ...validUpdate, updatedAt: knex.fn.now() }) return id diff --git a/packages/server/modules/core/services/streams/discoverableStreams.ts b/packages/server/modules/core/services/streams/discoverableStreams.ts new file mode 100644 index 000000000..215d9b867 --- /dev/null +++ b/packages/server/modules/core/services/streams/discoverableStreams.ts @@ -0,0 +1,63 @@ +import { + DiscoverableStreamsSortingInput, + DiscoverableStreamsSortType, + QueryDiscoverableStreamsArgs, + SortDirection +} from '@/modules/core/graph/generated/graphql' +import { StreamRecord } from '@/modules/core/helpers/types' +import { + countDiscoverableStreams, + GetDiscoverableStreamsParams, + getDiscoverableStreams as getDiscoverableStreamsQuery, + encodeDiscoverableStreamsCursor +} from '@/modules/core/repositories/streams' +import { Nullable } from '@/modules/shared/helpers/typeHelper' +import { clamp } from 'lodash' + +type StreamCollection = { + cursor: Nullable + totalCount: number + items: StreamRecord[] +} + +function buildRetrievalSortingParams( + args: QueryDiscoverableStreamsArgs +): DiscoverableStreamsSortingInput { + return ( + args.sort || { + type: DiscoverableStreamsSortType.CreatedDate, + direction: SortDirection.Desc + } + ) +} + +function formatRetrievalParams( + args: QueryDiscoverableStreamsArgs +): GetDiscoverableStreamsParams { + return { + sort: buildRetrievalSortingParams(args), + cursor: args.cursor || null, + limit: clamp(args.limit || 25, 1, 100) + } +} + +/** + * Retrieve discoverable streams + */ +export async function getDiscoverableStreams( + args: QueryDiscoverableStreamsArgs +): Promise { + const params = formatRetrievalParams(args) + const [items, totalCount] = await Promise.all([ + getDiscoverableStreamsQuery(params), + countDiscoverableStreams() + ]) + + const cursor = encodeDiscoverableStreamsCursor(params.sort.type, items, params.cursor) + + return { + totalCount, + cursor, + items + } +} diff --git a/packages/server/modules/core/services/users.js b/packages/server/modules/core/services/users.js index 277feb732..26309aaf0 100644 --- a/packages/server/modules/core/services/users.js +++ b/packages/server/modules/core/services/users.js @@ -55,6 +55,10 @@ module.exports = { */ + /** + * @param {{}} user + * @returns {Promise} + */ async createUser(user) { user.id = crs({ length: 10 }) user.email = user.email.toLowerCase() diff --git a/packages/server/modules/core/services/users/adminUsersListService.js b/packages/server/modules/core/services/users/adminUsersListService.js index a7077370e..85d0d381c 100644 --- a/packages/server/modules/core/services/users/adminUsersListService.js +++ b/packages/server/modules/core/services/users/adminUsersListService.js @@ -118,7 +118,7 @@ function mapUserToListItem(user) { } /** - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite * @returns {AdminUsersListItem} */ function mapInviteToListItem(invite) { diff --git a/packages/server/modules/core/tests/discoverableStreams.spec.ts b/packages/server/modules/core/tests/discoverableStreams.spec.ts new file mode 100644 index 000000000..8627da7b0 --- /dev/null +++ b/packages/server/modules/core/tests/discoverableStreams.spec.ts @@ -0,0 +1,371 @@ +import { Streams, Users } from '@/modules/core/dbSchema' +import { getStream, setStreamFavorited } from '@/modules/core/repositories/streams' +import { Nullable, Optional } from '@/modules/shared/helpers/typeHelper' +import { BasicTestUser, createTestUsers } from '@/test/authHelper' +import { + DiscoverableStreamsSortType, + SortDirection +} from '@/test/graphql/generated/graphql' +import { + createStream, + readDiscoverableStreams, + updateStream +} from '@/test/graphql/streams' +import { truncateTables } from '@/test/hooks' +import { + buildAuthenticatedApolloServer, + buildUnauthenticatedApolloServer +} from '@/test/serverHelper' +import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper' +import { ApolloServer } from 'apollo-server-express' +import { expect } from 'chai' +import dayjs from 'dayjs' +import { shuffle } from 'lodash' + +const READABLE_DISCOVERABLE_STREAM_COUNT = 15 + +const cleanup = async () => await truncateTables([Streams.name, Users.name]) + +describe('Discoverable streams', () => { + let apollo: ApolloServer + + const me: BasicTestUser = { + name: 'itsaa meeee', + email: 'me@gimail.com', + password: 'whateveridontcare', + id: '' + } + + const otherGuy: BasicTestUser = { + name: 'otherr guyyyy1', + email: 'otherguy1@gimail.com', + password: 'whateveridontcare', + id: '' + } + + const favoriterGuy1: BasicTestUser = { + name: 'favoriter guy1', + email: 'favoriterguy1@gimail.com', + password: 'whateveridontcare', + id: '' + } + + const favoriterGuy2: BasicTestUser = { + name: 'favoriter guy2', + email: 'favoriterguy2@gimail.com', + password: 'whateveridontcare', + id: '' + } + + const favoriterGuy3: BasicTestUser = { + name: 'favoriter guy3', + email: 'favoriterguy3@gimail.com', + password: 'whateveridontcare', + id: '' + } + + const allUsers = [me, otherGuy, favoriterGuy1, favoriterGuy2, favoriterGuy3] + + const readableDiscoverableStreams: BasicTestStream[] = [] + + before(async () => { + await cleanup() + + // Seeding users + await createTestUsers(allUsers) + + // Seeding streams (sequentially to ensure different created dates) + for (let i = 0; i < READABLE_DISCOVERABLE_STREAM_COUNT; i++) { + const owner = i % 2 === 0 ? me : otherGuy + const newStream: BasicTestStream = { + name: 'Readable Discoverable Stream ' + i, + isPublic: true, + isDiscoverable: true, + id: '', + ownerId: '' + } + readableDiscoverableStreams.push(newStream) + await createTestStream(newStream, owner) + } + + // Favoriting some of them - stream with 5 favorites, stream with 4 favorites, then 3 and so on... + const favoriters = shuffle(allUsers.slice()) + const favoritableStreams = shuffle(readableDiscoverableStreams.slice()) + const favoritePromises: Promise[] = [] + + for (let i = favoriters.length; i > 0; i--) { + const currentFavoriters = favoriters.slice(0, i) + const currentStream = favoritableStreams.pop() + + while (currentStream && currentFavoriters.length > 0) { + const favoriter = currentFavoriters.pop() + if (!favoriter) break + + favoritePromises.push( + setStreamFavorited({ + streamId: currentStream.id, + userId: favoriter.id, + favorited: true + }) + ) + } + } + await Promise.all(favoritePromises) + + apollo = buildUnauthenticatedApolloServer() + }) + + after(async () => { + await cleanup() + }) + + it('can be retrieved', async () => { + const { data, errors } = await readDiscoverableStreams(apollo, { + limit: READABLE_DISCOVERABLE_STREAM_COUNT + }) + + expect(errors).to.be.not.ok + expect(data?.discoverableStreams).to.be.ok + expect(data?.discoverableStreams?.totalCount).to.eq( + READABLE_DISCOVERABLE_STREAM_COUNT + ) + expect(data?.discoverableStreams?.items?.length).to.eq( + READABLE_DISCOVERABLE_STREAM_COUNT + ) + expect(data?.discoverableStreams?.cursor).to.be.ok + + const someItem = data?.discoverableStreams?.items?.[0] + expect(someItem?.id).to.be.ok + expect(someItem?.isDiscoverable).to.be.ok + }) + + const sortTypeDataset = [ + { display: 'created date', sortType: DiscoverableStreamsSortType.CreatedDate }, + { display: 'favorites count', sortType: DiscoverableStreamsSortType.FavoritesCount } + ] + const sortDirectionDataset = [ + { display: 'ascending', sortDir: SortDirection.Asc }, + { display: 'descending', sortDir: SortDirection.Desc } + ] + + sortTypeDataset.forEach(({ display: sortTypeDisplay, sortType }) => { + sortDirectionDataset.forEach(({ display: sortDirDisplay, sortDir }) => { + it(`can be retrieved properly paginated & sorted by ${sortDirDisplay} ${sortTypeDisplay}`, async () => { + const limit = Math.max(Math.floor(READABLE_DISCOVERABLE_STREAM_COUNT / 3), 1) + + const collectedItems = [] + let currentSortByValue: Optional = undefined + let cursor: Nullable = null + + const retrieveAndTestPage = async (cursor: Nullable) => { + const { data, errors } = await readDiscoverableStreams(apollo, { + limit, + cursor, + sort: { + type: sortType, + direction: sortDir + } + }) + + expect(errors).to.be.not.ok + expect(data?.discoverableStreams?.totalCount).to.eq( + READABLE_DISCOVERABLE_STREAM_COUNT + ) + + const items = data?.discoverableStreams?.items || [] + const hasMorePages = items.length === limit + const newCursor = data?.discoverableStreams?.cursor + + for (const currentItem of items) { + collectedItems.push(currentItem) + + let sortByValue: string | number + if (sortType === DiscoverableStreamsSortType.CreatedDate) { + sortByValue = currentItem.createdAt + } else if (sortType === DiscoverableStreamsSortType.FavoritesCount) { + sortByValue = currentItem.favoritesCount + } else { + throw new Error('Unexpected sort type') + } + + if (!currentSortByValue) { + currentSortByValue = sortByValue + continue + } + + const previousValue = currentSortByValue + const currentValue = sortByValue + if (sortType === DiscoverableStreamsSortType.CreatedDate) { + if (sortDir === SortDirection.Asc) { + expect(dayjs(currentValue).isAfter(dayjs(previousValue))).to.be.true + } else { + expect(dayjs(previousValue).isAfter(dayjs(currentValue))).to.be.true + } + } else if (sortType === DiscoverableStreamsSortType.FavoritesCount) { + if (sortDir === SortDirection.Asc) { + expect(currentValue).is.greaterThanOrEqual(previousValue as number) + } else { + expect(previousValue).is.greaterThanOrEqual(currentValue as number) + } + } else { + throw new Error('Unexpected sort type') + } + } + + return { hasMorePages, newCursor } + } + + let failsafe = 10 + while (failsafe > 0) { + const testResult = await retrieveAndTestPage(cursor) + cursor = testResult.newCursor as Nullable + + if (!testResult.hasMorePages) break + failsafe-- + } + + if (failsafe <= 0) + throw new Error( + 'Pagination failsafe triggered! Possible infinite loop encountered.' + ) + + expect(collectedItems.length).to.eq(READABLE_DISCOVERABLE_STREAM_COUNT) + }) + }) + }) + + describe('when authenticated', () => { + let apollo: ApolloServer + + before(async () => { + apollo = buildAuthenticatedApolloServer(me.id) + }) + + it('can be retrieved with role properly filled out', async () => { + const { data, errors } = await readDiscoverableStreams(apollo, { + limit: READABLE_DISCOVERABLE_STREAM_COUNT + }) + + expect(errors).to.be.not.ok + expect(data?.discoverableStreams?.totalCount).to.eq( + READABLE_DISCOVERABLE_STREAM_COUNT + ) + + const items = data?.discoverableStreams?.items || [] + const someHaveRole = items.some((i) => !!i.role) + expect(someHaveRole).to.be.true + }) + + it('can be created', async () => { + const { errors, data } = await createStream(apollo, { + stream: { + name: 'some rando stream', + isPublic: true, + isDiscoverable: true + } + }) + + expect(errors).to.not.be.ok + expect(data).to.be.ok + expect(data?.streamCreate).to.be.ok + + const streamId = data?.streamCreate as string + const streamData = await getStream({ streamId }) + + expect(streamData).to.be.ok + expect(streamData?.isDiscoverable).to.be.true + expect(streamData?.isPublic).to.be.true + }) + + const cantMakeDiscoverableDataset = [ + { display: 'isDiscoverable set to false', isDiscoverable: false, isPublic: true }, + { display: 'isPublic is set to false', isDiscoverable: true, isPublic: false } + ] + cantMakeDiscoverableDataset.forEach(({ display, isDiscoverable, isPublic }) => { + it(`cant be created discoverable if ${display}`, async () => { + const { errors, data } = await createStream(apollo, { + stream: { + isPublic, + isDiscoverable + } + }) + + expect(errors).to.not.be.ok + expect(data).to.be.ok + expect(data?.streamCreate).to.be.ok + + const streamId = data?.streamCreate as string + const streamData = await getStream({ streamId }) + + expect(streamData).to.be.ok + expect(streamData?.isDiscoverable).to.be.false + expect(streamData?.isPublic).to.eq(isPublic) + }) + }) + + describe('and being updated', () => { + const updateableStream: BasicTestStream = { + name: 'ill be getting updated a lot', + isPublic: false, + isDiscoverable: false, + id: '', + ownerId: '' + } + + beforeEach(async () => { + // re-create for each test + await createTestStream(updateableStream, me) + }) + + it('can be updated to be discoverable or not', async () => { + const testWithDiscoverable = async (val: boolean) => { + const { errors, data } = await updateStream(apollo, { + stream: { + id: updateableStream.id, + isPublic: val, + isDiscoverable: val + } + }) + + expect(errors).to.not.be.ok + expect(data).to.be.ok + expect(data?.streamUpdate).to.be.ok + + const streamData = await getStream({ streamId: updateableStream.id }) + + expect(streamData).to.be.ok + expect(streamData?.isDiscoverable).to.eq(val) + expect(streamData?.isPublic).to.eq(val) + } + + // Toggle on + await testWithDiscoverable(true) + + // Toggle off + await testWithDiscoverable(false) + }) + + cantMakeDiscoverableDataset.forEach(({ display, isDiscoverable, isPublic }) => { + it(`cant be updated to be discoverable if ${display}`, async () => { + const { errors, data } = await updateStream(apollo, { + stream: { + id: updateableStream.id, + isPublic, + isDiscoverable + } + }) + + expect(errors).to.not.be.ok + expect(data).to.be.ok + expect(data?.streamUpdate).to.be.ok + + const streamData = await getStream({ streamId: updateableStream.id }) + + expect(streamData).to.be.ok + expect(streamData?.isDiscoverable).to.be.false + expect(streamData?.isPublic).to.eq(isPublic) + }) + }) + }) + }) +}) diff --git a/packages/server/modules/core/tests/streams.spec.js b/packages/server/modules/core/tests/streams.spec.js index 2e89ea9f5..d3a628f29 100644 --- a/packages/server/modules/core/tests/streams.spec.js +++ b/packages/server/modules/core/tests/streams.spec.js @@ -84,7 +84,7 @@ describe('Streams @core-streams', () => { it('Should update a stream', async () => { await updateStream({ - streamId: testStream.id, + id: testStream.id, name: 'Modified Name', description: 'Wooot' }) @@ -213,7 +213,7 @@ describe('Streams @core-streams', () => { await sleep(100) - await updateStream({ streamId: s.id, name: 'TU1' }) + await updateStream({ id: s.id, name: 'TU1' }) const su = await getStream({ streamId: s.id }) expect(su.updatedAt).to.not.equal(s.updatedAt) diff --git a/packages/server/modules/core/tests/usersAdminList.spec.ts b/packages/server/modules/core/tests/usersAdminList.spec.ts index 0307ab777..1c9df8c35 100644 --- a/packages/server/modules/core/tests/usersAdminList.spec.ts +++ b/packages/server/modules/core/tests/usersAdminList.spec.ts @@ -1,9 +1,4 @@ -import { - ServerInviteRecord, - ServerInvites, - Streams, - Users -} from '@/modules/core/dbSchema' +import { ServerInvites, Streams, Users } from '@/modules/core/dbSchema' import { truncateTables } from '@/test/hooks' import { createUser } from '@/modules/core/services/users' import { createStream } from '@/modules/core/services/streams' @@ -15,6 +10,8 @@ import { addLoadersToCtx } from '@/modules/shared' import { Roles, AllScopes } from '@/modules/core/helpers/mainConstants' import { expect } from 'chai' import { ApolloServer } from 'apollo-server-express' +import { ServerInviteRecord } from '@/modules/serverinvites/helpers/types' +import { Optional } from '@/modules/shared/helpers/typeHelper' function randomEl(array: T[]): T { return array[Math.floor(Math.random() * array.length)] @@ -44,7 +41,7 @@ describe('[Admin users list]', () => { name: 'Mr Server Admin Dude', email: 'adminuserguy@gmail.com', password: 'sn3aky-1337-b1m', - id: undefined + id: undefined as Optional } const USER_COUNT = 15 @@ -241,8 +238,8 @@ describe('[Admin users list]', () => { expect(userItem.registeredUser?.id).to.eq(expectedUserId) if (userItem.registeredUser?.id !== me.id) { - expect(userItem.registeredUser.name).to.contain('User #') - expect(userItem.registeredUser.email).to.contain('speckleuser') + expect(userItem.registeredUser?.name).to.contain('User #') + expect(userItem.registeredUser?.email).to.contain('speckleuser') } } diff --git a/packages/server/modules/serverinvites/helpers/inviteHelper.js b/packages/server/modules/serverinvites/helpers/inviteHelper.js index bd84bdc75..c8f7eebe4 100644 --- a/packages/server/modules/serverinvites/helpers/inviteHelper.js +++ b/packages/server/modules/serverinvites/helpers/inviteHelper.js @@ -77,7 +77,7 @@ function buildUserTarget(userId) { /** * Resolve a display name for the user being invited - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite * @param {import("@/modules/core/helpers/userHelper").LimitedUserRecord | null} user The user, * if invite targets a registered user. * @returns {string} diff --git a/packages/server/modules/serverinvites/helpers/types.ts b/packages/server/modules/serverinvites/helpers/types.ts new file mode 100644 index 000000000..da9450049 --- /dev/null +++ b/packages/server/modules/serverinvites/helpers/types.ts @@ -0,0 +1,14 @@ +import { Nullable } from '@/modules/shared/helpers/typeHelper' + +export type ServerInviteRecord = { + id: string + target: string + inviterId: string + createdAt: Date + used: boolean + message: Nullable + resourceTarget: Nullable + resourceId: Nullable + role: Nullable + token: string +} diff --git a/packages/server/modules/serverinvites/repositories/index.js b/packages/server/modules/serverinvites/repositories/index.js index 337754b35..524215196 100644 --- a/packages/server/modules/serverinvites/repositories/index.js +++ b/packages/server/modules/serverinvites/repositories/index.js @@ -10,21 +10,6 @@ const { const { uniq, isArray } = require('lodash') const { getStream } = require('@/modules/core/repositories/streams') -/** - * @typedef {{ - * id: string, - * target: string, - * inviterId: string, - * createdAt?: Date, - * used?: boolean, - * message?: string, - * resourceTarget?: string, - * resourceId?: string, - * role?: string, - * token: string - * }} ServerInviteRecord - */ - /** * * Resolve resource from invite diff --git a/packages/server/modules/serverinvites/services/inviteCreationService.js b/packages/server/modules/serverinvites/services/inviteCreationService.js index 50b4d0a74..8bcb74566 100644 --- a/packages/server/modules/serverinvites/services/inviteCreationService.js +++ b/packages/server/modules/serverinvites/services/inviteCreationService.js @@ -42,7 +42,7 @@ const { */ /** - * @typedef {CreateInviteParams|import('@/modules/serverinvites/repositories').ServerInviteRecord} InviteOrInputParams + * @typedef {CreateInviteParams|import('@/modules/serverinvites/helpers/types').ServerInviteRecord} InviteOrInputParams */ /** @@ -174,7 +174,7 @@ function sanitizeMessage(message, stripAll = false) { /** * Build the email subject line - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite * @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter * @param {string | null} resourceName * @returns {string} @@ -195,7 +195,7 @@ function buildEmailSubject(invite, inviter, resourceName) { /** * Build invite link URL - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite * @returns {string} */ function buildInviteLink(invite) { @@ -260,7 +260,7 @@ ${message ? inviter.name + ' said: "' + sanitizeMessage(message, true) + '"' : ' } /** - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite * @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter * @param {import('@/modules/core/helpers/types').ServerInfo} serverInfo * @param {string} resourceName @@ -286,7 +286,7 @@ function buildEmailTemplateParams( /** * Build invite email contents - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite * @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter * @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser * @param {Object | null} resource @@ -384,7 +384,7 @@ async function createAndSendInvite(params) { /** * Re-send existing invite email - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite */ async function resendInviteEmail(invite) { const { inviterId, target } = invite diff --git a/packages/server/modules/serverinvites/services/inviteProcessingService.js b/packages/server/modules/serverinvites/services/inviteProcessingService.js index 93c11479e..bee7129d9 100644 --- a/packages/server/modules/serverinvites/services/inviteProcessingService.js +++ b/packages/server/modules/serverinvites/services/inviteProcessingService.js @@ -30,7 +30,7 @@ const { * Resolve the relative auth redirect path, after registering with an invite * Note: Important auth query string params like the access_code are added separately * in auth middlewares - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord | undefined} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord | undefined} invite * @returns {string} */ function resolveAuthRedirectPath(invite) { @@ -50,7 +50,7 @@ function resolveAuthRedirectPath(invite) { * Validate that the new user has a valid invite for registering to the server * @param {Object} email User's email address * @param {string} token Invite token - * @returns {import('@/modules/serverinvites/repositories').ServerInviteRecord} + * @returns {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} */ async function validateServerInvite(email, token) { const invite = await getServerInvite(email, token) diff --git a/packages/server/modules/serverinvites/services/inviteRetrievalService.js b/packages/server/modules/serverinvites/services/inviteRetrievalService.js index 871a4e996..91767f062 100644 --- a/packages/server/modules/serverinvites/services/inviteRetrievalService.js +++ b/packages/server/modules/serverinvites/services/inviteRetrievalService.js @@ -29,7 +29,7 @@ const { keyBy, uniq } = require('lodash') */ /** - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite * @returns {string} */ function buildPendingStreamCollaboratorId(invite) { @@ -37,7 +37,7 @@ function buildPendingStreamCollaboratorId(invite) { } /** - * @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite * @param {import('@/modules/core/helpers/userHelper').UserRecord | null} targetUser * @returns {PendingStreamCollaboratorGraphQLType} */ diff --git a/packages/server/modules/shared/helpers/cryptoHelper.js b/packages/server/modules/shared/helpers/cryptoHelper.js deleted file mode 100644 index a78010f20..000000000 --- a/packages/server/modules/shared/helpers/cryptoHelper.js +++ /dev/null @@ -1,12 +0,0 @@ -const crypto = require('crypto') - -function md5(stringValue) { - return crypto - .createHash('md5') - .update(stringValue || '') - .digest('hex') -} - -module.exports = { - md5 -} diff --git a/packages/server/modules/shared/helpers/cryptoHelper.ts b/packages/server/modules/shared/helpers/cryptoHelper.ts new file mode 100644 index 000000000..90f1c1064 --- /dev/null +++ b/packages/server/modules/shared/helpers/cryptoHelper.ts @@ -0,0 +1,18 @@ +import crypto from 'crypto' + +export function md5(val: string): string { + return crypto + .createHash('md5') + .update(val || '') + .digest('hex') +} + +export function base64Encode(val: string): string { + const bufferObj = Buffer.from(val, 'utf8') + return bufferObj.toString('base64') +} + +export function base64Decode(val: string): string { + const bufferObj = Buffer.from(val, 'base64') + return bufferObj.toString('utf8') +} diff --git a/packages/server/modules/shared/helpers/graphqlHelper.ts b/packages/server/modules/shared/helpers/graphqlHelper.ts new file mode 100644 index 000000000..cda162446 --- /dev/null +++ b/packages/server/modules/shared/helpers/graphqlHelper.ts @@ -0,0 +1,15 @@ +import { base64Decode, base64Encode } from '@/modules/shared/helpers/cryptoHelper' + +/** + * Encode cursor to turn it into an opaque & obfuscated value + */ +export function encodeCursor(value: string): string { + return base64Encode(value) +} + +/** + * Decode obfuscated cursor value + */ +export function decodeCursor(value: string): string { + return base64Decode(value) +} diff --git a/packages/server/modules/shared/helpers/typeHelper.ts b/packages/server/modules/shared/helpers/typeHelper.ts index ad5ec759c..784936ffe 100644 --- a/packages/server/modules/shared/helpers/typeHelper.ts +++ b/packages/server/modules/shared/helpers/typeHelper.ts @@ -1,3 +1,5 @@ +import { RequestDataLoaders } from '@/modules/core/loaders' +import { AuthContext } from '@/modules/shared/authz' import { Express } from 'express' export type Nullable = T | null @@ -28,3 +30,11 @@ export type SpeckleModule = { */ shutdown?: () => MaybeAsync } + +export type GraphQLContext = AuthContext & { + /** + * Request-scoped GraphQL dataloaders + * @see https://github.com/graphql/dataloader + */ + loaders: RequestDataLoaders +} diff --git a/packages/server/modules/shared/index.js b/packages/server/modules/shared/index.js index c1be19ff8..6061ad6a0 100644 --- a/packages/server/modules/shared/index.js +++ b/packages/server/modules/shared/index.js @@ -22,7 +22,7 @@ const pubsub = new RedisPubSub({ }) /** - * @typedef {import('@/modules/shared/authz').AuthContext & {loaders: import('@/modules/core/loaders').RequestDataLoaders}} GraphQLContext + * @typedef {import('@/modules/shared/helpers/typeHelper').GraphQLContext} GraphQLContext */ /** diff --git a/packages/server/test/authHelper.js b/packages/server/test/authHelper.js deleted file mode 100644 index 42c3be5c3..000000000 --- a/packages/server/test/authHelper.js +++ /dev/null @@ -1,16 +0,0 @@ -const { AllScopes } = require('@/modules/core/helpers/mainConstants') -const { createPersonalAccessToken } = require('@/modules/core/services/tokens') - -/** - * Create an auth token for the specified user (use only during tests, of course) - * @param {string} userId User's ID - * @param {string[]} scopes Specify scopes you want to allow. Defaults to all scopes. - * @returns {Promise} - */ -async function createAuthTokenForUser(userId, scopes = AllScopes) { - return await createPersonalAccessToken(userId, 'test-runner-token', scopes) -} - -module.exports = { - createAuthTokenForUser -} diff --git a/packages/server/test/authHelper.ts b/packages/server/test/authHelper.ts new file mode 100644 index 000000000..17fa34e9d --- /dev/null +++ b/packages/server/test/authHelper.ts @@ -0,0 +1,42 @@ +import { AllScopes } from '@/modules/core/helpers/mainConstants' +import { UserRecord } from '@/modules/core/helpers/types' +import { createPersonalAccessToken } from '@/modules/core/services/tokens' +import { createUser } from '@/modules/core/services/users' + +export type BasicTestUser = { + name: string + email: string + password: string + /** + * Will be set by createTestUser() + */ + id: string +} & Partial + +/** + * Create basic user for tests and on success mutate the input object to have + * the new ID + */ +export async function createTestUser(userObj: BasicTestUser) { + const id = await createUser(userObj) + userObj.id = id +} + +/** + * Create multiple users for tests and update them to include their ID + */ +export async function createTestUsers(userObjs: BasicTestUser[]) { + await Promise.all(userObjs.map((o) => createTestUser(o))) +} + +/** + * Create an auth token for the specified user (use only during tests, of course) + * @param userId User's ID + * @param Specify scopes you want to allow. Defaults to all scopes. + */ +export async function createAuthTokenForUser( + userId: string, + scopes: string[] = AllScopes +): Promise { + return await createPersonalAccessToken(userId, 'test-runner-token', scopes) +} diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index a5a905717..2a381321f 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -11,9 +11,9 @@ export type Scalars = { Int: number; Float: number; BigInt: any; - DateTime: any; + DateTime: string; EmailAddress: any; - JSONObject: any; + JSONObject: Record; }; export type Activity = { @@ -361,6 +361,16 @@ export type CommitUpdateInput = { streamId: Scalars['String']; }; +export enum DiscoverableStreamsSortType { + CreatedDate = 'CREATED_DATE', + FavoritesCount = 'FAVORITES_COUNT' +} + +export type DiscoverableStreamsSortingInput = { + direction: SortDirection; + type: DiscoverableStreamsSortType; +}; + export type FileUpload = { __typename?: 'FileUpload'; branchName?: Maybe; @@ -784,6 +794,7 @@ export type Query = { __typename?: 'Query'; /** Stare into the void. */ _?: Maybe; + /** All the streams of the server. Available to admins only. */ adminStreams?: Maybe; /** * Get all (or search for specific) users, registered or invited, from the server in a paginated view. @@ -801,6 +812,8 @@ export type Query = { * - get the comments targeting any of a set of provided resources (comments/objects): **pass in an array of resources.** */ comments?: Maybe; + /** All of the discoverable streams of the server */ + discoverableStreams?: Maybe; serverInfo: ServerInfo; serverStats: ServerStats; /** @@ -867,6 +880,13 @@ export type QueryCommentsArgs = { }; +export type QueryDiscoverableStreamsArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']; + sort?: InputMaybe; +}; + + export type QueryStreamArgs = { id: Scalars['String']; }; @@ -1043,6 +1063,11 @@ export type SmartTextEditorValue = { version: Scalars['String']; }; +export enum SortDirection { + Asc = 'ASC', + Desc = 'DESC' +} + export type Stream = { __typename?: 'Stream'; /** All the recent activity on this stream in chronological order */ @@ -1077,6 +1102,12 @@ export type Stream = { /** Returns a list of all the file uploads for this stream. */ fileUploads?: Maybe>>; id: Scalars['String']; + /** + * Whether the stream (if public) can be found on public stream exploration pages + * and searches + */ + isDiscoverable: Scalars['Boolean']; + /** Whether the stream can be viewed by non-contributors */ isPublic: Scalars['Boolean']; name: Scalars['String']; object?: Maybe; @@ -1165,6 +1196,12 @@ export type StreamCollection = { export type StreamCreateInput = { description?: InputMaybe; + /** + * Whether the stream (if public) can be found on public stream exploration pages + * and searches + */ + isDiscoverable?: InputMaybe; + /** Whether the stream can be viewed by non-contributors */ isPublic?: InputMaybe; name?: InputMaybe; /** Optionally specify user IDs of users that you want to invite to be contributors to this stream */ @@ -1195,6 +1232,12 @@ export type StreamUpdateInput = { allowPublicComments?: InputMaybe; description?: InputMaybe; id: Scalars['String']; + /** + * Whether the stream (if public) can be found on public stream exploration pages + * and searches + */ + isDiscoverable?: InputMaybe; + /** Whether the stream can be viewed by non-contributors */ isPublic?: InputMaybe; name?: InputMaybe; }; @@ -1488,7 +1531,7 @@ export type WebhookUpdateInput = { url?: InputMaybe; }; -export type CommentWithRepliesFragment = { __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: any | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies?: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: any | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } | null }; +export type CommentWithRepliesFragment = { __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies?: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } | null }; export type CreateCommentMutationVariables = Exact<{ input: CommentCreateInput; @@ -1510,7 +1553,7 @@ export type GetCommentQueryVariables = Exact<{ }>; -export type GetCommentQuery = { __typename?: 'Query', comment?: { __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: any | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies?: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: any | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } | null } | null }; +export type GetCommentQuery = { __typename?: 'Query', comment?: { __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies?: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } | null } | null }; export type GetCommentsQueryVariables = Exact<{ streamId: Scalars['String']; @@ -1518,7 +1561,7 @@ export type GetCommentsQueryVariables = Exact<{ }>; -export type GetCommentsQuery = { __typename?: 'Query', comments?: { __typename?: 'CommentCollection', totalCount: number, cursor?: any | null, items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: any | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies?: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: any | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } | null }> } | null }; +export type GetCommentsQuery = { __typename?: 'Query', comments?: { __typename?: 'CommentCollection', totalCount: number, cursor?: string | null, items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies?: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } | null }> } | null }; export type CreateServerInviteMutationVariables = Exact<{ input: ServerInviteCreateInput; @@ -1601,6 +1644,8 @@ export type GetStreamPendingCollaboratorsQueryVariables = Exact<{ export type GetStreamPendingCollaboratorsQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, pendingCollaborators?: Array<{ __typename?: 'PendingStreamCollaborator', inviteId: string, title: string, token?: string | null, user?: { __typename?: 'LimitedUser', id: string, name?: string | null } | null }> | null } | null }; +export type BasicStreamFieldsFragment = { __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }; + export type LeaveStreamMutationVariables = Exact<{ streamId: Scalars['String']; }>; @@ -1608,6 +1653,36 @@ export type LeaveStreamMutationVariables = Exact<{ export type LeaveStreamMutation = { __typename?: 'Mutation', streamLeave: boolean }; +export type CreateStreamMutationVariables = Exact<{ + stream: StreamCreateInput; +}>; + + +export type CreateStreamMutation = { __typename?: 'Mutation', streamCreate?: string | null }; + +export type UpdateStreamMutationVariables = Exact<{ + stream: StreamUpdateInput; +}>; + + +export type UpdateStreamMutation = { __typename?: 'Mutation', streamUpdate: boolean }; + +export type ReadStreamQueryVariables = Exact<{ + id: Scalars['String']; +}>; + + +export type ReadStreamQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } | null }; + +export type ReadDiscoverableStreamsQueryVariables = Exact<{ + limit?: Scalars['Int']; + cursor?: InputMaybe; + sort?: InputMaybe; +}>; + + +export type ReadDiscoverableStreamsQuery = { __typename?: 'Query', discoverableStreams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', favoritesCount: number, id: string, name: string, description?: string | null, isPublic: boolean, isDiscoverable: boolean, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string }> | null } | null }; + export type GetAdminUsersQueryVariables = Exact<{ limit?: Scalars['Int']; offset?: Scalars['Int']; diff --git a/packages/server/test/graphql/streams.js b/packages/server/test/graphql/streams.js deleted file mode 100644 index 407fc2d8d..000000000 --- a/packages/server/test/graphql/streams.js +++ /dev/null @@ -1,22 +0,0 @@ -const { gql } = require('apollo-server-express') - -const leaveStreamMutation = gql` - mutation LeaveStream($streamId: String!) { - streamLeave(streamId: $streamId) - } -` - -module.exports = { - /** - * streamLeave mutation - * @param {import('apollo-server-express').ApolloServer} apollo - */ - leaveStream(apollo, { streamId }) { - return apollo.executeOperation({ - query: leaveStreamMutation, - variables: { - streamId - } - }) - } -} diff --git a/packages/server/test/graphql/streams.ts b/packages/server/test/graphql/streams.ts new file mode 100644 index 000000000..5a44ede0e --- /dev/null +++ b/packages/server/test/graphql/streams.ts @@ -0,0 +1,122 @@ +import { + LeaveStreamMutation, + LeaveStreamMutationVariables, + CreateStreamMutation, + CreateStreamMutationVariables, + UpdateStreamMutationVariables, + UpdateStreamMutation, + ReadStreamQueryVariables, + ReadStreamQuery, + ReadDiscoverableStreamsQueryVariables, + ReadDiscoverableStreamsQuery +} from '@/test/graphql/generated/graphql' +import { executeOperation } from '@/test/graphqlHelper' +import { ApolloServer, gql } from 'apollo-server-express' + +export const basicStreamFieldsFragment = gql` + fragment BasicStreamFields on Stream { + id + name + description + isPublic + isDiscoverable + allowPublicComments + role + createdAt + updatedAt + } +` + +const leaveStreamMutation = gql` + mutation LeaveStream($streamId: String!) { + streamLeave(streamId: $streamId) + } +` + +const createStreamMutation = gql` + mutation CreateStream($stream: StreamCreateInput!) { + streamCreate(stream: $stream) + } +` + +const updateStreamMutation = gql` + mutation UpdateStream($stream: StreamUpdateInput!) { + streamUpdate(stream: $stream) + } +` + +const readStreamQuery = gql` + query ReadStream($id: String!) { + stream(id: $id) { + ...BasicStreamFields + } + } + + ${basicStreamFieldsFragment} +` + +const readDiscoverableStreamsQuery = gql` + query ReadDiscoverableStreams( + $limit: Int! = 25 + $cursor: String + $sort: DiscoverableStreamsSortingInput + ) { + discoverableStreams(limit: $limit, cursor: $cursor, sort: $sort) { + totalCount + cursor + items { + favoritesCount + ...BasicStreamFields + } + } + } + + ${basicStreamFieldsFragment} +` + +export const leaveStream = ( + apollo: ApolloServer, + variables: LeaveStreamMutationVariables +) => + executeOperation( + apollo, + leaveStreamMutation, + variables + ) + +export const createStream = ( + apollo: ApolloServer, + variables: CreateStreamMutationVariables +) => + executeOperation( + apollo, + createStreamMutation, + variables + ) + +export const updateStream = ( + apollo: ApolloServer, + variables: UpdateStreamMutationVariables +) => + executeOperation( + apollo, + updateStreamMutation, + variables + ) + +export const readStream = (apollo: ApolloServer, variables: ReadStreamQueryVariables) => + executeOperation( + apollo, + readStreamQuery, + variables + ) + +export const readDiscoverableStreams = ( + apollo: ApolloServer, + variables: ReadDiscoverableStreamsQueryVariables +) => + executeOperation( + apollo, + readDiscoverableStreamsQuery, + variables + ) diff --git a/packages/server/test/serverHelper.ts b/packages/server/test/serverHelper.ts index 6bc8468f7..1701f5ff2 100644 --- a/packages/server/test/serverHelper.ts +++ b/packages/server/test/serverHelper.ts @@ -21,3 +21,15 @@ export function buildAuthenticatedApolloServer( }) }) } + +/** + * Build an unauthenticated ApolloServer instance + */ +export function buildUnauthenticatedApolloServer() { + return buildApolloServer({ + context: () => + addLoadersToCtx({ + auth: false + }) + }) +} diff --git a/packages/server/test/speckle-helpers/streamHelper.js b/packages/server/test/speckle-helpers/streamHelper.js deleted file mode 100644 index 6643663b2..000000000 --- a/packages/server/test/speckle-helpers/streamHelper.js +++ /dev/null @@ -1,22 +0,0 @@ -const { StreamAcl } = require('@/modules/core/dbSchema') - -/** - * Get the role user has for the specified stream - * @param {string} userId - * @param {string} streamId - * @returns {Promise} - */ -async function getUserStreamRole(userId, streamId) { - const entry = await StreamAcl.knex() - .where({ - [StreamAcl.col.resourceId]: streamId, - [StreamAcl.col.userId]: userId - }) - .first() - - return entry?.role || null -} - -module.exports = { - getUserStreamRole -} diff --git a/packages/server/test/speckle-helpers/streamHelper.ts b/packages/server/test/speckle-helpers/streamHelper.ts new file mode 100644 index 000000000..82b53a031 --- /dev/null +++ b/packages/server/test/speckle-helpers/streamHelper.ts @@ -0,0 +1,60 @@ +import { StreamAcl } from '@/modules/core/dbSchema' +import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types' +import { createStream } from '@/modules/core/services/streams' +import { Nullable } from '@/modules/shared/helpers/typeHelper' +import { BasicTestUser } from '@/test/authHelper' +import { omit } from 'lodash' + +export type BasicTestStream = { + name: string + isPublic: boolean + /** + * The ID of the owner user + */ + ownerId: string + /** + * The ID of the stream. Will be filled in by createTestStream(). + */ + id: string +} & Partial + +/** + * Create multiple test streams with their IDs filled in + */ +export async function createTestStreams( + streamOwnerPairs: [BasicTestStream, BasicTestUser][] +) { + await Promise.all(streamOwnerPairs.map((p) => createTestStream(p[0], p[1]))) +} + +/** + * Create basic stream for testing and update streamObj to have a real ID + */ +export async function createTestStream( + streamObj: BasicTestStream, + owner: BasicTestUser +) { + const id = await createStream({ + ...omit(streamObj, ['id', 'ownerId']), + ownerId: owner.id + }) + streamObj.id = id + streamObj.ownerId = owner.id +} + +/** + * Get the role user has for the specified stream + */ +export async function getUserStreamRole( + userId: string, + streamId: string +): Promise> { + const entry = await StreamAcl.knex() + .where({ + [StreamAcl.col.resourceId]: streamId, + [StreamAcl.col.userId]: userId + }) + .first() + + return entry?.role || null +} From 56d0d54bca3adda34f212796d8cf9e962a459fb6 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 24 Aug 2022 16:21:01 +0100 Subject: [PATCH 17/28] refactor(helm chart): explicitly define the deployment rollout strategy (#963) Partially addresses https://github.com/specklesystems/speckle-server/issues/925 --- .../speckle-server/templates/fileimport_service/deployment.yml | 2 ++ utils/helm/speckle-server/templates/frontend/deployment.yml | 2 ++ utils/helm/speckle-server/templates/monitoring/deployment.yml | 2 ++ .../speckle-server/templates/preview_service/deployment.yml | 2 ++ utils/helm/speckle-server/templates/server/deployment.yml | 2 ++ .../speckle-server/templates/webhook_service/deployment.yml | 2 ++ 6 files changed, 12 insertions(+) diff --git a/utils/helm/speckle-server/templates/fileimport_service/deployment.yml b/utils/helm/speckle-server/templates/fileimport_service/deployment.yml index aac7b446a..6fdad3b0d 100644 --- a/utils/helm/speckle-server/templates/fileimport_service/deployment.yml +++ b/utils/helm/speckle-server/templates/fileimport_service/deployment.yml @@ -12,6 +12,8 @@ spec: matchLabels: app: speckle-fileimport-service project: speckle-server + strategy: + type: RollingUpdate template: metadata: labels: diff --git a/utils/helm/speckle-server/templates/frontend/deployment.yml b/utils/helm/speckle-server/templates/frontend/deployment.yml index 3c5d9096b..d77460814 100644 --- a/utils/helm/speckle-server/templates/frontend/deployment.yml +++ b/utils/helm/speckle-server/templates/frontend/deployment.yml @@ -11,6 +11,8 @@ spec: matchLabels: app: speckle-frontend project: speckle-server + strategy: + type: RollingUpdate template: metadata: labels: diff --git a/utils/helm/speckle-server/templates/monitoring/deployment.yml b/utils/helm/speckle-server/templates/monitoring/deployment.yml index dc4c7acf3..eacc4dd5d 100644 --- a/utils/helm/speckle-server/templates/monitoring/deployment.yml +++ b/utils/helm/speckle-server/templates/monitoring/deployment.yml @@ -11,6 +11,8 @@ spec: matchLabels: app: speckle-monitoring project: speckle-server + strategy: + type: RollingUpdate template: metadata: labels: diff --git a/utils/helm/speckle-server/templates/preview_service/deployment.yml b/utils/helm/speckle-server/templates/preview_service/deployment.yml index ad98d3eed..145eb5e33 100644 --- a/utils/helm/speckle-server/templates/preview_service/deployment.yml +++ b/utils/helm/speckle-server/templates/preview_service/deployment.yml @@ -11,6 +11,8 @@ spec: matchLabels: app: speckle-preview-service project: speckle-server + strategy: + type: RollingUpdate template: metadata: labels: diff --git a/utils/helm/speckle-server/templates/server/deployment.yml b/utils/helm/speckle-server/templates/server/deployment.yml index 606a90794..b24b8b5bf 100644 --- a/utils/helm/speckle-server/templates/server/deployment.yml +++ b/utils/helm/speckle-server/templates/server/deployment.yml @@ -11,6 +11,8 @@ spec: matchLabels: app: speckle-server project: speckle-server + strategy: + type: RollingUpdate template: metadata: labels: diff --git a/utils/helm/speckle-server/templates/webhook_service/deployment.yml b/utils/helm/speckle-server/templates/webhook_service/deployment.yml index f669e87f8..d3545eebd 100644 --- a/utils/helm/speckle-server/templates/webhook_service/deployment.yml +++ b/utils/helm/speckle-server/templates/webhook_service/deployment.yml @@ -11,6 +11,8 @@ spec: matchLabels: app: speckle-webhook-service project: speckle-server + strategy: + type: RollingUpdate template: metadata: labels: From b61f0ffabe7fddb3fcab00b29dbe437b484714a3 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 24 Aug 2022 16:25:08 +0100 Subject: [PATCH 18/28] Cilium network policies (#954) * feat(helm chart): deployes Cilium Network Policies when configured Cilium Network Policies provide more features over regular Kubernetes Network Policies, but Cilium is not available everywhere. When selected by an operator, Cilium Network Policies will be deployed instead of Kubernetes Network Policies. Fixes https://github.com/specklesystems/speckle-server/issues/913 * Cilium Network Policy for fileimport service. * tested only for external host. * Still to test internal pod and external IP. * Cilium network policy for file import service restricts DNS * allows egress to service instead of endpoint * file import service uses service url of speckle-server * helper functions for server and dns * DRY the prometheus selector * CiliumNetworkPolicy for frontend * CiliumNetworkPolicy for monitoring service * CiliumNetworkPolicy for preview service * CiliumNetworkPolicy for test * CiliumNetworkPolicy for webhook_service * CiliumNetworkPolicy for Server * Test should egress to domain, not internally * Test should be in tests directory to match Helm convention for tests * Test should explicitly deny ingress from everywhere * Server needs to egress to canonical domain (i.e. itself) - DNS and egress for canonical domain added to Server - As Test also egresses via canonical domain to access Server, we do not require the intra-cluster ingress to the server from the test pod - Explicitly deny all egress from frontend * WIP update to schema.json * Breaking Change: inCluster network policies supported for cilium * Breaking change: kubernetes network policy podSelector and namespaceSelector are now at a different level * Updates schema.json * add notes to remove egress once bug is fixed --- .../speckle-server/templates/_helpers.tpl | 215 +++++++++++++++++- .../fileimport_service/deployment.yml | 2 +- .../networkpolicy.cilium.yml | 46 ++++ ...olicy.yml => networkpolicy.kubernetes.yml} | 4 +- .../frontend/networkpolicy.cilium.yml | 25 ++ ...olicy.yml => networkpolicy.kubernetes.yml} | 4 +- .../monitoring/networkpolicy.cilium.yml | 35 +++ ...olicy.yml => networkpolicy.kubernetes.yml} | 4 +- .../preview_service/networkpolicy.cilium.yml | 35 +++ ...olicy.yml => networkpolicy.kubernetes.yml} | 4 +- .../templates/server/_helpers.tpl | 14 ++ .../templates/server/deployment.yml | 6 +- .../templates/server/networkpolicy.cilium.yml | 78 +++++++ ...olicy.yml => networkpolicy.kubernetes.yml} | 4 +- .../templates/server/service.yml | 2 +- .../templates/{test => tests}/_helpers.tpl | 0 .../templates/{test => tests}/deployment.yml | 0 .../templates/tests/networkpolicy.cilium.yml | 35 +++ .../networkpolicy.kubernetes.yml} | 2 +- .../{test => tests}/serviceaccount.yml | 0 .../webhook_service/networkpolicy.cilium.yml | 40 ++++ ...olicy.yml => networkpolicy.kubernetes.yml} | 4 +- utils/helm/speckle-server/values.schema.json | 106 +++++++-- utils/helm/speckle-server/values.yaml | 124 +++++++--- utils/test-deployment/run_tests.py | 9 +- 25 files changed, 713 insertions(+), 85 deletions(-) create mode 100644 utils/helm/speckle-server/templates/fileimport_service/networkpolicy.cilium.yml rename utils/helm/speckle-server/templates/fileimport_service/{networkpolicy.yml => networkpolicy.kubernetes.yml} (84%) create mode 100644 utils/helm/speckle-server/templates/frontend/networkpolicy.cilium.yml rename utils/helm/speckle-server/templates/frontend/{networkpolicy.yml => networkpolicy.kubernetes.yml} (78%) create mode 100644 utils/helm/speckle-server/templates/monitoring/networkpolicy.cilium.yml rename utils/helm/speckle-server/templates/monitoring/{networkpolicy.yml => networkpolicy.kubernetes.yml} (82%) create mode 100644 utils/helm/speckle-server/templates/preview_service/networkpolicy.cilium.yml rename utils/helm/speckle-server/templates/preview_service/{networkpolicy.yml => networkpolicy.kubernetes.yml} (82%) create mode 100644 utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml rename utils/helm/speckle-server/templates/server/{networkpolicy.yml => networkpolicy.kubernetes.yml} (91%) rename utils/helm/speckle-server/templates/{test => tests}/_helpers.tpl (100%) rename utils/helm/speckle-server/templates/{test => tests}/deployment.yml (100%) create mode 100644 utils/helm/speckle-server/templates/tests/networkpolicy.cilium.yml rename utils/helm/speckle-server/templates/{test/networkpolicy.yml => tests/networkpolicy.kubernetes.yml} (87%) rename utils/helm/speckle-server/templates/{test => tests}/serviceaccount.yml (100%) create mode 100644 utils/helm/speckle-server/templates/webhook_service/networkpolicy.cilium.yml rename utils/helm/speckle-server/templates/webhook_service/{networkpolicy.yml => networkpolicy.kubernetes.yml} (84%) diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 5272acc3c..86be36ba6 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -102,36 +102,59 @@ Expects the global context "$" to be passed as the parameter {{- define "speckle.networkpolicy.egress.redis" -}} {{- $port := (default "6379" .Values.redis.networkPolicy.port ) -}} {{- if .Values.redis.networkPolicy.inCluster.enabled -}} -{{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.redis.networkPolicy.inCluster.podSelector "namespaceSelector" .Values.redis.networkPolicy.inCluster.namespaceSelector "port" $port) }} +{{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.redis.networkPolicy.inCluster.kubernetes.podSelector "namespaceSelector" .Values.redis.networkPolicy.inCluster.kubernetes.namespaceSelector "port" $port) }} {{- else if .Values.redis.networkPolicy.externalToCluster.enabled -}} {{ include "speckle.networkpolicy.egress.external" (dict "ip" .Values.redis.networkPolicy.externalToCluster.ipv4 "port" $port) }} {{- end -}} {{- end }} {{/* -Creates a network policy egress definition for connecting to Postgres +Creates a Cilium Network Policy egress definition for connecting to Redis + +Expects the global context "$" to be passed as the parameter +*/}} +{{- define "speckle.networkpolicy.egress.redis.cilium" -}} +{{- $port := (default "6379" .Values.redis.networkPolicy.port ) -}} +{{- if .Values.redis.networkPolicy.inCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.internal.cilium" (dict "endpointSelector" .Values.redis.networkPolicy.inCluster.cilium.endpointSelector "serviceSelector" .Values.redis.networkPolicy.inCluster.cilium.serviceSelector "port" $port) }} +{{- else if .Values.redis.networkPolicy.externalToCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" .Values.redis.networkPolicy.externalToCluster.ipv4 "fqdn" .Values.redis.networkPolicy.externalToCluster.host "port" $port) }} +{{- end -}} +{{- end }} + +{{/* +Creates a Kubernetes Network Policy egress definition for connecting to Postgres */}} {{- define "speckle.networkpolicy.egress.postgres" -}} {{- $port := (default "5432" .Values.db.networkPolicy.port ) -}} {{- if .Values.db.networkPolicy.inCluster.enabled -}} -{{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.db.networkPolicy.inCluster.podSelector "namespaceSelector" .Values.db.networkPolicy.inCluster.namespaceSelector "port" $port) }} +{{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.db.networkPolicy.inCluster.kubernetes.podSelector "namespaceSelector" .Values.db.networkPolicy.inCluster.kubernetes.namespaceSelector "port" $port) }} {{- else if .Values.db.networkPolicy.externalToCluster.enabled -}} {{ include "speckle.networkpolicy.egress.external" (dict "ip" .Values.db.networkPolicy.externalToCluster.ipv4 "port" $port) }} {{- end -}} {{- end }} {{/* -Creates a network policy egress definition for connecting to Postgres +Creates a Cilium network policy egress definition for connecting to Postgres +*/}} +{{- define "speckle.networkpolicy.egress.postgres.cilium" -}} +{{- $port := (default "5432" .Values.db.networkPolicy.port ) -}} +{{- if .Values.db.networkPolicy.inCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.internal.cilium" (dict "endpointSelector" .Values.db.networkPolicy.inCluster.cilium.endpointSelector "serviceSelector" .Values.db.networkPolicy.inCluster.cilium.serviceSelector "port" $port) }} +{{- else if .Values.db.networkPolicy.externalToCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" .Values.db.networkPolicy.externalToCluster.ipv4 "fqdn" .Values.db.networkPolicy.externalToCluster.host "port" $port) }} +{{- end -}} +{{- end }} + +{{/* +Creates a Kubernetes network policy egress definition for connecting to S3 compatible storage */}} {{- define "speckle.networkpolicy.egress.blob_storage" -}} {{- $port := (default "443" .Values.s3.networkPolicy.port ) -}} {{- if .Values.s3.networkPolicy.inCluster.enabled -}} -{{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.s3.networkPolicy.inCluster.podSelector "namespaceSelector" .Values.s3.networkPolicy.inCluster.namespaceSelector "port" $port) }} +{{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.s3.networkPolicy.inCluster.kubernetes.podSelector "namespaceSelector" .Values.s3.networkPolicy.inCluster.kubernetes.namespaceSelector "port" $port) }} {{- else if .Values.s3.networkPolicy.externalToCluster.enabled -}} - {{- $host := (urlParse .Values.s3.endpoint).host -}} - {{- if (contains ":" $host) -}} - {{- $host = first (mustRegexSplit ":" $host) -}} - {{- end -}} + {{- $host := ( include "speckle.networkPolicy.domainFromUrl" .Values.s3.endpoint ) -}} {{- $ip := "" -}} {{- if eq (include "speckle.isIPv4" $host) "true" -}} {{- $ip = $host -}} @@ -140,6 +163,73 @@ Creates a network policy egress definition for connecting to Postgres {{- end -}} {{- end }} +{{/* +Creates a Cilium Network Policy egress definition for connecting to S3 compatible storage +*/}} +{{- define "speckle.networkpolicy.egress.blob_storage.cilium" -}} +{{- $port := (default "443" .Values.s3.networkPolicy.port ) -}} +{{- if .Values.s3.networkPolicy.inCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.internal.cilium" (dict "endpointSelector" .Values.s3.networkPolicy.inCluster.cilium.endpointSelector "serviceSelector" .Values.s3.networkPolicy.inCluster.cilium.serviceSelector "port" $port) }} +{{- else if .Values.s3.networkPolicy.externalToCluster.enabled -}} + {{- $host := ( include "speckle.networkPolicy.domainFromUrl" .Values.s3.endpoint ) -}} + {{- $ip := "" -}} + {{- $fqdn := "" -}} + {{- if eq (include "speckle.isIPv4" $host) "true" -}} + {{- $ip = $host -}} + {{- else -}} + {{- $fqdn = $host -}} + {{- end -}} +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" $ip "fqdn" $fqdn "port" $port) }} +{{- end -}} +{{- end }} + +{{/* +Extracts the domain name from a url +*/}} +{{- define "speckle.networkPolicy.domainFromUrl" -}} +{{- $host := ( urlParse . ).host -}} +{{- if (contains ":" $host) -}} + {{- $host = first (mustRegexSplit ":" $host) -}} +{{- end -}} +{{ printf "%s" $host }} +{{- end }} + +{{/* +Creates a DNS match pattern for Cilium Network Policies. + +Usage: +{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster .Values.redis.networkPolicy.externalToCluster) }} + +Params: + - domain names - List of dictionaries containing `ipv4` and `host` string values - Required - If IP exists, domain is not added. Otherwise host is used to match domain excactly or match a pattern (domain with a glob). +*/}} +{{- define "speckle.networkpolicy.dns.cilium" -}} +{{- $catchAll := false -}} +{{- range . -}} + {{- if ( and .enabled ( not .ipv4 ) ) }} + {{- if .host -}} +{{ include "speckle.networkpolicy.matchNameOrPattern" .host }} + {{- else }} + # only add catch all match pattern if there is no ipv4 or host, and only add it one time. + {{- if not $catchAll }} +- matchPattern: "*" + {{- $catchAll = true }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} +{{- end }} + +{{/* +Creates a DNS match pattern for discovering blob storage IP +*/}} +{{- define "speckle.networkpolicy.dns.blob_storage.cilium" -}} +{{- $domain := ( include "speckle.networkPolicy.domainFromUrl" .Values.s3.endpoint ) -}} + {{- if ne (include "speckle.isIPv4" $domain ) "true" -}} +{{ include "speckle.networkpolicy.matchNameOrPattern" $domain }} + {{- end }} +{{- end }} + {{/* Creates a network policy egress definition for connecting to an external url:port or ip:port @@ -174,11 +264,57 @@ Limitations: - port: {{ printf "%s" .port }} {{- end }} +{{/* +Creates a Cilium network policy egress definition for connecting to an external Layer 3/Layer 4 endpoint i.e. ip:port + +Usage: +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" "" "fqdn" "myredis.example.org" "port" "6379") }} + +Params: + - ip - String - Optional - IP of the endpoint to allow egress to. Can provide either ip, fqdn or neither. If both IP or FQDN are provided, IP takes precedence. If neither fqdn or ip is provided then egress is allowed to 0.0.0.0/0 (i.e. everywhere!) + - fpdn - String - Optional - Domain name of the endpoint to allow egress to. Can include a pattern matching glob '*'. Can provide either ip, fqdn, or neither. If both IP or FQDN are provided, IP takes precedence. If neigher, then egress is allowed to 0.0.0.0/0 (i.e. everywhere!) + - port - String - Required + +Limitations: + - IP is limited to IPv4 due to Kubernetes use of IPv4 CIDR +*/}} +{{- define "speckle.networkpolicy.egress.external.cilium" -}} +{{- if not .port -}} + {{- printf "\nNETWORKPOLICY ERROR: The port was not provided \"%s\"\n" .port | fail -}} +{{- end -}} +{{- if .ip }} +- toCIDR: + - {{ printf "%s/32" .ip }} +{{- else if .fqdn }} +- toFQDNs: +{{ include "speckle.networkpolicy.matchNameOrPattern" .fqdn | indent 4 }} +{{- else }} +- toCIDRSet: + # Kubernetes network policy does not support fqdn, so we have to allow egress anywhere + - cidr: 0.0.0.0/0 + # except to kubernetes pods or services + except: + - 10.0.0.0/8 +{{- end }} + toPorts: + - ports: + - port: {{ printf "%s" .port | quote }} + protcol: TCP +{{- end }} + +{{- define "speckle.networkpolicy.matchNameOrPattern" -}} + {{- if ( contains "*" . ) }} +- matchPattern: {{ printf "%s" . }} + {{- else }} +- matchName: {{ printf "%s" . }} + {{- end }} +{{- end }} + {{/* Creates a network policy egress definition for connecting to a pod within the cluster Usage: -{{ include "speckle.networkpolicy.egress.internal" (dict "podSelectorLabels" {matchLabels.name=redis} "namespaceSelector" {matchLabels.name=redis} "port" "6379") }} +{{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" {matchLabels.name=redis} "namespaceSelector" {matchLabels.name=redis} "port" "6379") }} Params: - podSelector - Object - Required @@ -205,6 +341,43 @@ Params: - port: {{ printf "%s" .port }} {{- end }} +{{/* +Creates a cilium network policy egress definition for connecting to an endpoint (pod or kubernetes endpoint) or service within the cluster + +Usage: +{{ include "speckle.networkpolicy.egress.internal.cilium" (dict "endpointSelector" {matchLabels.name=redis matchLabels."io.kubernetes.pod.namespace.labels.name"=speckle} "serviceSelector" "" "port" "6379") }} + +Params: + - endpointSelector - Object - One of endpointSelector or serviceSelector is required. + - serviceSelector - Object - One of endpointSelector or serviceSelector is required. + - port - String - Required + +*/}} +{{- define "speckle.networkpolicy.egress.internal.cilium" -}} +{{- if not .endpointSelector -}} + {{- printf "\nNETWORKPOLICY ERROR: The Endpoint selector was not provided\n" | fail -}} +{{- end -}} +{{- if not .port -}} + {{- printf "\nNETWORKPOLICY ERROR: The port was not provided \"%s\"\n" .port | fail -}} +{{- end -}} +{{- if .endpointSelector }} +- toEndpoints: +{{ .endpointSelector | toYaml | indent 4 }} + toPorts: + - ports: + - port: {{ printf "%s" .port | quote }} + protocol: TCP +{{- end }} +{{- if .serviceSelector }} +- toServices: +{{ .serviceSelector | toYaml | indent 4 }} + toPorts: + - ports: + - port: {{ printf "%s" .port | quote }} + protcol: TCP +{{- end }} +{{- end }} + {{/* Tries to determine if a given string is a valid IP address Usage: @@ -233,3 +406,25 @@ Usage: {{- tpl (.value | toYaml) .context }} {{- end }} {{- end -}} + +{{/* +Selector labels for Prometheus +*/}} +{{- define "speckle.prometheus.selectorLabels" -}} +{{ include "speckle.prometheus.selectorLabels.release" . }} +io.kubernetes.pod.namespace: {{ default .Values.namespace .Values.prometheusMonitoring.namespace }} +{{- end }} + +{{/* +Selector labels for Prometheus release +*/}} +{{- define "speckle.prometheus.selectorLabels.release" -}} +prometheus: {{ default "kube-prometheus-stack" .Values.prometheusMonitoring.release }}-prometheus +{{- end }} + +{{/* +Ingress pod selector +*/}} +{{- define "speckle.ingress.selector.pod" -}} +app.kubernetes.io/name: {{ .Values.ingress.controllerName }} +{{- end }} diff --git a/utils/helm/speckle-server/templates/fileimport_service/deployment.yml b/utils/helm/speckle-server/templates/fileimport_service/deployment.yml index 6fdad3b0d..4c9acf3b9 100644 --- a/utils/helm/speckle-server/templates/fileimport_service/deployment.yml +++ b/utils/helm/speckle-server/templates/fileimport_service/deployment.yml @@ -66,7 +66,7 @@ spec: env: - name: SPECKLE_SERVER_URL - value: "http://speckle-server:3000" + value: {{ printf "http://%s:%s" ( include "server.service.fqdn" $ ) ( include "server.port" $ ) }} - name: PG_CONNECTION_STRING valueFrom: diff --git a/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.cilium.yml new file mode 100644 index 000000000..03f0929b0 --- /dev/null +++ b/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.cilium.yml @@ -0,0 +1,46 @@ +{{- if (and (.Values.fileimport_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "cilium")) -}} +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "fileimport_service.name" $ }} + namespace: {{ .Values.namespace }} + labels: +{{ include "fileimport_service.labels" . | indent 4 }} +spec: + endpointSelector: + matchLabels: +{{ include "fileimport_service.selectorLabels" . | indent 6 }} + ingress: + - fromEndpoints: + - matchLabels: +{{ include "speckle.prometheus.selectorLabels" $ | indent 12 }} + toPorts: + - ports: + - port: "metrics" + protocol: TCP + egress: + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + rules: + dns: + - matchName: {{ include "server.service.fqdn" $ }} +{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster) | indent 14 }} + # allow egress to speckle-server + - toServices: + - k8sServiceSelector: + namespace: {{ printf "%s" .Values.namespace }} + selector: + matchLabels: +{{ include "server.selectorLabels" $ | indent 16 }} + toPorts: + - ports: + - port: {{ printf "%s" ( include "server.port" $ | quote ) }} + # postgres +{{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} +{{- end }} diff --git a/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.yml b/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.kubernetes.yml similarity index 84% rename from utils/helm/speckle-server/templates/fileimport_service/networkpolicy.yml rename to utils/helm/speckle-server/templates/fileimport_service/networkpolicy.kubernetes.yml index e7a92305b..0a8f4ea2a 100644 --- a/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.yml +++ b/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.kubernetes.yml @@ -1,4 +1,4 @@ -{{- if .Values.fileimport_service.networkPolicy.enabled -}} +{{- if (and (.Values.fileimport_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "kubernetes")) -}} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -20,7 +20,7 @@ spec: kubernetes.io/metadata.name: {{ default .Values.namespace .Values.prometheusMonitoring.namespace }} podSelector: matchLabels: - prometheus: {{ default "kube-prometheus-stack" .Values.prometheusMonitoring.release }}-prometheus + {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: metrics egress: diff --git a/utils/helm/speckle-server/templates/frontend/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/frontend/networkpolicy.cilium.yml new file mode 100644 index 000000000..6051dc31c --- /dev/null +++ b/utils/helm/speckle-server/templates/frontend/networkpolicy.cilium.yml @@ -0,0 +1,25 @@ +{{- if (and (.Values.frontend.networkPolicy.enabled) (eq .Values.networkPlugin.type "cilium")) -}} +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "frontend.name" $ }} + namespace: {{ .Values.namespace }} + labels: +{{ include "frontend.labels" . | indent 4 }} +spec: + endpointSelector: + matchLabels: +{{ include "frontend.selectorLabels" . | indent 6 }} + ingress: + - fromEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: {{ .Values.ingress.namespace }} +{{ include "speckle.ingress.selector.pod" $ | indent 12 }} + toPorts: + - ports: + - port: "www" + protocol: TCP + egressDeny: + - toEntities: + - "all" +{{- end }} diff --git a/utils/helm/speckle-server/templates/frontend/networkpolicy.yml b/utils/helm/speckle-server/templates/frontend/networkpolicy.kubernetes.yml similarity index 78% rename from utils/helm/speckle-server/templates/frontend/networkpolicy.yml rename to utils/helm/speckle-server/templates/frontend/networkpolicy.kubernetes.yml index 57ba8114a..e62641d94 100644 --- a/utils/helm/speckle-server/templates/frontend/networkpolicy.yml +++ b/utils/helm/speckle-server/templates/frontend/networkpolicy.kubernetes.yml @@ -1,4 +1,4 @@ -{{- if .Values.frontend.networkPolicy.enabled -}} +{{- if (and (.Values.fileimport_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "kubernetes")) -}} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -21,7 +21,7 @@ spec: kubernetes.io/metadata.name: {{ .Values.ingress.namespace }} - podSelector: matchLabels: - app.kubernetes.io/name: {{ .Values.ingress.controllerName }} +{{ include "frontend.ingress.selector.pod" $ | indent 14 }} ports: - port: www egress: [] # block all egress diff --git a/utils/helm/speckle-server/templates/monitoring/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/monitoring/networkpolicy.cilium.yml new file mode 100644 index 000000000..8d7d7e2e7 --- /dev/null +++ b/utils/helm/speckle-server/templates/monitoring/networkpolicy.cilium.yml @@ -0,0 +1,35 @@ +{{- if (and (.Values.monitoring.networkPolicy.enabled) (eq .Values.networkPlugin.type "cilium")) -}} +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "monitoring.name" $ }} + namespace: {{ .Values.namespace }} + labels: +{{ include "monitoring.labels" . | indent 4 }} +spec: + endpointSelector: + matchLabels: +{{ include "monitoring.selectorLabels" . | indent 6 }} + ingress: + - fromEndpoints: + - matchLabels: +{{ include "speckle.prometheus.selectorLabels" $ | indent 12 }} + toPorts: + - ports: + - port: "metrics" + protocol: TCP + egress: + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + rules: + dns: +{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster) | indent 14 }} + # postgres +{{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} +{{- end }} diff --git a/utils/helm/speckle-server/templates/monitoring/networkpolicy.yml b/utils/helm/speckle-server/templates/monitoring/networkpolicy.kubernetes.yml similarity index 82% rename from utils/helm/speckle-server/templates/monitoring/networkpolicy.yml rename to utils/helm/speckle-server/templates/monitoring/networkpolicy.kubernetes.yml index 307bd60fd..143abaa91 100644 --- a/utils/helm/speckle-server/templates/monitoring/networkpolicy.yml +++ b/utils/helm/speckle-server/templates/monitoring/networkpolicy.kubernetes.yml @@ -1,4 +1,4 @@ -{{- if .Values.monitoring.networkPolicy.enabled -}} +{{- if (and (.Values.fileimport_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "kubernetes")) -}} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -20,7 +20,7 @@ spec: kubernetes.io/metadata.name: {{ default .Values.namespace .Values.prometheusMonitoring.namespace }} podSelector: matchLabels: - prometheus: {{ default "kube-prometheus-stack" .Values.prometheusMonitoring.release }}-prometheus + {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: metrics egress: diff --git a/utils/helm/speckle-server/templates/preview_service/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/preview_service/networkpolicy.cilium.yml new file mode 100644 index 000000000..30c083e60 --- /dev/null +++ b/utils/helm/speckle-server/templates/preview_service/networkpolicy.cilium.yml @@ -0,0 +1,35 @@ +{{- if (and (.Values.preview_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "cilium")) -}} +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "preview_service.name" $ }} + namespace: {{ .Values.namespace }} + labels: +{{ include "preview_service.labels" . | indent 4 }} +spec: + endpointSelector: + matchLabels: +{{ include "preview_service.selectorLabels" . | indent 6 }} + ingress: + - fromEndpoints: + - matchLabels: +{{ include "speckle.prometheus.selectorLabels" $ | indent 12 }} + toPorts: + - ports: + - port: "metrics" + protocol: TCP + egress: + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + rules: + dns: +{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster) | indent 14 }} + # postgres +{{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} +{{- end }} diff --git a/utils/helm/speckle-server/templates/preview_service/networkpolicy.yml b/utils/helm/speckle-server/templates/preview_service/networkpolicy.kubernetes.yml similarity index 82% rename from utils/helm/speckle-server/templates/preview_service/networkpolicy.yml rename to utils/helm/speckle-server/templates/preview_service/networkpolicy.kubernetes.yml index 76ad67b9e..0ddeadc15 100644 --- a/utils/helm/speckle-server/templates/preview_service/networkpolicy.yml +++ b/utils/helm/speckle-server/templates/preview_service/networkpolicy.kubernetes.yml @@ -1,4 +1,4 @@ -{{- if .Values.preview_service.networkPolicy.enabled -}} +{{- if (and (.Values.fileimport_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "kubernetes")) -}} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -20,7 +20,7 @@ spec: kubernetes.io/metadata.name: {{ default .Values.namespace .Values.prometheusMonitoring.namespace }} podSelector: matchLabels: - prometheus: {{ default "kube-prometheus-stack" .Values.prometheusMonitoring.release }}-prometheus + {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: metrics egress: diff --git a/utils/helm/speckle-server/templates/server/_helpers.tpl b/utils/helm/speckle-server/templates/server/_helpers.tpl index a75b1f094..a0c79e8f5 100644 --- a/utils/helm/speckle-server/templates/server/_helpers.tpl +++ b/utils/helm/speckle-server/templates/server/_helpers.tpl @@ -41,6 +41,20 @@ app.kubernetes.io/name: {{ include "server.name" . }} {{ include "speckle.commonSelectorLabels" . }} {{- end }} +{{/* +Service FQDN +*/}} +{{- define "server.service.fqdn" -}} +{{ printf "%s.%s.svc.cluster.local." (include "server.name" $) .Values.namespace }} +{{- end }} + +{{/* +Server Port +*/}} +{{- define "server.port" -}} +{{ printf "%d" 3000 }} +{{- end }} + {{/* Create the name of the service account to use */}} diff --git a/utils/helm/speckle-server/templates/server/deployment.yml b/utils/helm/speckle-server/templates/server/deployment.yml index b24b8b5bf..152440e0b 100644 --- a/utils/helm/speckle-server/templates/server/deployment.yml +++ b/utils/helm/speckle-server/templates/server/deployment.yml @@ -1,7 +1,7 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: speckle-server + name: {{ include "server.name" $ }} namespace: {{ .Values.namespace }} labels: {{ include "server.labels" . | indent 4 }} @@ -25,7 +25,7 @@ spec: ports: - name: http - containerPort: 3000 + containerPort: {{ include "server.port" $ }} protocol: TCP resources: @@ -91,7 +91,7 @@ spec: {{- end }} - name: PORT - value: "3000" + value: {{ include "server.port" $ | quote }} - name: DEBUG value: "speckle:*" diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml new file mode 100644 index 000000000..ebe23eef1 --- /dev/null +++ b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml @@ -0,0 +1,78 @@ +{{- if (and (.Values.server.networkPolicy.enabled) (eq .Values.networkPlugin.type "cilium")) -}} +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "server.name" $ }} + namespace: {{ .Values.namespace }} + labels: +{{ include "server.labels" . | indent 4 }} +spec: + endpointSelector: + matchLabels: +{{ include "server.selectorLabels" . | indent 6 }} + ingress: + - fromEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: {{ .Values.ingress.namespace }} +{{ include "speckle.ingress.selector.pod" $ | indent 12 }} + toPorts: + - ports: + - port: http + protocol: TCP + - fromEndpoints: + - matchLabels: +{{ include "speckle.prometheus.selectorLabels" $ | indent 12 }} + toPorts: + - ports: + - port: http + protocol: TCP + # ingress from file import service + - fromEndpoints: + - matchLabels: +{{ include "fileimport_service.selectorLabels" $ | indent 12 }} + toPorts: + - ports: + - port: http + protocol: TCP + egress: + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + rules: + dns: + # TODO: remove egress to domain once https://github.com/specklesystems/speckle-server/issues/959 is fixed + - matchPattern: {{ .Values.domain }} +{{- if .Values.server.sentry_dns }} + # DNS lookup for sentry + - matchPattern: "*.ingest.sentry.io" +{{- end }} +{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster .Values.redis.networkPolicy.externalToCluster ) | indent 14 }} +{{ include "speckle.networkpolicy.dns.blob_storage.cilium" $ | indent 14 }} +{{- if .Values.server.sentry_dns }} + # egress to sentry + - toCIDRSet: + - cidr: 34.120.195.249/32 + toPorts: + - ports: + - port: "443" +{{- end }} + # postgres +{{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} + # redis +{{ include "speckle.networkpolicy.egress.redis.cilium" $ | indent 4 }} + # s3 +{{ include "speckle.networkpolicy.egress.blob_storage.cilium" $ | indent 4 }} + # allow egress to the ingress for speckle-server, so it can call itself + # TODO: remove egress to domain once https://github.com/specklesystems/speckle-server/issues/959 is fixed + - toFQDNs: + - matchPattern: {{ .Values.domain }} + toPorts: + - ports: + - port: "443" + protocol: TCP +{{- end }} diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.yml b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml similarity index 91% rename from utils/helm/speckle-server/templates/server/networkpolicy.yml rename to utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml index 7bc6ed66e..d5ee2601b 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml @@ -1,4 +1,4 @@ -{{- if .Values.server.networkPolicy.enabled -}} +{{- if (and (.Values.fileimport_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "kubernetes")) -}} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -55,7 +55,7 @@ spec: kubernetes.io/metadata.name: {{ default .Values.namespace .Values.prometheusMonitoring.namespace }} podSelector: matchLabels: - prometheus: {{ default "kube-prometheus-stack" .Values.prometheusMonitoring.release }}-prometheus + {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: http # allow ingress from the fileimport service diff --git a/utils/helm/speckle-server/templates/server/service.yml b/utils/helm/speckle-server/templates/server/service.yml index eda58029f..1cd2ccb4f 100644 --- a/utils/helm/speckle-server/templates/server/service.yml +++ b/utils/helm/speckle-server/templates/server/service.yml @@ -1,7 +1,7 @@ apiVersion: v1 kind: Service metadata: - name: speckle-server + name: {{ include "server.name" $ }} namespace: {{ .Values.namespace }} labels: {{ include "server.labels" . | indent 4 }} diff --git a/utils/helm/speckle-server/templates/test/_helpers.tpl b/utils/helm/speckle-server/templates/tests/_helpers.tpl similarity index 100% rename from utils/helm/speckle-server/templates/test/_helpers.tpl rename to utils/helm/speckle-server/templates/tests/_helpers.tpl diff --git a/utils/helm/speckle-server/templates/test/deployment.yml b/utils/helm/speckle-server/templates/tests/deployment.yml similarity index 100% rename from utils/helm/speckle-server/templates/test/deployment.yml rename to utils/helm/speckle-server/templates/tests/deployment.yml diff --git a/utils/helm/speckle-server/templates/tests/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/tests/networkpolicy.cilium.yml new file mode 100644 index 000000000..1d601919d --- /dev/null +++ b/utils/helm/speckle-server/templates/tests/networkpolicy.cilium.yml @@ -0,0 +1,35 @@ +{{- if (and (.Values.test.networkPolicy.enabled) (eq .Values.networkPlugin.type "cilium")) -}} +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "test.name" $ }} + namespace: {{ .Values.namespace }} + labels: +{{ include "test.labels" . | indent 4 }} +spec: + endpointSelector: + matchLabels: +{{ include "test.selectorLabels" . | indent 6 }} + ingressDeny: + - fromEntities: + - "all" + egress: + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + rules: + dns: + - matchName: {{ .Values.domain }} + # allow egress to domain hosting speckle-server + - toFQDNs: + - matchPattern: {{ .Values.domain }} + toPorts: + - ports: + - port: "443" + protocol: TCP +{{- end }} diff --git a/utils/helm/speckle-server/templates/test/networkpolicy.yml b/utils/helm/speckle-server/templates/tests/networkpolicy.kubernetes.yml similarity index 87% rename from utils/helm/speckle-server/templates/test/networkpolicy.yml rename to utils/helm/speckle-server/templates/tests/networkpolicy.kubernetes.yml index be18a04e9..349f5f557 100644 --- a/utils/helm/speckle-server/templates/test/networkpolicy.yml +++ b/utils/helm/speckle-server/templates/tests/networkpolicy.kubernetes.yml @@ -1,4 +1,4 @@ -{{- if and .Values.helm_test_enabled .Values.test.networkPolicy.enabled -}} +{{- if (and (.Values.fileimport_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "kubernetes")) -}} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: diff --git a/utils/helm/speckle-server/templates/test/serviceaccount.yml b/utils/helm/speckle-server/templates/tests/serviceaccount.yml similarity index 100% rename from utils/helm/speckle-server/templates/test/serviceaccount.yml rename to utils/helm/speckle-server/templates/tests/serviceaccount.yml diff --git a/utils/helm/speckle-server/templates/webhook_service/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/webhook_service/networkpolicy.cilium.yml new file mode 100644 index 000000000..964197c26 --- /dev/null +++ b/utils/helm/speckle-server/templates/webhook_service/networkpolicy.cilium.yml @@ -0,0 +1,40 @@ +{{- if (and (.Values.webhook_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "cilium")) -}} +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "webhook_service.name" $ }} + namespace: {{ .Values.namespace }} + labels: +{{ include "webhook_service.labels" . | indent 4 }} +spec: + endpointSelector: + matchLabels: +{{ include "webhook_service.selectorLabels" . | indent 6 }} + ingress: + - fromEndpoints: + - matchLabels: +{{ include "speckle.prometheus.selectorLabels" $ | indent 12 }} + toPorts: + - ports: + - port: "metrics" + protocol: TCP + egress: + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + rules: + dns: + # allow dns discoverability for all entities + - matchPattern: "*" +{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster) | indent 14 }} + # postgres +{{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} + # allow access to all entities outside of the cluster + - toEntities: + - world +{{- end }} diff --git a/utils/helm/speckle-server/templates/webhook_service/networkpolicy.yml b/utils/helm/speckle-server/templates/webhook_service/networkpolicy.kubernetes.yml similarity index 84% rename from utils/helm/speckle-server/templates/webhook_service/networkpolicy.yml rename to utils/helm/speckle-server/templates/webhook_service/networkpolicy.kubernetes.yml index 8055699c9..724431aa7 100644 --- a/utils/helm/speckle-server/templates/webhook_service/networkpolicy.yml +++ b/utils/helm/speckle-server/templates/webhook_service/networkpolicy.kubernetes.yml @@ -1,4 +1,4 @@ -{{- if .Values.webhook_service.networkPolicy.enabled -}} +{{- if (and (.Values.fileimport_service.networkPolicy.enabled) (eq .Values.networkPlugin.type "kubernetes")) -}} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -20,7 +20,7 @@ spec: kubernetes.io/metadata.name: {{ default .Values.namespace .Values.prometheusMonitoring.namespace }} podSelector: matchLabels: - prometheus: {{ default "kube-prometheus-stack" .Values.prometheusMonitoring.release }}-prometheus + {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: metrics egress: diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index 29d3ed487..8bf70c6f2 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -27,6 +27,16 @@ "description": "The name of the ClusterIssuer kubernetes resource that provides the SSL Certificate", "default": "letsencrypt-staging" }, + "networkPlugin": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "(Optional) Used to configure which type of NetworkPolicy is deployed. Options are 'kubernetes' or 'cilium'.", + "default": "kubernetes" + } + } + }, "ingress": { "type": "object", "properties": { @@ -141,15 +151,35 @@ "description": "If enabled, indicates that the Postgres database is hosted withing the same Kubernetes cluster in which Speckle will be deployed", "default": false }, - "podSelector": { + "kubernetes": { "type": "object", - "description": "The pod Selector yaml object used to uniquely select the Postgres database pods within the cluster and given namespace", - "default": {} + "properties": { + "podSelector": { + "type": "object", + "description": "(Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the postgres compatible database pods within the cluster and given namespace", + "default": {} + }, + "namespaceSelector": { + "type": "object", + "description": "(Kubernetes Network Policy only) The namespace selector yaml object used to uniquely select the namespace in which the postgres compatible database pods are deployed", + "default": {} + } + } }, - "namespaceSelector": { + "cilium": { "type": "object", - "description": "The namespace selector yaml object used to uniquely select the namespace in which the Postgres database pods are deployed", - "default": {} + "properties": { + "endpointSelector": { + "type": "object", + "description": "(Cilium Network Policy only) The endpoint selector yaml object used to uniquely select the in-cluster endpoint in which the postgres compatible database pods are deployed", + "default": {} + }, + "serviceSelector": { + "type": "object", + "description": "(Cilium Network Policy only) The service selector yaml object used to uniquely select the in-cluster service providing the postgres compatible database service", + "default": {} + } + } } } } @@ -221,15 +251,35 @@ "description": "If enabled, indicates that the s3 compatible storage is hosted withing the same Kubernetes cluster in which Speckle will be deployed", "default": false }, - "podSelector": { + "kubernetes": { "type": "object", - "description": "The pod Selector yaml object used to uniquely select the s3 compatible storage pods within the cluster and given namespace", - "default": {} + "properties": { + "podSelector": { + "type": "object", + "description": "(Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the s3 compatible storage pods within the cluster and given namespace", + "default": {} + }, + "namespaceSelector": { + "type": "object", + "description": "(Kubernetes Network Policy only) The namespace selector yaml object used to uniquely select the namespace in which the s3 compatible storage pods are deployed", + "default": {} + } + } }, - "namespaceSelector": { + "cilium": { "type": "object", - "description": "The namespace selector yaml object used to uniquely select the namespace in which the s3 compatible storage pods are deployed", - "default": {} + "properties": { + "endpointSelector": { + "type": "object", + "description": "(Cilium Network Policy only) The endpoint selector yaml object used to uniquely select the in-cluster endpoint in which the s3 compatible storage pods are deployed", + "default": {} + }, + "serviceSelector": { + "type": "object", + "description": "(Cilium Network Policy only) The service selector yaml object used to uniquely select the in-cluster service providing the s3 compatible storage service", + "default": {} + } + } } } } @@ -276,15 +326,35 @@ "description": "If enabled, indicates that the Redis store is hosted withing the same Kubernetes cluster in which Speckle will be deployed", "default": false }, - "podSelector": { + "kubernetes": { "type": "object", - "description": "The pod Selector yaml object used to uniquely select the Redis store pods within the cluster and given namespace", - "default": {} + "properties": { + "podSelector": { + "type": "object", + "description": "(Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the redis store pods within the cluster and given namespace", + "default": {} + }, + "namespaceSelector": { + "type": "object", + "description": "(Kubernetes Network Policy only) The namespace selector yaml object used to uniquely select the namespace in which the redis store pods are deployed", + "default": {} + } + } }, - "namespaceSelector": { + "cilium": { "type": "object", - "description": "The namespace selector yaml object used to uniquely select the namespace in which the Redis store pods are deployed", - "default": {} + "properties": { + "endpointSelector": { + "type": "object", + "description": "(Cilium Network Policy only) The endpoint selector yaml object used to uniquely select the in-cluster endpoint in which the redis pods are deployed", + "default": {} + }, + "serviceSelector": { + "type": "object", + "description": "(Cilium Network Policy only) The service selector yaml object used to uniquely select the in-cluster service providing the redis store service", + "default": {} + } + } } } } diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 7426cf7c7..e21b82e62 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -26,6 +26,16 @@ ssl_canonical_url: true ## cert_manager_issuer: letsencrypt-staging +## @section Network Plugin configuration +## @descriptionStart +## This is used to define the type of network policy that is deployed. +## Different Kubernetes Network Plugins or Container Network Interfaces (CNIs) can make use of different types of +## Network Policy. Some of these provide more features than the standard Kubernetes Network Policy. +## @descriptionEnd +networkPlugin: + ## @param networkPlugin.type (Optional) Used to configure which type of NetworkPolicy is deployed. Options are 'kubernetes' or 'cilium'. + type: 'kubernetes' + ## @section Ingress metadata for NetworkPolicy ## @descriptionStart ## This section is ignored unless networkPolicy is enabled for frontend or server. @@ -149,18 +159,32 @@ db: ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only set of egress network policy rules deployed. ## enabled: false - ## @param db.networkPolicy.inCluster.podSelector The pod Selector yaml object used to uniquely select the Postgres database pods within the cluster and given namespace - ## This is a Kubernetes podSelector object - ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors - ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ - ## - podSelector: {} - ## @param db.networkPolicy.inCluster.namespaceSelector The namespace selector yaml object used to uniquely select the namespace in which the Postgres database pods are deployed - ## This is a Kubernetes namespaceSelector object - ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors - ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ - ## - namespaceSelector: {} + kubernetes: + ## @param db.networkPolicy.inCluster.kubernetes.podSelector (Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the postgres compatible database pods within the cluster and given namespace + ## For Kubernetes Network Policies this is a podSelector object. + ## For Cilium Network Policies this is ignored. + ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors + ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + ## + podSelector: {} + ## @param db.networkPolicy.inCluster.kubernetes.namespaceSelector (Kubernetes Network Policy only) The namespace selector yaml object used to uniquely select the namespace in which the postgres compatible database pods are deployed + ## This is a Kubernetes namespaceSelector object. + ## For Cilium Network Policies this is ignored + ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors + ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + ## + namespaceSelector: {} + cilium: + ## @param db.networkPolicy.inCluster.cilium.endpointSelector (Cilium Network Policy only) The endpoint selector yaml object used to uniquely select the in-cluster endpoint in which the postgres compatible database pods are deployed + ## For Kubernetes Network Policies this is ignored. + ## ref: https://docs.cilium.io/en/v1.9/policy/language/#egress + ## ref: https://github.com/cilium/cilium/blob/master/pkg/policy/api/selector.go + endpointSelector: {} + ## @param db.networkPolicy.inCluster.cilium.serviceSelector (Cilium Network Policy only) The service selector yaml object used to uniquely select the in-cluster service providing the postgres compatible database service + ## For Kubernetes Network Policies this is ignored. + ## ref: https://docs.cilium.io/en/v1.9/policy/language/#egress + ## ref: https://github.com/cilium/cilium/blob/master/pkg/policy/api/service.go + serviceSelector: {} ## @section S3 Compatible Storage ## @descriptionStart @@ -221,18 +245,32 @@ s3: ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only set of egress network policy rules deployed. ## enabled: false - ## @param s3.networkPolicy.inCluster.podSelector The pod Selector yaml object used to uniquely select the s3 compatible storage pods within the cluster and given namespace - ## This is a Kubernetes podSelector object - ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors - ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ - ## - podSelector: {} - ## @param s3.networkPolicy.inCluster.namespaceSelector The namespace selector yaml object used to uniquely select the namespace in which the s3 compatible storage pods are deployed - ## This is a Kubernetes namespaceSelector object - ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors - ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ - ## - namespaceSelector: {} + kubernetes: + ## @param s3.networkPolicy.inCluster.kubernetes.podSelector (Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the s3 compatible storage pods within the cluster and given namespace + ## For Kubernetes Network Policies this is a podSelector object. + ## For Cilium Network Policies this is ignored. + ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors + ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + ## + podSelector: {} + ## @param s3.networkPolicy.inCluster.kubernetes.namespaceSelector (Kubernetes Network Policy only) The namespace selector yaml object used to uniquely select the namespace in which the s3 compatible storage pods are deployed + ## This is a Kubernetes namespaceSelector object. + ## For Cilium Network Policies, this is ignored + ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors + ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + ## + namespaceSelector: {} + cilium: + ## @param s3.networkPolicy.inCluster.cilium.endpointSelector (Cilium Network Policy only) The endpoint selector yaml object used to uniquely select the in-cluster endpoint in which the s3 compatible storage pods are deployed + ## For Kubernetes Network Policies, this is ignored. + ## ref: https://docs.cilium.io/en/v1.9/policy/language/#egress + ## ref: https://github.com/cilium/cilium/blob/master/pkg/policy/api/selector.go + endpointSelector: {} + ## @param s3.networkPolicy.inCluster.cilium.serviceSelector (Cilium Network Policy only) The service selector yaml object used to uniquely select the in-cluster service providing the s3 compatible storage service + ## For Kubernetes Network Policies this is ignored. + ## ref: https://docs.cilium.io/en/v1.9/policy/language/#egress + ## ref: https://github.com/cilium/cilium/blob/master/pkg/policy/api/service.go + serviceSelector: {} ## @section Redis Store ## @descriptionStart @@ -273,18 +311,32 @@ redis: ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only set of egress network policy rules deployed. ## enabled: false - ## @param redis.networkPolicy.inCluster.podSelector The pod Selector yaml object used to uniquely select the Redis store pods within the cluster and given namespace - ## This is a Kubernetes podSelector object - ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors - ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ - ## - podSelector: {} - ## @param redis.networkPolicy.inCluster.namespaceSelector The namespace selector yaml object used to uniquely select the namespace in which the Redis store pods are deployed - ## This is a Kubernetes namespaceSelector object - ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors - ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ - ## - namespaceSelector: {} + kubernetes: + ## @param redis.networkPolicy.inCluster.kubernetes.podSelector (Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the redis store pods within the cluster and given namespace + ## For Kubernetes Network Policies this is a podSelector object. + ## For Cilium Network Policies this is ignored. + ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors + ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + ## + podSelector: {} + ## @param redis.networkPolicy.inCluster.kubernetes.namespaceSelector (Kubernetes Network Policy only) The namespace selector yaml object used to uniquely select the namespace in which the redis store pods are deployed + ## This is a Kubernetes namespaceSelector object. + ## For Cilium Network Policies, this is ignored + ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors + ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + ## + namespaceSelector: {} + cilium: + ## @param redis.networkPolicy.inCluster.cilium.endpointSelector (Cilium Network Policy only) The endpoint selector yaml object used to uniquely select the in-cluster endpoint in which the redis pods are deployed + ## For Kubernetes Network Policies, this is ignored. + ## ref: https://docs.cilium.io/en/v1.9/policy/language/#egress + ## ref: https://github.com/cilium/cilium/blob/master/pkg/policy/api/selector.go + endpointSelector: {} + ## @param redis.networkPolicy.inCluster.cilium.serviceSelector (Cilium Network Policy only) The service selector yaml object used to uniquely select the in-cluster service providing the redis store service + ## For Kubernetes Network Policies this is ignored. + ## ref: https://docs.cilium.io/en/v1.9/policy/language/#egress + ## ref: https://github.com/cilium/cilium/blob/master/pkg/policy/api/service.go + serviceSelector: {} ## @section Server ## @descriptionStart diff --git a/utils/test-deployment/run_tests.py b/utils/test-deployment/run_tests.py index 2b78eaff2..24ca7a745 100755 --- a/utils/test-deployment/run_tests.py +++ b/utils/test-deployment/run_tests.py @@ -51,9 +51,12 @@ if len(sys.argv) > 2: if not SERVER_VERSION: SERVER_VERSION = os.getenv('SERVER_VERSION') if SERVER_VERSION: - assert server_info.version == SERVER_VERSION, f"The deployed version {server_info.version} doesn't match the expected {SERVER_VERSION}" - print(f"Server version {SERVER_VERSION} is deployed and available") + if not SERVER_VERSION == 'latest': + assert server_info.version == SERVER_VERSION, f"The deployed version {server_info.version} doesn't match the expected {SERVER_VERSION}" + print(f"Server version {SERVER_VERSION} is deployed and available") + else: + print("Not testing server version, as it was set to 'latest'") else: - print("Not testing server version, since it an expected value was not provided via env var or command-line argument") + print("Not testing server version, as an expected value was not provided via environment variables or command-line argument") print('Deployment tests PASS') From d6f6a64630c44755e25bcc6fc6164b46a3ef5e2e Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 25 Aug 2022 09:36:15 +0100 Subject: [PATCH 19/28] fix(helm chart): remove unnecessary values from helm chart (#964) * fix(helm chart): remove unused values from helm chart Previous commit introduced two additional values that are not being used for s3. This commit removes them. * Looks up domain or IP from secret for redis and postgres - undertakes a kubectl get on the secret. The user or service account that deploys helm must have permissions to view the secret. - fix: matchName for domain instead of matchPattern - fix: typo in protocol * Only allow monitoring ingress if monitoring is enabled * Port can be determine from the provided secret - updates values.yaml to only require port for postgres and redis for inCluster endpoints --- .../speckle-server/templates/_helpers.tpl | 175 +++++++++++------- .../networkpolicy.cilium.yml | 8 +- .../networkpolicy.kubernetes.yml | 5 + .../monitoring/networkpolicy.cilium.yml | 8 +- .../monitoring/networkpolicy.kubernetes.yml | 5 + .../preview_service/networkpolicy.cilium.yml | 8 +- .../networkpolicy.kubernetes.yml | 5 + .../templates/server/networkpolicy.cilium.yml | 7 +- .../server/networkpolicy.kubernetes.yml | 2 + .../templates/tests/networkpolicy.cilium.yml | 4 +- .../webhook_service/networkpolicy.cilium.yml | 8 +- .../networkpolicy.kubernetes.yml | 5 + utils/helm/speckle-server/values.schema.json | 50 +---- utils/helm/speckle-server/values.yaml | 42 +---- 14 files changed, 182 insertions(+), 150 deletions(-) diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 86be36ba6..16eab70e5 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -100,11 +100,14 @@ Creates a network policy egress definition for connecting to Redis Expects the global context "$" to be passed as the parameter */}} {{- define "speckle.networkpolicy.egress.redis" -}} -{{- $port := (default "6379" .Values.redis.networkPolicy.port ) -}} {{- if .Values.redis.networkPolicy.inCluster.enabled -}} + {{- $port := (default "6379" .Values.redis.networkPolicy.inCluster.port ) -}} {{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.redis.networkPolicy.inCluster.kubernetes.podSelector "namespaceSelector" .Values.redis.networkPolicy.inCluster.kubernetes.namespaceSelector "port" $port) }} {{- else if .Values.redis.networkPolicy.externalToCluster.enabled -}} -{{ include "speckle.networkpolicy.egress.external" (dict "ip" .Values.redis.networkPolicy.externalToCluster.ipv4 "port" $port) }} + {{- $secret := ( include "speckle.getSecret" (dict "secret_key" "redis_url" "context" . ) ) -}} + {{- $domain := ( include "speckle.networkPolicy.domainFromUrl" $secret ) -}} + {{- $port := ( default "6379" ( include "speckle.networkPolicy.portFromUrl" $secret ) ) -}} +{{ include "speckle.networkpolicy.egress.external" (dict "ip" $domain "port" $port) }} {{- end -}} {{- end }} @@ -114,11 +117,14 @@ Creates a Cilium Network Policy egress definition for connecting to Redis Expects the global context "$" to be passed as the parameter */}} {{- define "speckle.networkpolicy.egress.redis.cilium" -}} -{{- $port := (default "6379" .Values.redis.networkPolicy.port ) -}} {{- if .Values.redis.networkPolicy.inCluster.enabled -}} + {{- $port := (default "6379" .Values.redis.networkPolicy.inCluster.port ) -}} {{ include "speckle.networkpolicy.egress.internal.cilium" (dict "endpointSelector" .Values.redis.networkPolicy.inCluster.cilium.endpointSelector "serviceSelector" .Values.redis.networkPolicy.inCluster.cilium.serviceSelector "port" $port) }} {{- else if .Values.redis.networkPolicy.externalToCluster.enabled -}} -{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" .Values.redis.networkPolicy.externalToCluster.ipv4 "fqdn" .Values.redis.networkPolicy.externalToCluster.host "port" $port) }} + {{- $secret := ( include "speckle.getSecret" (dict "secret_key" "redis_url" "context" . ) ) -}} + {{- $domain := ( include "speckle.networkPolicy.domainFromUrl" $secret ) -}} + {{- $port := ( default "6379" ( include "speckle.networkPolicy.portFromUrl" $secret ) ) -}} +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" $domain "port" $port) }} {{- end -}} {{- end }} @@ -126,11 +132,14 @@ Expects the global context "$" to be passed as the parameter Creates a Kubernetes Network Policy egress definition for connecting to Postgres */}} {{- define "speckle.networkpolicy.egress.postgres" -}} -{{- $port := (default "5432" .Values.db.networkPolicy.port ) -}} {{- if .Values.db.networkPolicy.inCluster.enabled -}} + {{- $port := (default "5432" .Values.db.networkPolicy.inCluster.port ) -}} {{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.db.networkPolicy.inCluster.kubernetes.podSelector "namespaceSelector" .Values.db.networkPolicy.inCluster.kubernetes.namespaceSelector "port" $port) }} {{- else if .Values.db.networkPolicy.externalToCluster.enabled -}} -{{ include "speckle.networkpolicy.egress.external" (dict "ip" .Values.db.networkPolicy.externalToCluster.ipv4 "port" $port) }} + {{- $secret := ( include "speckle.getSecret" (dict "secret_key" "postgres_url" "context" . ) ) -}} + {{- $domain := ( include "speckle.networkPolicy.domainFromUrl" $secret ) -}} + {{- $port := ( default "5432" ( include "speckle.networkPolicy.portFromUrl" $secret ) ) -}} +{{ include "speckle.networkpolicy.egress.external" (dict "ip" $domain "port" $port) }} {{- end -}} {{- end }} @@ -138,11 +147,14 @@ Creates a Kubernetes Network Policy egress definition for connecting to Postgres Creates a Cilium network policy egress definition for connecting to Postgres */}} {{- define "speckle.networkpolicy.egress.postgres.cilium" -}} -{{- $port := (default "5432" .Values.db.networkPolicy.port ) -}} {{- if .Values.db.networkPolicy.inCluster.enabled -}} + {{- $port := (default "5432" .Values.db.networkPolicy.inCluster.port ) -}} {{ include "speckle.networkpolicy.egress.internal.cilium" (dict "endpointSelector" .Values.db.networkPolicy.inCluster.cilium.endpointSelector "serviceSelector" .Values.db.networkPolicy.inCluster.cilium.serviceSelector "port" $port) }} {{- else if .Values.db.networkPolicy.externalToCluster.enabled -}} -{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" .Values.db.networkPolicy.externalToCluster.ipv4 "fqdn" .Values.db.networkPolicy.externalToCluster.host "port" $port) }} + {{- $secret := ( include "speckle.getSecret" (dict "secret_key" "postgres_url" "context" . ) ) -}} + {{- $domain := ( include "speckle.networkPolicy.domainFromUrl" $secret ) -}} + {{- $port := ( default "5432" ( include "speckle.networkPolicy.portFromUrl" $secret ) ) -}} +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" $domain "port" $port) }} {{- end -}} {{- end }} @@ -150,74 +162,60 @@ Creates a Cilium network policy egress definition for connecting to Postgres Creates a Kubernetes network policy egress definition for connecting to S3 compatible storage */}} {{- define "speckle.networkpolicy.egress.blob_storage" -}} -{{- $port := (default "443" .Values.s3.networkPolicy.port ) -}} -{{- if .Values.s3.networkPolicy.inCluster.enabled -}} + {{- $port := (default "443" .Values.s3.networkPolicy.port ) -}} + {{- if .Values.s3.networkPolicy.inCluster.enabled -}} {{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.s3.networkPolicy.inCluster.kubernetes.podSelector "namespaceSelector" .Values.s3.networkPolicy.inCluster.kubernetes.namespaceSelector "port" $port) }} -{{- else if .Values.s3.networkPolicy.externalToCluster.enabled -}} - {{- $host := ( include "speckle.networkPolicy.domainFromUrl" .Values.s3.endpoint ) -}} - {{- $ip := "" -}} - {{- if eq (include "speckle.isIPv4" $host) "true" -}} - {{- $ip = $host -}} - {{- end -}} + {{- else if .Values.s3.networkPolicy.externalToCluster.enabled -}} + {{- $ip := ( include "speckle.networkPolicy.domainFromUrl" .Values.s3.endpoint ) -}} {{ include "speckle.networkpolicy.egress.external" (dict "ip" $ip "port" $port) }} -{{- end -}} + {{- end -}} {{- end }} {{/* Creates a Cilium Network Policy egress definition for connecting to S3 compatible storage */}} {{- define "speckle.networkpolicy.egress.blob_storage.cilium" -}} -{{- $port := (default "443" .Values.s3.networkPolicy.port ) -}} -{{- if .Values.s3.networkPolicy.inCluster.enabled -}} + {{- $port := (default "443" .Values.s3.networkPolicy.port ) -}} + {{- if .Values.s3.networkPolicy.inCluster.enabled -}} {{ include "speckle.networkpolicy.egress.internal.cilium" (dict "endpointSelector" .Values.s3.networkPolicy.inCluster.cilium.endpointSelector "serviceSelector" .Values.s3.networkPolicy.inCluster.cilium.serviceSelector "port" $port) }} -{{- else if .Values.s3.networkPolicy.externalToCluster.enabled -}} - {{- $host := ( include "speckle.networkPolicy.domainFromUrl" .Values.s3.endpoint ) -}} - {{- $ip := "" -}} - {{- $fqdn := "" -}} - {{- if eq (include "speckle.isIPv4" $host) "true" -}} - {{- $ip = $host -}} - {{- else -}} - {{- $fqdn = $host -}} + {{- else if .Values.s3.networkPolicy.externalToCluster.enabled -}} + {{- $host := ( include "speckle.networkPolicy.domainFromUrl" .Values.s3.endpoint ) -}} +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" $host "port" $port) }} {{- end -}} -{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" $ip "fqdn" $fqdn "port" $port) }} -{{- end -}} {{- end }} {{/* -Extracts the domain name from a url -*/}} -{{- define "speckle.networkPolicy.domainFromUrl" -}} -{{- $host := ( urlParse . ).host -}} -{{- if (contains ":" $host) -}} - {{- $host = first (mustRegexSplit ":" $host) -}} -{{- end -}} -{{ printf "%s" $host }} -{{- end }} - -{{/* -Creates a DNS match pattern for Cilium Network Policies. +Creates a DNS match pattern for discovering the postgres IP Usage: -{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster .Values.redis.networkPolicy.externalToCluster) }} +{{ include "speckle.networkpolicy.dns.postgres.cilium" $ }} Params: - - domain names - List of dictionaries containing `ipv4` and `host` string values - Required - If IP exists, domain is not added. Otherwise host is used to match domain excactly or match a pattern (domain with a glob). + - context - Required, global context should be provided. */}} -{{- define "speckle.networkpolicy.dns.cilium" -}} -{{- $catchAll := false -}} -{{- range . -}} - {{- if ( and .enabled ( not .ipv4 ) ) }} - {{- if .host -}} -{{ include "speckle.networkpolicy.matchNameOrPattern" .host }} - {{- else }} - # only add catch all match pattern if there is no ipv4 or host, and only add it one time. - {{- if not $catchAll }} -- matchPattern: "*" - {{- $catchAll = true }} - {{- end }} - {{- end }} +{{- define "speckle.networkpolicy.dns.postgres.cilium" -}} +{{- $secret := ( include "speckle.getSecret" (dict "secret_key" "postgres_url" "context" . ) ) -}} +{{- $domain := ( include "speckle.networkPolicy.domainFromUrl" $secret ) -}} + {{- if (and .Values.db.networkPolicy.externalToCluster.enabled ( ne ( include "speckle.isIPv4" $domain ) "true" ) ) -}} +{{ include "speckle.networkpolicy.matchNameOrPattern" $domain }} {{- end }} {{- end }} + +{{/* +Creates a DNS match pattern for discovering redis store IP + +Usage: +{{ include "speckle.networkpolicy.dns.redis.cilium" $ }} + +Params: + - context - Required, global context should be provided. +*/}} +{{- define "speckle.networkpolicy.dns.redis.cilium" -}} +{{- $secret := ( include "speckle.getSecret" (dict "secret_key" "redis_url" "context" . ) ) -}} +{{- $domain := ( include "speckle.networkPolicy.domainFromUrl" $secret ) -}} + {{- if (and .Values.redis.networkPolicy.externalToCluster.enabled ( ne ( include "speckle.isIPv4" $domain ) "true" ) ) -}} +{{ include "speckle.networkpolicy.matchNameOrPattern" $domain }} + {{- end }} {{- end }} {{/* @@ -237,7 +235,7 @@ Usage: {{ include "speckle.networkpolicy.egress.external" (dict "ip" "" "port" "6379") }} Params: - - ip - String - Optional - If the IP is not known, then egress is allowed to 0.0.0.0/0. + - ip - String - Optional - IP or Domain of the endpoint to allow egress to. Can provide either ip, fqdn or neither. If neither fqdn or ip is provided then egress is allowed to 0.0.0.0/0 (i.e. everywhere!) - port - String - Required Limitations: @@ -251,7 +249,7 @@ Limitations: {{- end -}} - to: - ipBlock: - {{- if .ip }} + {{- if ( eq ( include "speckle.isIPv4" .ip ) "true" ) }} cidr: {{ printf "%s/32" .ip }} {{- else }} # Kubernetes network policy does not support fqdn, so we have to allow egress anywhere @@ -268,11 +266,10 @@ Limitations: Creates a Cilium network policy egress definition for connecting to an external Layer 3/Layer 4 endpoint i.e. ip:port Usage: -{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" "" "fqdn" "myredis.example.org" "port" "6379") }} +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" "" "port" "6379") }} Params: - - ip - String - Optional - IP of the endpoint to allow egress to. Can provide either ip, fqdn or neither. If both IP or FQDN are provided, IP takes precedence. If neither fqdn or ip is provided then egress is allowed to 0.0.0.0/0 (i.e. everywhere!) - - fpdn - String - Optional - Domain name of the endpoint to allow egress to. Can include a pattern matching glob '*'. Can provide either ip, fqdn, or neither. If both IP or FQDN are provided, IP takes precedence. If neigher, then egress is allowed to 0.0.0.0/0 (i.e. everywhere!) + - ip - String - Optional - IP or Domain of the endpoint to allow egress to. Can provide either ip, fqdn or neither. If neither fqdn or ip is provided then egress is allowed to 0.0.0.0/0 (i.e. everywhere!) - port - String - Required Limitations: @@ -282,12 +279,12 @@ Limitations: {{- if not .port -}} {{- printf "\nNETWORKPOLICY ERROR: The port was not provided \"%s\"\n" .port | fail -}} {{- end -}} -{{- if .ip }} +{{- if ( eq ( include "speckle.isIPv4" .ip ) "true" ) }} - toCIDR: - {{ printf "%s/32" .ip }} -{{- else if .fqdn }} +{{- else if .ip }} - toFQDNs: -{{ include "speckle.networkpolicy.matchNameOrPattern" .fqdn | indent 4 }} +{{ include "speckle.networkpolicy.matchNameOrPattern" .ip | indent 4 }} {{- else }} - toCIDRSet: # Kubernetes network policy does not support fqdn, so we have to allow egress anywhere @@ -299,10 +296,13 @@ Limitations: toPorts: - ports: - port: {{ printf "%s" .port | quote }} - protcol: TCP + protocol: TCP {{- end }} {{- define "speckle.networkpolicy.matchNameOrPattern" -}} +{{- if not . -}} + {{- printf "\nNETWORKPOLICY ERROR: The name or glob pattern was not provided \"%s\"\n" . | fail -}} +{{- end -}} {{- if ( contains "*" . ) }} - matchPattern: {{ printf "%s" . }} {{- else }} @@ -374,7 +374,7 @@ Params: toPorts: - ports: - port: {{ printf "%s" .port | quote }} - protcol: TCP + protocol: TCP {{- end }} {{- end }} @@ -394,6 +394,32 @@ Params: {{- end -}} {{- end -}} +{{/* +Extracts the domain name from a url +*/}} +{{- define "speckle.networkPolicy.domainFromUrl" -}} + {{- if not . -}} + {{- printf "\nERROR: The url was not provided as the context \"%s\"\n" . | fail -}} + {{- end -}} + {{- $host := ( urlParse . ).host -}} + {{- if (contains ":" $host) -}} + {{- $host = first (mustRegexSplit ":" $host -1) -}} + {{- end -}} +{{ printf "%s" $host }} +{{- end }} + +{{/* +Extracts the port from a url +*/}} +{{- define "speckle.networkPolicy.portFromUrl" -}} + {{- if not . -}} + {{- printf "\nERROR: The url was not provided as the context \"%s\"\n" . | fail -}} + {{- end -}} + {{- $host := ( urlParse . ).host -}} + {{- if (contains ":" $host) -}} +{{ printf "%s" ( index (mustRegexSplit ":" $host -1) 1 ) }} + {{- end -}} +{{- end }} {{/* Renders a value that contains template. Usage: @@ -428,3 +454,20 @@ Ingress pod selector {{- define "speckle.ingress.selector.pod" -}} app.kubernetes.io/name: {{ .Values.ingress.controllerName }} {{- end }} + +{{/* +Retrieves an existing secret + +Usage: +{{ include "speckle.getSecret" (dict "secret_key" "postgres_url" "context" $ )}} + +Params: + - secret_key - Required, the key within the secret. + - context - Required, must be global context. Values of global context must include 'namespace' and 'secretName' keys. +*/}} +{{- define "speckle.getSecret" -}} +{{- $secretResource := (lookup "v1" "Secret" .context.Values.namespace .context.Values.secretName ) -}} +{{- $secret := ( index $secretResource.data .secret_key ) -}} +{{- $secretDecoded := (b64dec $secret) -}} +{{- printf "%s" $secretDecoded }} +{{- end }} diff --git a/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.cilium.yml index 03f0929b0..a56f8e596 100644 --- a/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.cilium.yml @@ -10,6 +10,7 @@ spec: endpointSelector: matchLabels: {{ include "fileimport_service.selectorLabels" . | indent 6 }} +{{- if .Values.enable_prometheus_monitoring }} ingress: - fromEndpoints: - matchLabels: @@ -18,6 +19,11 @@ spec: - ports: - port: "metrics" protocol: TCP +{{- else }} + ingressDeny: + - fromEntities: + - "all" +{{- end }} egress: - toEndpoints: - matchLabels: @@ -30,7 +36,7 @@ spec: rules: dns: - matchName: {{ include "server.service.fqdn" $ }} -{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster) | indent 14 }} +{{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }} # allow egress to speckle-server - toServices: - k8sServiceSelector: diff --git a/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.kubernetes.yml index 0a8f4ea2a..72cf2de9f 100644 --- a/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/fileimport_service/networkpolicy.kubernetes.yml @@ -13,6 +13,7 @@ spec: policyTypes: - Egress - Ingress +{{- if .Values.enable_prometheus_monitoring }} ingress: - from: - namespaceSelector: @@ -23,6 +24,10 @@ spec: {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: metrics +{{- else }} + # deny all ingress + ingress: [] +{{- end }} egress: # allow access to DNS - to: diff --git a/utils/helm/speckle-server/templates/monitoring/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/monitoring/networkpolicy.cilium.yml index 8d7d7e2e7..f3db42da2 100644 --- a/utils/helm/speckle-server/templates/monitoring/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/monitoring/networkpolicy.cilium.yml @@ -10,6 +10,7 @@ spec: endpointSelector: matchLabels: {{ include "monitoring.selectorLabels" . | indent 6 }} +{{- if .Values.enable_prometheus_monitoring }} ingress: - fromEndpoints: - matchLabels: @@ -18,6 +19,11 @@ spec: - ports: - port: "metrics" protocol: TCP +{{- else }} + ingressDeny: + - fromEntities: + - "all" +{{- end }} egress: - toEndpoints: - matchLabels: @@ -29,7 +35,7 @@ spec: protocol: UDP rules: dns: -{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster) | indent 14 }} +{{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }} # postgres {{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} {{- end }} diff --git a/utils/helm/speckle-server/templates/monitoring/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/monitoring/networkpolicy.kubernetes.yml index 143abaa91..aef973fc2 100644 --- a/utils/helm/speckle-server/templates/monitoring/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/monitoring/networkpolicy.kubernetes.yml @@ -13,6 +13,7 @@ spec: policyTypes: - Egress - Ingress +{{- if .Values.enable_prometheus_monitoring }} ingress: - from: - namespaceSelector: @@ -23,6 +24,10 @@ spec: {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: metrics +{{- else }} + # deny all ingress + ingress: [] +{{- end }} egress: # allow access to DNS - to: diff --git a/utils/helm/speckle-server/templates/preview_service/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/preview_service/networkpolicy.cilium.yml index 30c083e60..f09991e68 100644 --- a/utils/helm/speckle-server/templates/preview_service/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/preview_service/networkpolicy.cilium.yml @@ -10,6 +10,7 @@ spec: endpointSelector: matchLabels: {{ include "preview_service.selectorLabels" . | indent 6 }} +{{- if .Values.enable_prometheus_monitoring }} ingress: - fromEndpoints: - matchLabels: @@ -18,6 +19,11 @@ spec: - ports: - port: "metrics" protocol: TCP +{{- else }} + ingressDeny: + - fromEntities: + - "all" +{{- end }} egress: - toEndpoints: - matchLabels: @@ -29,7 +35,7 @@ spec: protocol: UDP rules: dns: -{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster) | indent 14 }} +{{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }} # postgres {{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} {{- end }} diff --git a/utils/helm/speckle-server/templates/preview_service/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/preview_service/networkpolicy.kubernetes.yml index 0ddeadc15..513f41557 100644 --- a/utils/helm/speckle-server/templates/preview_service/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/preview_service/networkpolicy.kubernetes.yml @@ -13,6 +13,7 @@ spec: policyTypes: - Egress - Ingress +{{- if .Values.enable_prometheus_monitoring }} ingress: - from: - namespaceSelector: @@ -23,6 +24,10 @@ spec: {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: metrics +{{- else }} + # deny all ingress + ingress: [] +{{- end }} egress: # allow access to DNS - to: diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml index ebe23eef1..7a6d4d258 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml @@ -19,6 +19,7 @@ spec: - ports: - port: http protocol: TCP +{{- if .Values.enable_prometheus_monitoring }} - fromEndpoints: - matchLabels: {{ include "speckle.prometheus.selectorLabels" $ | indent 12 }} @@ -26,6 +27,7 @@ spec: - ports: - port: http protocol: TCP +{{- end }} # ingress from file import service - fromEndpoints: - matchLabels: @@ -46,12 +48,13 @@ spec: rules: dns: # TODO: remove egress to domain once https://github.com/specklesystems/speckle-server/issues/959 is fixed - - matchPattern: {{ .Values.domain }} + - matchName: {{ .Values.domain }} {{- if .Values.server.sentry_dns }} # DNS lookup for sentry - matchPattern: "*.ingest.sentry.io" {{- end }} -{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster .Values.redis.networkPolicy.externalToCluster ) | indent 14 }} +{{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }} +{{ include "speckle.networkpolicy.dns.redis.cilium" $ | indent 14 }} {{ include "speckle.networkpolicy.dns.blob_storage.cilium" $ | indent 14 }} {{- if .Values.server.sentry_dns }} # egress to sentry diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml index d5ee2601b..d372a410f 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml @@ -48,6 +48,7 @@ spec: app.kubernetes.io/name: {{ .Values.ingress.controllerName }} ports: - port: http +{{- if .Values.enable_prometheus_monitoring }} # allow ingress from servicemonitor/prometheus - from: - namespaceSelector: @@ -58,6 +59,7 @@ spec: {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: http +{{- end }} # allow ingress from the fileimport service - from: - podSelector: diff --git a/utils/helm/speckle-server/templates/tests/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/tests/networkpolicy.cilium.yml index 1d601919d..a17ddf3fe 100644 --- a/utils/helm/speckle-server/templates/tests/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/tests/networkpolicy.cilium.yml @@ -11,8 +11,8 @@ spec: matchLabels: {{ include "test.selectorLabels" . | indent 6 }} ingressDeny: - - fromEntities: - - "all" + - fromEntities: + - "all" egress: - toEndpoints: - matchLabels: diff --git a/utils/helm/speckle-server/templates/webhook_service/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/webhook_service/networkpolicy.cilium.yml index 964197c26..9f78b2a15 100644 --- a/utils/helm/speckle-server/templates/webhook_service/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/webhook_service/networkpolicy.cilium.yml @@ -10,6 +10,7 @@ spec: endpointSelector: matchLabels: {{ include "webhook_service.selectorLabels" . | indent 6 }} +{{- if .Values.enable_prometheus_monitoring }} ingress: - fromEndpoints: - matchLabels: @@ -18,6 +19,11 @@ spec: - ports: - port: "metrics" protocol: TCP +{{- else }} + ingressDeny: + - fromEntities: + - "all" +{{- end }} egress: - toEndpoints: - matchLabels: @@ -31,7 +37,7 @@ spec: dns: # allow dns discoverability for all entities - matchPattern: "*" -{{ include "speckle.networkpolicy.dns.cilium" (list .Values.db.networkPolicy.externalToCluster) | indent 14 }} +{{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }} # postgres {{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} # allow access to all entities outside of the cluster diff --git a/utils/helm/speckle-server/templates/webhook_service/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/webhook_service/networkpolicy.kubernetes.yml index 724431aa7..d2ecdf590 100644 --- a/utils/helm/speckle-server/templates/webhook_service/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/webhook_service/networkpolicy.kubernetes.yml @@ -13,6 +13,7 @@ spec: policyTypes: - Egress - Ingress +{{- if .Values.enable_prometheus_monitoring }} ingress: - from: - namespaceSelector: @@ -23,6 +24,10 @@ spec: {{ include "speckle.prometheus.selectorLabels.release" $ | indent 14 }} ports: - port: metrics +{{- else }} + # deny all ingress + ingress: [] +{{- end }} egress: # webhook can call anything external, but is blocked from egress elsewhere within the cluster - to: diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index 8bf70c6f2..1246ced74 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -118,11 +118,6 @@ "networkPolicy": { "type": "object", "properties": { - "port": { - "type": "string", - "description": "the port on the server providing the Postgres database (default: \"5432\")", - "default": "" - }, "externalToCluster": { "type": "object", "properties": { @@ -130,16 +125,6 @@ "type": "boolean", "description": "If enabled, indicates that the Postgres database is hosted externally to the Kubernetes cluster", "default": true - }, - "host": { - "type": "string", - "description": "The domain name at which the Postgres database is hosted.", - "default": "" - }, - "ipv4": { - "type": "string", - "description": "The IP address at which the Postgres database is hosted", - "default": "" } } }, @@ -151,6 +136,11 @@ "description": "If enabled, indicates that the Postgres database is hosted withing the same Kubernetes cluster in which Speckle will be deployed", "default": false }, + "port": { + "type": "string", + "description": "the port on the server providing the Postgres database (default: \"5432\")", + "default": "" + }, "kubernetes": { "type": "object", "properties": { @@ -230,16 +220,6 @@ "type": "boolean", "description": "If enabled, indicates that the s3 compatible storage is hosted externally to the Kubernetes cluster", "default": true - }, - "host": { - "type": "string", - "description": "The domain name at which the s3 compatible storage is hosted.", - "default": "" - }, - "ipv4": { - "type": "string", - "description": "The IP address at which the s3 compatible storage is hosted", - "default": "" } } }, @@ -293,11 +273,6 @@ "networkPolicy": { "type": "object", "properties": { - "port": { - "type": "string", - "description": "the port on the server providing the Redis store (default: \"6379\")", - "default": "" - }, "externalToCluster": { "type": "object", "properties": { @@ -305,16 +280,6 @@ "type": "boolean", "description": "If enabled, indicates that the Redis store is hosted externally to the Kubernetes cluster", "default": true - }, - "host": { - "type": "string", - "description": "The domain name at which the Redis store is hosted.", - "default": "" - }, - "ipv4": { - "type": "string", - "description": "The IP address at which the Redis store is hosted", - "default": "" } } }, @@ -326,6 +291,11 @@ "description": "If enabled, indicates that the Redis store is hosted withing the same Kubernetes cluster in which Speckle will be deployed", "default": false }, + "port": { + "type": "string", + "description": "the port on the server providing the Redis store (default: \"6379\")", + "default": "" + }, "kubernetes": { "type": "object", "properties": { diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index e21b82e62..751263b60 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -132,9 +132,6 @@ db: ## @extra db.networkPolicy If networkPolicy is enabled for any service, this provides the NetworkPolicy with the necessary details to allow egress connections to the Postgres database ## networkPolicy: - ## @param db.networkPolicy.port the port on the server providing the Postgres database (default: "5432") - ## - port: '' ## @extra db.networkPolicy.externalToCluster Only required if the Postgres database is not hosted within the Kubernetes cluster in which Speckle will be deployed. ## externalToCluster: @@ -142,16 +139,6 @@ db: ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only one deployed ## enabled: true - ## @param db.networkPolicy.externalToCluster.host The domain name at which the Postgres database is hosted. - ## This should match the value provided within the connection string. - ## Provide the IP address if available (use the `ipv4` parameter), as the IP address takes precedence. - ## - host: '' - ## @param db.networkPolicy.externalToCluster.ipv4 The IP address at which the Postgres database is hosted - ## This should be an IP address not within the Kubernetes Cluster Pod or Service IP ranges. - ## If both host and ipv4 parameters are provided, ipv4 takes precedence and host is ignored. - ## - ipv4: '' ## @extra db.networkPolicy.inCluster Only required if the Postgres database is hosted within the Kubernetes cluster in which Speckle will be deployed. ## inCluster: @@ -159,6 +146,9 @@ db: ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only set of egress network policy rules deployed. ## enabled: false + ## @param db.networkPolicy.inCluster.port the port on the server providing the Postgres database (default: "5432") + ## + port: '' kubernetes: ## @param db.networkPolicy.inCluster.kubernetes.podSelector (Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the postgres compatible database pods within the cluster and given namespace ## For Kubernetes Network Policies this is a podSelector object. @@ -228,16 +218,6 @@ s3: ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only one deployed ## enabled: true - ## @param s3.networkPolicy.externalToCluster.host The domain name at which the s3 compatible storage is hosted. - ## This should match the value provided within the connection string. - ## Provide the IP address if available (use the `ipv4` parameter), as the IP address takes precedence. - ## - host: '' - ## @param s3.networkPolicy.externalToCluster.ipv4 The IP address at which the s3 compatible storage is hosted - ## This should be an IP address not within the Kubernetes Cluster Pod or Service IP ranges. - ## If both host and ipv4 parameters are provided, ipv4 takes precedence and host is ignored. - ## - ipv4: '' ## @extra s3.networkPolicy.inCluster Only required if the s3 compatible storage is hosted within the Kubernetes cluster in which Speckle will be deployed. ## inCluster: @@ -284,9 +264,6 @@ redis: ## @extra redis.networkPolicy If networkPolicy is enabled for Speckle server, this provides the NetworkPolicy with the necessary details to allow egress connections to the Redis store ## networkPolicy: - ## @param redis.networkPolicy.port the port on the server providing the Redis store (default: "6379") - ## - port: '' ## @extra redis.networkPolicy.externalToCluster Only required if the Redis store is not hosted within the Kubernetes cluster in which Speckle will be deployed. ## externalToCluster: @@ -294,16 +271,6 @@ redis: ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only one deployed ## enabled: true - ## @param redis.networkPolicy.externalToCluster.host The domain name at which the Redis store is hosted. - ## This should match the value provided within the connection string. - ## Provide the IP address if available (use the `ipv4` parameter), as the IP address takes precedence. - ## - host: '' - ## @param redis.networkPolicy.externalToCluster.ipv4 The IP address at which the Redis store is hosted - ## This should be an IP address not within the Kubernetes Cluster Pod or Service IP ranges. - ## If both host and ipv4 parameters are provided, ipv4 takes precedence and host is ignored. - ## - ipv4: '' ## @extra redis.networkPolicy.inCluster is only required if the Redis store is hosted within the Kubernetes cluster in which Speckle will be deployed. ## inCluster: @@ -311,6 +278,9 @@ redis: ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only set of egress network policy rules deployed. ## enabled: false + ## @param redis.networkPolicy.inCluster.port the port on the server providing the Redis store (default: "6379") + ## + port: '' kubernetes: ## @param redis.networkPolicy.inCluster.kubernetes.podSelector (Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the redis store pods within the cluster and given namespace ## For Kubernetes Network Policies this is a podSelector object. From ab0c60ec57c83b0d2bf10fe3803012d65d2b0e8a Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 25 Aug 2022 15:08:25 +0100 Subject: [PATCH 20/28] Helm Chart: Network Policies allow server egress to apollo (#965) * fix(helm chart): allow egress in server Network Policies to Apollo The Cilium and Kubernetes network policies currently do not allow egress from the server to Apollo for graphql monitoring. Kubernetes Network Policies don't allow domain names. We have an open support ticket with Apollo Studio to request which CIDR to limit egress to. Until then, we will need to open egress to everywhere if a Kubernetes Network Policy is used. --- .../templates/frontend/networkpolicy.cilium.yml | 2 +- .../templates/server/networkpolicy.cilium.yml | 14 +++++++++++++- .../templates/server/networkpolicy.kubernetes.yml | 10 ++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/utils/helm/speckle-server/templates/frontend/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/frontend/networkpolicy.cilium.yml index 6051dc31c..f6ec58cfc 100644 --- a/utils/helm/speckle-server/templates/frontend/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/frontend/networkpolicy.cilium.yml @@ -14,7 +14,7 @@ spec: - fromEndpoints: - matchLabels: io.kubernetes.pod.namespace: {{ .Values.ingress.namespace }} -{{ include "speckle.ingress.selector.pod" $ | indent 12 }} +{{ include "speckle.ingress.selector.pod" $ | indent 12 }} toPorts: - ports: - port: "www" diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml index 7a6d4d258..96e6eca73 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml @@ -49,6 +49,9 @@ spec: dns: # TODO: remove egress to domain once https://github.com/specklesystems/speckle-server/issues/959 is fixed - matchName: {{ .Values.domain }} +{{- if .Values.server.monitoring.apollo.enabled }} + - matchPattern: "*.api.apollographql.com" +{{- end }} {{- if .Values.server.sentry_dns }} # DNS lookup for sentry - matchPattern: "*.ingest.sentry.io" @@ -56,6 +59,14 @@ spec: {{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }} {{ include "speckle.networkpolicy.dns.redis.cilium" $ | indent 14 }} {{ include "speckle.networkpolicy.dns.blob_storage.cilium" $ | indent 14 }} +{{- if .Values.server.monitoring.apollo.enabled }} + - toFQDNs: + - matchPattern: "*.api.apollographql.com" + toPorts: + - ports: + - port: "443" + protocol: TCP +{{- end }} {{- if .Values.server.sentry_dns }} # egress to sentry - toCIDRSet: @@ -63,6 +74,7 @@ spec: toPorts: - ports: - port: "443" + protocol: TCP {{- end }} # postgres {{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} @@ -73,7 +85,7 @@ spec: # allow egress to the ingress for speckle-server, so it can call itself # TODO: remove egress to domain once https://github.com/specklesystems/speckle-server/issues/959 is fixed - toFQDNs: - - matchPattern: {{ .Values.domain }} + - matchName: {{ .Values.domain }} toPorts: - ports: - port: "443" diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml index d372a410f..3c0107c09 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml @@ -23,6 +23,16 @@ spec: ports: - port: 53 protocol: UDP +{{- if .Values.server.monitoring.apollo.enabled }} + - to: + - ipBlock: + cidr: 0.0.0.0/0 + # except to kubernetes pods or services + except: + - 10.0.0.0/8 + ports: + - port: 443 +{{- end }} {{- if .Values.server.sentry_dns }} # sentry.io https://docs.sentry.io/product/security/ip-ranges/#event-ingestion - to: From 5aa00784a6180a8614cc1a3d37e4022fb02836ab Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 25 Aug 2022 16:00:34 +0100 Subject: [PATCH 21/28] fix(helm chart): allow egress from server to email server (#966) Network Policies omitted to allow egress to email. This commit allows egress to email. --- .../speckle-server/templates/_helpers.tpl | 45 +++++++++++++++ .../templates/server/networkpolicy.cilium.yml | 8 +++ .../server/networkpolicy.kubernetes.yml | 4 ++ utils/helm/speckle-server/values.schema.json | 55 +++++++++++++++++++ utils/helm/speckle-server/values.yaml | 43 +++++++++++++++ 5 files changed, 155 insertions(+) diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 16eab70e5..376455887 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -184,6 +184,35 @@ Creates a Cilium Network Policy egress definition for connecting to S3 compatibl {{- end -}} {{- end }} +{{/* +Creates a Kubernetes Network Policy egress definition for connecting to the email server + +Params: + - context - Required, global context should be provided +*/}} +{{- define "speckle.networkpolicy.egress.email" -}} + {{- $port := (default "443" .Values.server.email.port ) -}} + {{- if .Values.server.email.networkPolicy.inCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.internal" (dict "podSelector" .Values.server.email.networkPolicy.inCluster.kubernetes.podSelector "namespaceSelector" .Values.server.email.networkPolicy.inCluster.kubernetes.namespaceSelector "port" $port) }} + {{- else if .Values.server.email.networkPolicy.externalToCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.external" (dict "ip" .Values.server.email.host "port" $port) }} + {{- end -}} +{{- end }} + +{{/* +Creates a Cilium Network Policy egress definition for connecting to an email server + +Expects the global context "$" to be passed as the parameter +*/}} +{{- define "speckle.networkpolicy.egress.email.cilium" -}} + {{- $port := (default "443" .Values.server.email.port ) -}} + {{- if .Values.server.email.networkPolicy.inCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.internal.cilium" (dict "endpointSelector" .Values.server.email.networkPolicy.inCluster.cilium.endpointSelector "serviceSelector" .Values.server.email.networkPolicy.inCluster.cilium.serviceSelector "port" $port) }} + {{- else if .Values.server.email.networkPolicy.externalToCluster.enabled -}} +{{ include "speckle.networkpolicy.egress.external.cilium" (dict "ip" .Values.server.email.host "port" $port) }} + {{- end -}} +{{- end }} + {{/* Creates a DNS match pattern for discovering the postgres IP @@ -228,6 +257,22 @@ Creates a DNS match pattern for discovering blob storage IP {{- end }} {{- end }} +{{/* +Creates a DNS match pattern for discovering email server IP + +Usage: +{{ include "speckle.networkpolicy.dns.email.cilium" $ }} + +Params: + - context - Required, global context should be provided. +*/}} +{{- define "speckle.networkpolicy.dns.email.cilium" -}} +{{- $domain := .Values.server.email.host -}} + {{- if (and .Values.server.email.networkPolicy.externalToCluster.enabled ( ne ( include "speckle.isIPv4" $domain ) "true" ) ) -}} +{{ include "speckle.networkpolicy.matchNameOrPattern" $domain }} + {{- end }} +{{- end }} + {{/* Creates a network policy egress definition for connecting to an external url:port or ip:port diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml index 96e6eca73..c004613d0 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml @@ -56,6 +56,10 @@ spec: # DNS lookup for sentry - matchPattern: "*.ingest.sentry.io" {{- end }} +{{- if .Values.server.email.enabled }} + # email server +{{ include "speckle.networkpolicy.dns.email.cilium" $ | indent 14 }} +{{- end }} {{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }} {{ include "speckle.networkpolicy.dns.redis.cilium" $ | indent 14 }} {{ include "speckle.networkpolicy.dns.blob_storage.cilium" $ | indent 14 }} @@ -75,6 +79,10 @@ spec: - ports: - port: "443" protocol: TCP +{{- end }} +{{- if .Values.server.email.enabled }} + # email server +{{ include "speckle.networkpolicy.egress.email.cilium" $ | indent 4 }} {{- end }} # postgres {{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml index 3c0107c09..7cbb1dabe 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml @@ -40,6 +40,10 @@ spec: cidr: 34.120.195.249/32 ports: - port: 443 +{{- end }} +{{- if .Values.server.email.enabled }} + # email server +{{ include "speckle.networkpolicy.egress.email" $ | indent 4 }} {{- end }} # redis {{ include "speckle.networkpolicy.egress.redis" $ | indent 4 }} diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index 1246ced74..b748793c5 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -437,6 +437,61 @@ "type": "string", "description": "The username with which Speckle will authenticate with the email service.", "default": "" + }, + "networkPolicy": { + "type": "object", + "properties": { + "externalToCluster": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "If enabled, indicates that the email server is hosted externally to the Kubernetes cluster", + "default": true + } + } + }, + "inCluster": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "If enabled, indicates that the email server is hosted withing the same Kubernetes cluster in which Speckle will be deployed", + "default": false + }, + "kubernetes": { + "type": "object", + "properties": { + "podSelector": { + "type": "object", + "description": "(Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the email server pods within the cluster and given namespace", + "default": {} + }, + "namespaceSelector": { + "type": "object", + "description": "(Kubernetes Network Policy only) The namespace selector yaml object used to uniquely select the namespace in which the email server pods are deployed", + "default": {} + } + } + }, + "cilium": { + "type": "object", + "properties": { + "endpointSelector": { + "type": "object", + "description": "(Cilium Network Policy only) The endpoint selector yaml object used to uniquely select the in-cluster endpoint in which the email server pods are deployed", + "default": {} + }, + "serviceSelector": { + "type": "object", + "description": "(Cilium Network Policy only) The service selector yaml object used to uniquely select the in-cluster service providing the email server", + "default": {} + } + } + } + } + } + } } } }, diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 751263b60..1a2c99002 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -388,6 +388,49 @@ server: ## Note that the `email_password` is expected to be provided in the Kubernetes Secret with the name provided in the `secretName` parameter. ## username: '' + ## @extra server.email.networkPolicy If networkPolicy is enabled for Speckle server, this provides the Network Policy with the necessary details to allow egress connections to the email server + ## + networkPolicy: + ## @extra server.email.networkPolicy.externalToCluster Only required if the Redis store is not hosted within the Kubernetes cluster in which Speckle will be deployed. + ## + externalToCluster: + ## @param server.email.networkPolicy.externalToCluster.enabled If enabled, indicates that the email server is hosted externally to the Kubernetes cluster + ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only one deployed + ## + enabled: true + ## @extra server.email.networkPolicy.inCluster is only required if the email server is hosted within the Kubernetes cluster in which Speckle will be deployed. + ## + inCluster: + ## @param server.email.networkPolicy.inCluster.enabled If enabled, indicates that the email server is hosted withing the same Kubernetes cluster in which Speckle will be deployed + ## Only one of externalToCluster or inCluster should be enabled. If both are enabled then inCluster takes precedence and is the only set of egress network policy rules deployed. + ## + enabled: false + kubernetes: + ## @param server.email.networkPolicy.inCluster.kubernetes.podSelector (Kubernetes Network Policy only) The pod Selector yaml object used to uniquely select the email server pods within the cluster and given namespace + ## For Kubernetes Network Policies this is a podSelector object. + ## For Cilium Network Policies this is ignored. + ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors + ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + ## + podSelector: {} + ## @param server.email.networkPolicy.inCluster.kubernetes.namespaceSelector (Kubernetes Network Policy only) The namespace selector yaml object used to uniquely select the namespace in which the email server pods are deployed + ## This is a Kubernetes namespaceSelector object. + ## For Cilium Network Policies, this is ignored + ## ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/#behavior-of-to-and-from-selectors + ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + ## + namespaceSelector: {} + cilium: + ## @param server.email.networkPolicy.inCluster.cilium.endpointSelector (Cilium Network Policy only) The endpoint selector yaml object used to uniquely select the in-cluster endpoint in which the email server pods are deployed + ## For Kubernetes Network Policies, this is ignored. + ## ref: https://docs.cilium.io/en/v1.9/policy/language/#egress + ## ref: https://github.com/cilium/cilium/blob/master/pkg/policy/api/selector.go + endpointSelector: {} + ## @param server.email.networkPolicy.inCluster.cilium.serviceSelector (Cilium Network Policy only) The service selector yaml object used to uniquely select the in-cluster service providing the email server + ## For Kubernetes Network Policies this is ignored. + ## ref: https://docs.cilium.io/en/v1.9/policy/language/#egress + ## ref: https://github.com/cilium/cilium/blob/master/pkg/policy/api/service.go + serviceSelector: {} requests: ## @param server.requests.cpu The CPU that should be available on a node when scheduling this pod. ## ref: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ From 278da16e537f4fce3ad8a5f24a8d0a00c872a295 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Fri, 26 Aug 2022 17:05:32 +0100 Subject: [PATCH 22/28] fix(helm chart server kubernetes network policy): update CIDR for Apollo (#968) Apollo responded to our support question, they confirmed that 34.120.83.176/32 is sufficient for egress to usage-reporting.api.apollographql.com --- .../templates/server/networkpolicy.kubernetes.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml index 7cbb1dabe..3b539b098 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml @@ -26,10 +26,7 @@ spec: {{- if .Values.server.monitoring.apollo.enabled }} - to: - ipBlock: - cidr: 0.0.0.0/0 - # except to kubernetes pods or services - except: - - 10.0.0.0/8 + cidr: 34.120.83.176/32 ports: - port: 443 {{- end }} From 3150763ddc929deebe04cd03e3200f5f88e89434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= <57442769+gjedlicska@users.noreply.github.com> Date: Sat, 27 Aug 2022 11:28:38 +0200 Subject: [PATCH 23/28] fix(server): put emails behind elevated privileges (#971) --- packages/server/modules/core/graph/resolvers/users.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/server/modules/core/graph/resolvers/users.js b/packages/server/modules/core/graph/resolvers/users.js index 7278e7022..fadc2244a 100644 --- a/packages/server/modules/core/graph/resolvers/users.js +++ b/packages/server/modules/core/graph/resolvers/users.js @@ -18,6 +18,7 @@ const zxcvbn = require('zxcvbn') const { getAdminUsersListCollection } = require('@/modules/core/services/users/adminUsersListService') +const { Roles, Scopes } = require('@/modules/core/helpers/mainConstants') module.exports = { Query: { @@ -86,7 +87,9 @@ module.exports = { } try { - await validateScopes(context.scopes, 'users:email') + // you should only have access to other users email if you have elevated privileges + await validateServerRole(context, Roles.Server.Admin) + await validateScopes(context.scopes, Scopes.Users.Email) return parent.email } catch (err) { return null From 23bc801eb0c0113b7bc1e6122ef12ba8cd49cd0d Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Mon, 29 Aug 2022 12:10:37 +0100 Subject: [PATCH 24/28] fix(helm chart): allow egress to auth providers (#970) * fix(helm chart): allow egress to auth providers * Increase Azure AD allowlist to match https://docs.microsoft.com/en-us/azure/azure-portal/azure-portal-safelist-urls?tabs=public-cloud * Allows customisation of azure AD domains --- .../templates/server/networkpolicy.cilium.yml | 68 +++++++++++++++++++ .../server/networkpolicy.kubernetes.yml | 10 +++ utils/helm/speckle-server/values.yaml | 6 ++ 3 files changed, 84 insertions(+) diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml index c004613d0..5e1dea390 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml @@ -60,6 +60,33 @@ spec: # email server {{ include "speckle.networkpolicy.dns.email.cilium" $ | indent 14 }} {{- end }} +{{- if .Values.server.auth.google.enabled }} + # google auth + - matchName: 'accounts.google.com' + - matchName: 'www.googleapis.com' +{{- end }} +{{- if .Values.server.auth.github.enabled }} + # github auth + - matchName: 'github.com' + - matchName: 'api.github.com' +{{- end }} +{{- if .Values.server.auth.azure_ad.enabled }} + # azure ad auth + - matchPattern: '*.login.microsoftonline.com' + - matchPattern: '*.aadcdn.msftauth.net' + - matchPattern: '*.logincdn.msftauth.net' + - matchPattern: '*.login.live.com' + - matchPattern: '*.msauth.net' + - matchPattern: '*.aadcdn.microsoftonline-p.com' + - matchPattern: '*.microsoftonline-p.com' + - matchPattern: '*.account.microsoft.com' + - matchPattern: '*.bmx.azure.com' + - matchPattern: '*.subscriptionrp.trafficmanager.net' + - matchPattern: '*.signup.azure.com' + - matchName: 'login.microsoftonline.com' + - matchName: 'login.windows.net' +{{ include "speckle.renderTpl" (dict "value" .Values.server.auth.azure_ad.networkPolicy.domains "context" $ ) | indent 14 }} +{{- end }} {{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }} {{ include "speckle.networkpolicy.dns.redis.cilium" $ | indent 14 }} {{ include "speckle.networkpolicy.dns.blob_storage.cilium" $ | indent 14 }} @@ -83,6 +110,47 @@ spec: {{- if .Values.server.email.enabled }} # email server {{ include "speckle.networkpolicy.egress.email.cilium" $ | indent 4 }} +{{- end }} +{{- if .Values.server.auth.google.enabled }} + # google auth + - toFQDNs: + - matchName: 'accounts.google.com' + - matchName: 'www.googleapis.com' + toPorts: + - ports: + - port: '443' + protocol: TCP +{{- end }} +{{- if .Values.server.auth.github.enabled }} + # github auth + - toFQDNs: + - matchName: 'github.com' + - matchName: 'api.github.com' + toPorts: + - ports: + - port: '443' + protocol: TCP +{{- end }} +{{- if .Values.server.auth.azure_ad.enabled }} + # azure ad auth + - toFQDNs: + - matchPattern: '*.login.microsoftonline.com' + - matchPattern: '*.aadcdn.msftauth.net' + - matchPattern: '*.logincdn.msftauth.net' + - matchPattern: '*.login.live.com' + - matchPattern: '*.msauth.net' + - matchPattern: '*.aadcdn.microsoftonline-p.com' + - matchPattern: '*.microsoftonline-p.com' + - matchPattern: '*.account.microsoft.com' + - matchPattern: '*.bmx.azure.com' + - matchPattern: '*.subscriptionrp.trafficmanager.net' + - matchPattern: '*.signup.azure.com' + - matchName: 'login.microsoftonline.com' + - matchName: 'login.windows.net' +{{ include "speckle.renderTpl" (dict "value" .Values.server.auth.azure_ad.additional_domains "context" $ ) | indent 8 }} + toPorts: + - port: {{ default 443 .Values.server.auth.azure_ad.port | quote }} + protocol: TCP {{- end }} # postgres {{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }} diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml index 3b539b098..6a7782b61 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml @@ -30,6 +30,16 @@ spec: ports: - port: 443 {{- end }} +{{- if ( or .Values.server.auth.google.enabled .Values.server.auth.github.enabled .Values.server.auth.azure_ad.enabled ) }} + - to: + - ipBlock: + cidr: 0.0.0.0/0 + # except to kubernetes pods or services + except: + - 10.0.0.0/8 + ports: + - port: 443 +{{- end }} {{- if .Values.server.sentry_dns }} # sentry.io https://docs.sentry.io/product/security/ip-ranges/#event-ingestion - to: diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 1a2c99002..14ca85382 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -369,6 +369,12 @@ server: ## @param server.auth.azure_ad.client_id This is the ID for Speckle that you have registered with Azure ## client_id: '' + ## @param server.auth.azure_ad.additional_domains List of `matchName` or `matchPattern` maps for domains that should be allow-listed for egress in Network Policy. https://docs.microsoft.com/en-us/azure/azure-portal/azure-portal-safelist-urls?tabs=public-cloud are enabled by default. + ## + additional_domains: [] + ## @param server.auth.azure_ad.port Port on server to connect to. Used to allow egress in Network Policy. Defaults to 443 + ## + port: 443 ## @extra server.email Speckle can communicate with users via email, providing account verification and notification. ## email: From ecb9f825bb2a185358a6b3b23216e0d3953b05d4 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Wed, 31 Aug 2022 17:11:19 +0300 Subject: [PATCH 25/28] refactor: email verification flow & email template (#967) --- .../frontend/src/graphql/generated/graphql.ts | 47 +++- packages/frontend/src/graphql/user.js | 18 ++ .../main/components/common/GlobalToast.vue | 43 +-- .../user/EmailVerificationBanner.vue | 107 +++++--- .../frontend/src/main/layouts/TheMain.vue | 54 ++-- .../lib/core/composables/notifications.ts | 110 ++++++++ packages/frontend/src/plugins/helpers.ts | 3 +- packages/server/.eslintrc.js | 15 +- .../server/assets/core/typedefs/user.graphql | 1 - .../assets/emails/typedefs/emails.graphql | 13 + .../server/modules/activitystream/index.ts | 17 ++ .../activitystream/services/eventListener.ts | 24 ++ .../server/modules/comments/events/emitter.ts | 63 +---- .../server/modules/comments/services/index.js | 15 +- .../comments/services/notifications.ts | 13 +- .../modules/comments/tests/comments.spec.js | 23 +- packages/server/modules/core/dbSchema.ts | 15 +- .../modules/core/events/usersEmitter.ts | 15 ++ .../modules/core/graph/directives/isOwner.ts | 45 ++++ .../modules/core/graph/generated/graphql.ts | 60 ++++- .../modules/core/graph/schema/baseTypeDefs.js | 1 + .../modules/core/helpers/routeHelper.ts | 8 + .../server/modules/core/repositories/users.ts | 14 + .../server/modules/core/services/users.js | 29 +- .../server/modules/emails/errors/index.ts | 11 + .../modules/emails/graph/resolvers/index.ts | 22 ++ packages/server/modules/emails/index.ts | 16 +- ...82631_drop_email_verifications_used_col.ts | 23 ++ .../modules/emails/repositories/index.ts | 61 +++++ packages/server/modules/emails/rest/index.js | 63 +---- .../modules/emails/services/verification.js | 100 ------- .../emails/services/verification/finalize.ts | 33 +++ .../emails/services/verification/request.ts | 102 ++++++++ .../emails/tests/verifications.spec.js | 157 ----------- .../emails/tests/verifications.spec.ts | 247 ++++++++++++++++++ packages/server/modules/index.js | 3 +- .../notifications/tests/notifications.spec.ts | 15 +- .../modules/pwdreset/services/request.ts | 3 +- .../serverinvites/tests/invites.spec.js | 84 +++--- .../services/moduleEventEmitterSetup.ts | 81 ++++++ packages/server/package.json | 4 + packages/server/test/authHelper.ts | 16 +- .../server/test/graphql/generated/graphql.ts | 45 +++- packages/server/test/graphql/users.ts | 42 ++- packages/server/test/hooks.js | 15 +- packages/server/test/mockHelper.js | 103 -------- packages/server/test/mockHelper.ts | 222 ++++++++++++++++ packages/server/test/mocks/global.ts | 10 + packages/server/test/plugins/graphql.ts | 47 ++++ packages/server/type-augmentations/chai.d.ts | 13 + yarn.lock | 46 ++++ 51 files changed, 1636 insertions(+), 701 deletions(-) create mode 100644 packages/frontend/src/main/lib/core/composables/notifications.ts create mode 100644 packages/server/assets/emails/typedefs/emails.graphql create mode 100644 packages/server/modules/activitystream/index.ts create mode 100644 packages/server/modules/activitystream/services/eventListener.ts create mode 100644 packages/server/modules/core/events/usersEmitter.ts create mode 100644 packages/server/modules/core/graph/directives/isOwner.ts create mode 100644 packages/server/modules/emails/errors/index.ts create mode 100644 packages/server/modules/emails/graph/resolvers/index.ts create mode 100644 packages/server/modules/emails/migrations/20220825082631_drop_email_verifications_used_col.ts create mode 100644 packages/server/modules/emails/repositories/index.ts delete mode 100644 packages/server/modules/emails/services/verification.js create mode 100644 packages/server/modules/emails/services/verification/finalize.ts create mode 100644 packages/server/modules/emails/services/verification/request.ts delete mode 100644 packages/server/modules/emails/tests/verifications.spec.js create mode 100644 packages/server/modules/emails/tests/verifications.spec.ts create mode 100644 packages/server/modules/shared/services/moduleEventEmitterSetup.ts delete mode 100644 packages/server/test/mockHelper.js create mode 100644 packages/server/test/mockHelper.ts create mode 100644 packages/server/test/mocks/global.ts create mode 100644 packages/server/test/plugins/graphql.ts create mode 100644 packages/server/type-augmentations/chai.d.ts diff --git a/packages/frontend/src/graphql/generated/graphql.ts b/packages/frontend/src/graphql/generated/graphql.ts index 01f485660..4e35bf252 100644 --- a/packages/frontend/src/graphql/generated/graphql.ts +++ b/packages/frontend/src/graphql/generated/graphql.ts @@ -477,6 +477,8 @@ export type Mutation = { /** Re-send a pending invite */ inviteResend: Scalars['Boolean']; objectCreate: Array>; + /** (Re-)send the account verification e-mail */ + requestVerification: Scalars['Boolean']; serverInfoUpdate?: Maybe; serverInviteBatchCreate: Scalars['Boolean']; /** Invite a new user to the speckle server and return the invite ID */ @@ -886,10 +888,7 @@ export type Query = { streamInvites: Array; /** All the streams of the current user, pass in the `query` parameter to search by name, description or ID. */ streams?: Maybe; - /** - * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). - * If ID is provided, admin access is required - */ + /** Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). */ user?: Maybe; /** Validate password strength */ userPwdStrength: PasswordStrengthCheckResults; @@ -1443,6 +1442,8 @@ export type User = { email?: Maybe; /** All the streams that a user has favorited */ favoriteStreams?: Maybe; + /** Whether the user has a pending/active email verification token */ + hasPendingVerification?: Maybe; id: Scalars['String']; name?: Maybe; profiles?: Maybe; @@ -1795,24 +1796,24 @@ export type StreamBranchFirstCommitQueryVariables = Exact<{ export type StreamBranchFirstCommitQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, branch?: { __typename?: 'Branch', commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, referencedObject: string } | null> | null } | null } | null } | null }; -export type CommonUserFieldsFragment = { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null }; +export type CommonUserFieldsFragment = { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null }; export type UserFavoriteStreamsQueryVariables = Exact<{ cursor?: InputMaybe; }>; -export type UserFavoriteStreamsQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, profiles?: Record | null, role?: string | null, favoriteStreams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: any, updatedAt: any, commentCount: number, favoritedDate?: any | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null } | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; +export type UserFavoriteStreamsQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, favoriteStreams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: any, updatedAt: any, commentCount: number, favoritedDate?: any | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null } | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; export type MainUserDataQueryVariables = Exact<{ [key: string]: never; }>; -export type MainUserDataQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; +export type MainUserDataQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; export type ExtraUserDataQueryVariables = Exact<{ [key: string]: never; }>; -export type ExtraUserDataQuery = { __typename?: 'Query', user?: { __typename?: 'User', totalOwnedStreamsFavorites: number, id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; +export type ExtraUserDataQuery = { __typename?: 'Query', user?: { __typename?: 'User', totalOwnedStreamsFavorites: number, id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; export type UserSearchQueryVariables = Exact<{ query: Scalars['String']; @@ -1852,6 +1853,16 @@ export type ValidatePasswordStrengthQueryVariables = Exact<{ export type ValidatePasswordStrengthQuery = { __typename?: 'Query', userPwdStrength: { __typename?: 'PasswordStrengthCheckResults', score: number, feedback: { __typename?: 'PasswordStrengthCheckFeedback', warning?: string | null, suggestions: Array } } }; +export type EmailVerificationBannerStateQueryVariables = Exact<{ [key: string]: never; }>; + + +export type EmailVerificationBannerStateQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null } | null }; + +export type RequestVerificationMutationVariables = Exact<{ [key: string]: never; }>; + + +export type RequestVerificationMutation = { __typename?: 'Mutation', requestVerification: boolean }; + export type UserQueryVariables = Exact<{ id: Scalars['String']; }>; @@ -2033,6 +2044,7 @@ export const CommonUserFields = gql` company avatar verified + hasPendingVerification profiles role streams { @@ -2457,6 +2469,21 @@ export const ValidatePasswordStrength = gql` } } `; +export const EmailVerificationBannerState = gql` + query EmailVerificationBannerState { + user { + id + email + verified + hasPendingVerification + } +} + `; +export const RequestVerification = gql` + mutation RequestVerification { + requestVerification +} + `; export const User = gql` query User($id: String!) { user(id: $id) { @@ -2545,7 +2572,7 @@ export const MainServerInfoFieldsFragmentDoc = {"kind":"Document","definitions": export const ServerInfoRolesFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerInfoRolesFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"roles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"resourceTarget"}}]}}]}}]} as unknown as DocumentNode; export const ServerInfoScopesFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerInfoScopesFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scopes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]} as unknown as DocumentNode; export const CommonStreamFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommonStreamFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"commentCount"}},{"kind":"Field","name":{"kind":"Name","value":"collaborators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"branches"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"favoritedDate"}},{"kind":"Field","name":{"kind":"Name","value":"favoritesCount"}}]}}]} as unknown as DocumentNode; -export const CommonUserFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommonUserFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"streams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CommonUserFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommonUserFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"hasPendingVerification"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"streams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const StreamWithBranchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamWithBranch"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"branchName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"branch"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"branchName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"4"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"authorAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"commentCount"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const BranchCreatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"BranchCreated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchCreated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; export const StreamCommitQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamCommitQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"commit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"authorAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"branchName"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -2579,6 +2606,8 @@ export const IsLoggedInDocument = {"kind":"Document","definitions":[{"kind":"Ope export const AdminUsersListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminUsersList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"query"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"query"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"registeredUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"authorizedApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const UserTimelineDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserTimeline"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"timeline"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ActivityMainFields"}}]}}]}}]}}]}},...ActivityMainFieldsFragmentDoc.definitions]} as unknown as DocumentNode; export const ValidatePasswordStrengthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidatePasswordStrength"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pwd"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userPwdStrength"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pwd"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pwd"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"feedback"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"suggestions"}}]}}]}}]}}]} as unknown as DocumentNode; +export const EmailVerificationBannerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EmailVerificationBannerState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"hasPendingVerification"}}]}}]}}]} as unknown as DocumentNode; +export const RequestVerificationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RequestVerification"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestVerification"}}]}}]} as unknown as DocumentNode; export const UserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"User"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; export const UserProfileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserProfile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]}}]} as unknown as DocumentNode; export const WebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"webhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"webhookId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"webhooks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"webhookId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"triggers"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"statusInfo"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/frontend/src/graphql/user.js b/packages/frontend/src/graphql/user.js index 67a2e89a9..a43b93074 100644 --- a/packages/frontend/src/graphql/user.js +++ b/packages/frontend/src/graphql/user.js @@ -12,6 +12,7 @@ export const commonUserFieldsFragment = gql` company avatar verified + hasPendingVerification profiles role streams { @@ -166,3 +167,20 @@ export const validatePasswordStrengthQuery = gql` } } ` + +export const emailVerificationBannerStateQuery = gql` + query EmailVerificationBannerState { + user { + id + email + verified + hasPendingVerification + } + } +` + +export const requestVerificationMutation = gql` + mutation RequestVerification { + requestVerification + } +` diff --git a/packages/frontend/src/main/components/common/GlobalToast.vue b/packages/frontend/src/main/components/common/GlobalToast.vue index 1c88f906b..b29c5318f 100644 --- a/packages/frontend/src/main/components/common/GlobalToast.vue +++ b/packages/frontend/src/main/components/common/GlobalToast.vue @@ -12,47 +12,16 @@ diff --git a/packages/frontend/src/main/components/user/EmailVerificationBanner.vue b/packages/frontend/src/main/components/user/EmailVerificationBanner.vue index 2432238bf..895bf2df9 100644 --- a/packages/frontend/src/main/components/user/EmailVerificationBanner.vue +++ b/packages/frontend/src/main/components/user/EmailVerificationBanner.vue @@ -1,5 +1,5 @@ - diff --git a/packages/frontend/src/main/layouts/TheMain.vue b/packages/frontend/src/main/layouts/TheMain.vue index cc0666796..1e45613aa 100644 --- a/packages/frontend/src/main/layouts/TheMain.vue +++ b/packages/frontend/src/main/layouts/TheMain.vue @@ -46,8 +46,7 @@ @@ -62,9 +61,9 @@ ` - let emailParams - mailerMock.enable() - mailerMock.mockFunction('sendEmail', (params) => { - emailParams = params - }) + const sendEmailInvocations = mailerMock.hijackFunction( + 'sendEmail', + async () => true + ) const result = await createInvite({ email: targetEmail, @@ -176,6 +163,7 @@ describe('[Stream & Server Invites]', () => { expect(result.errors).to.be.not.ok // Check that email was sent out + const emailParams = sendEmailInvocations.args[0][0] expect(emailParams).to.be.ok expect(emailParams.to).to.eq(targetEmail) expect(emailParams.subject).to.be.ok @@ -307,11 +295,10 @@ describe('[Stream & Server Invites]', () => { const unsanitaryMessage = `${messagePart1} ` const targetEmail = email || user.email - let emailParams - mailerMock.enable() - mailerMock.mockFunction('sendEmail', (params) => { - emailParams = params - }) + const sendEmailInvocations = mailerMock.hijackFunction( + 'sendEmail', + async () => true + ) const result = await createInvite({ email, @@ -326,6 +313,7 @@ describe('[Stream & Server Invites]', () => { expect(result.errors).to.be.not.ok // Check that email was sent out + const emailParams = sendEmailInvocations.args[0][0] expect(emailParams).to.be.ok expect(emailParams.to).to.eq(targetEmail) expect(emailParams.subject).to.be.ok @@ -429,11 +417,11 @@ describe('[Stream & Server Invites]', () => { }) it('they can resend pre-existing invites irregardless of type', async () => { - const emailParamsArr = [] - mailerMock.enable() - mailerMock.mockFunction('sendEmail', (params) => { - emailParamsArr.push(params) - }) + const sendEmailInvocations = mailerMock.hijackFunction( + 'sendEmail', + async () => true, + { times: invites.length } + ) const inviteIds = invites.map((i) => i.inviteId) @@ -446,7 +434,7 @@ describe('[Stream & Server Invites]', () => { expect(result.errors).to.not.be.ok } - expect(emailParamsArr).to.have.length(inviteIds.length) + expect(sendEmailInvocations.length()).to.eq(inviteIds.length) }) it('they can delete pre-existing invites irregardless of type', async () => { @@ -493,11 +481,11 @@ describe('[Stream & Server Invites]', () => { const emails = ['abababa1@mail.com', 'abababa2@mail.com', 'abababa3@mail.com'] const message = 'ayyoyoyoyoy' - const emailParamsArr = [] - mailerMock.enable() - mailerMock.mockFunction('sendEmail', (params) => { - emailParamsArr.push(params) - }) + const sendEmailInvocations = mailerMock.hijackFunction( + 'sendEmail', + async () => true, + { times: emails.length } + ) const result = await batchCreateServerInvites(apollo, { message, @@ -507,9 +495,9 @@ describe('[Stream & Server Invites]', () => { expect(result.data?.serverInviteBatchCreate).to.be.ok expect(result.errors).to.not.be.ok - expect(emailParamsArr).to.have.length(emails.length) + expect(sendEmailInvocations.length()).to.eq(emails.length) for (const email of emails) { - const emailParams = emailParamsArr.find((p) => p.to === email) + const emailParams = sendEmailInvocations.args.find(([p]) => p.to === email)[0] expect(emailParams).to.be.ok expect(emailParams.html).to.contain(message) @@ -544,22 +532,22 @@ describe('[Stream & Server Invites]', () => { } ] - const emailParamsArr = [] - mailerMock.enable() - mailerMock.mockFunction('sendEmail', (params) => { - emailParamsArr.push(params) - }) + const sendEmailInvocations = mailerMock.hijackFunction( + 'sendEmail', + async () => false, + { times: inputs.length } + ) const result = await batchCreateStreamInvites(apollo, inputs) expect(result.data?.streamInviteBatchCreate).to.be.ok expect(result.errors).to.not.be.ok - expect(emailParamsArr).to.have.length(inputs.length) + expect(sendEmailInvocations.length()).to.eq(inputs.length) for (const inputData of inputs) { - const emailParams = emailParamsArr.find((p) => + const emailParams = sendEmailInvocations.args.find(([p]) => inputData.email ? p.to === inputData.email : p.to === otherGuy.email - ) + )[0] expect(emailParams).to.be.ok expect(emailParams.html).to.contain(inputData.message) expect(emailParams.text).to.contain(inputData.message) diff --git a/packages/server/modules/shared/services/moduleEventEmitterSetup.ts b/packages/server/modules/shared/services/moduleEventEmitterSetup.ts new file mode 100644 index 000000000..559c7a068 --- /dev/null +++ b/packages/server/modules/shared/services/moduleEventEmitterSetup.ts @@ -0,0 +1,81 @@ +import { MaybeAsync } from '@/modules/shared/helpers/typeHelper' +import { modulesDebug } from '@/modules/shared/utils/logger' +import EventEmitter from 'eventemitter2' + +export type ModuleEventEmitterParams = { + moduleName: string + /** + * If you have multiple emitters in a single module, you can use this identify + * each of them differently + */ + namespace?: string +} + +/** + * Initialize Speckle Module scoped event emitter. These can be used to make code more SOLID - instead of + * modifying some code that does X every time you want to do something extra when X occurs, just emit an event + * there and specify the listening code in a more appropriate module. + * + * Example: Instead of comment mentions being sent out from the comment repository's "createComment" function, + * this repo function emits a COMMENT_CREATED event, that is then handled in a more appropriate module - the speckle + * Notifications module. + */ +export function initializeModuleEventEmitter

>( + params: ModuleEventEmitterParams +) { + const { moduleName, namespace } = params + const identifier = namespace ? `${moduleName}-${namespace}` : moduleName + + const debug = modulesDebug.extend(identifier).extend('events') + + const errHandler = (e: unknown) => { + debug(`Unhandled ${identifier} event emitter error`, e) + } + + const emitter = new EventEmitter() + emitter.on('uncaughtException', errHandler) + emitter.on('error', errHandler) + + return { + /** + * Emit a module event. This function must be awaited to ensure all listeners + * execute. Any errors thrown in the listeners will bubble up and throw from + * the part of code that triggers this emit() call. + */ + emit: async (eventName: K, payload: P[K]) => { + return await emitter.emitAsync(eventName, payload) + }, + + /** + * Listen for module events. Any errors thrown here will bubble out of where + * emit() was invoked. + * + * @returns Callback for stopping listening + */ + listen: ( + eventName: K, + handler: (payload: P[K]) => MaybeAsync + ) => { + emitter.on(eventName, handler, { + async: true, + promisify: true + }) + + return () => { + emitter.removeListener(eventName, handler) + } + }, + + /** + * Destroy event emitter + */ + destroy() { + emitter.removeAllListeners() + }, + + /** + * Debugger scoped to this module event emitter + */ + debug + } +} diff --git a/packages/server/package.json b/packages/server/package.json index 4fc5d273a..4428ecd45 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -49,6 +49,7 @@ "debug": "^4.3.1", "dotenv": "^8.2.0", "ejs": "^3.1.8", + "eventemitter2": "^6.4.7", "express": "^4.17.3", "express-async-errors": "^3.1.1", "express-session": "^1.17.1", @@ -103,9 +104,11 @@ "@types/express": "^4.17.13", "@types/lodash": "^4.14.180", "@types/mocha": "^7.0.2", + "@types/mock-require": "^2.0.1", "@types/module-alias": "^2.0.1", "@types/nodemailer": "^6.4.5", "@types/sanitize-html": "^2.6.2", + "@types/supertest": "^2.0.12", "@types/verror": "^1.10.6", "@types/yargs": "^17.0.10", "@types/zxcvbn": "^4.4.1", @@ -134,6 +137,7 @@ "supertest": "^4.0.2", "ts-node": "^10.9.1", "tsconfig-paths": "^4.0.0", + "type-fest": "^2.19.0", "typescript": "^4.6.4", "ws": "^7.5.7", "yargs": "^17.3.1" diff --git a/packages/server/test/authHelper.ts b/packages/server/test/authHelper.ts index 17fa34e9d..b2768b6a5 100644 --- a/packages/server/test/authHelper.ts +++ b/packages/server/test/authHelper.ts @@ -2,13 +2,15 @@ import { AllScopes } from '@/modules/core/helpers/mainConstants' import { UserRecord } from '@/modules/core/helpers/types' import { createPersonalAccessToken } from '@/modules/core/services/tokens' import { createUser } from '@/modules/core/services/users' +import { kebabCase, omit } from 'lodash' export type BasicTestUser = { name: string email: string - password: string + password?: string /** - * Will be set by createTestUser() + * Will be set by createTestUser(), but you need to set a default value to '' + * so that you don't have to check if its empty cause of TS */ id: string } & Partial @@ -18,7 +20,15 @@ export type BasicTestUser = { * the new ID */ export async function createTestUser(userObj: BasicTestUser) { - const id = await createUser(userObj) + if (!userObj.password) { + userObj.password = 'some-random-password-123456789#!@' + } + + if (!userObj.email) { + userObj.email = `${kebabCase(userObj.name)}@someemail.com` + } + + const id = await createUser(omit(userObj, ['id'])) userObj.id = id } diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 2a381321f..f30dcb05d 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -445,6 +445,8 @@ export type Mutation = { /** Re-send a pending invite */ inviteResend: Scalars['Boolean']; objectCreate: Array>; + /** (Re-)send the account verification e-mail */ + requestVerification: Scalars['Boolean']; serverInfoUpdate?: Maybe; serverInviteBatchCreate: Scalars['Boolean']; /** Invite a new user to the speckle server and return the invite ID */ @@ -774,6 +776,27 @@ export type ObjectCreateInput = { streamId: Scalars['String']; }; +export type PasswordStrengthCheckFeedback = { + __typename?: 'PasswordStrengthCheckFeedback'; + suggestions: Array; + warning?: Maybe; +}; + +export type PasswordStrengthCheckResults = { + __typename?: 'PasswordStrengthCheckResults'; + /** Verbal feedback to help choose better passwords. set when score <= 2. */ + feedback: PasswordStrengthCheckFeedback; + /** + * Integer from 0-4 (useful for implementing a strength bar): + * 0 too guessable: risky password. (guesses < 10^3) + * 1 very guessable: protection from throttled online attacks. (guesses < 10^6) + * 2 somewhat guessable: protection from unthrottled online attacks. (guesses < 10^8) + * 3 safely unguessable: moderate protection from offline slow-hash scenario. (guesses < 10^10) + * 4 very unguessable: strong protection from offline slow-hash scenario. (guesses >= 10^10) + */ + score: Scalars['Int']; +}; + export type PendingStreamCollaborator = { __typename?: 'PendingStreamCollaborator'; id: Scalars['String']; @@ -830,12 +853,10 @@ export type Query = { streamInvites: Array; /** All the streams of the current user, pass in the `query` parameter to search by name, description or ID. */ streams?: Maybe; - /** - * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). - * If ID is provided, admin access is required - */ + /** Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). */ user?: Maybe; - userPwdStrength?: Maybe; + /** Validate password strength */ + userPwdStrength: PasswordStrengthCheckResults; /** * Search for users and return limited metadata about them, if you have the server:user role. * The query looks for matches in name & email @@ -1379,6 +1400,8 @@ export type User = { email?: Maybe; /** All the streams that a user has favorited */ favoriteStreams?: Maybe; + /** Whether the user has a pending/active email verification token */ + hasPendingVerification?: Maybe; id: Scalars['String']; name?: Maybe; profiles?: Maybe; @@ -1691,3 +1714,15 @@ export type GetAdminUsersQueryVariables = Exact<{ export type GetAdminUsersQuery = { __typename?: 'Query', adminUsers?: { __typename?: 'AdminUsersListCollection', totalCount: number, items: Array<{ __typename?: 'AdminUsersListItem', id: string, registeredUser?: { __typename?: 'User', id: string, email?: string | null, name?: string | null } | null, invitedUser?: { __typename?: 'ServerInvite', id: string, email: string, invitedBy: { __typename?: 'LimitedUser', id: string, name?: string | null } } | null }> } | null }; + +export type GetPendingEmailVerificationStatusQueryVariables = Exact<{ + id?: InputMaybe; +}>; + + +export type GetPendingEmailVerificationStatusQuery = { __typename?: 'Query', user?: { __typename?: 'User', hasPendingVerification?: boolean | null } | null }; + +export type RequestVerificationMutationVariables = Exact<{ [key: string]: never; }>; + + +export type RequestVerificationMutation = { __typename?: 'Mutation', requestVerification: boolean }; diff --git a/packages/server/test/graphql/users.ts b/packages/server/test/graphql/users.ts index bb4fca32c..189777328 100644 --- a/packages/server/test/graphql/users.ts +++ b/packages/server/test/graphql/users.ts @@ -1,7 +1,11 @@ import { ApolloServer, gql } from 'apollo-server-express' import { GetAdminUsersQuery, - GetAdminUsersQueryVariables + GetAdminUsersQueryVariables, + GetPendingEmailVerificationStatusQuery, + GetPendingEmailVerificationStatusQueryVariables, + RequestVerificationMutation, + RequestVerificationMutationVariables } from '@/test/graphql/generated/graphql' import { executeOperation } from '@/test/graphqlHelper' @@ -29,9 +33,20 @@ const adminUsersQuery = gql` } ` -/** - * adminUsers query - */ +const getPendingEmailVerificationStatusQuery = gql` + query GetPendingEmailVerificationStatus($id: String) { + user(id: $id) { + hasPendingVerification + } + } +` + +const requestVerificationMutation = gql` + mutation RequestVerification { + requestVerification + } +` + export async function getAdminUsersList( apollo: ApolloServer, variables: GetAdminUsersQueryVariables @@ -42,3 +57,22 @@ export async function getAdminUsersList( variables ) } + +export const getPendingEmailVerificationStatus = ( + apollo: ApolloServer, + variables: GetPendingEmailVerificationStatusQueryVariables +) => + executeOperation< + GetPendingEmailVerificationStatusQuery, + GetPendingEmailVerificationStatusQueryVariables + >(apollo, getPendingEmailVerificationStatusQuery, variables) + +export const requestVerification = ( + apollo: ApolloServer, + variables?: RequestVerificationMutationVariables +) => + executeOperation( + apollo, + requestVerificationMutation, + variables + ) diff --git a/packages/server/test/hooks.js b/packages/server/test/hooks.js index 21f46032a..8b1ccd4c8 100644 --- a/packages/server/test/hooks.js +++ b/packages/server/test/hooks.js @@ -1,13 +1,18 @@ -/* istanbul ignore file */ require('../bootstrap') const chai = require('chai') const chaiHttp = require('chai-http') const deepEqualInAnyOrder = require('deep-equal-in-any-order') const knex = require(`@/db/knex`) const { init, startHttp, shutdown } = require(`@/app`) +const { default: graphqlChaiPlugin } = require('@/test/plugins/graphql') +// Register chai plugins chai.use(chaiHttp) chai.use(deepEqualInAnyOrder) +chai.use(graphqlChaiPlugin) + +// Register global mocks +require('@/test/mocks/global') const unlock = async () => { const exists = await knex.schema.hasTable('knex_migrations_lock') @@ -75,10 +80,14 @@ exports.mochaHooks = { } } -exports.beforeEachContext = async () => { - await exports.truncateTables() +exports.buildApp = async () => { const { app, graphqlServer } = await init() return { app, graphqlServer } } +exports.beforeEachContext = async () => { + await exports.truncateTables() + return await exports.buildApp() +} + exports.initializeTestServer = initializeTestServer diff --git a/packages/server/test/mockHelper.js b/packages/server/test/mockHelper.js deleted file mode 100644 index fc7b8b8ec..000000000 --- a/packages/server/test/mockHelper.js +++ /dev/null @@ -1,103 +0,0 @@ -const { isArray, isFunction } = require('lodash') -const mock = require('mock-require') - -/** - * Mock a module's exported functions with the possibility to conditionally disable & change the mock - * @param {string|string[]} modulePaths Absolute & relative paths to the module being mocked or if you sometimes require - * with the '/index' suffix and sometimes don't you need to specify both options. Multiple options are required - * because of a limitation of mock-require - it doesn't understand that all of these point to the same thing. - * @param {string|string[]} dependencyPaths Paths to modules that use the mocked module and that you - * want to re-require so that if they are already loaded in memory, they're re-required with the new mock. Basically, - * if you've mocked a module, but it's not being used, debug the test and see if the mocked module is maybe required - * by another module that you haven't specified in this list. - */ -function mockRequireModule(modulePaths, dependencyPaths = []) { - modulePaths = isArray(modulePaths) ? modulePaths : [modulePaths] - dependencyPaths = isArray(dependencyPaths) ? dependencyPaths : [dependencyPaths] - - let disabled = false - let functionReplacements = {} - - const originalModule = require(modulePaths[0]) - const mockDefinition = new Proxy(originalModule, { - get(target, prop) { - if (!isFunction(target[prop])) return target[prop] - return function (...args) { - if (disabled || !functionReplacements[prop]) { - return target[prop].apply(this, args) - } - - return functionReplacements[prop].apply(this, args) - } - } - }) - - // Initialize mock with all paths (relative path, absolute alias path - both need to be specified - // cause of a limitation in mock-require) - for (const modulePath of modulePaths) { - mock(modulePath, mockDefinition) - } - - /** - * Re-requires the specified modules, in case they were required before the mock was set up - * and thus don't have the mocked module - */ - const reRequireDependencies = () => { - for (const dependencyPath of dependencyPaths) { - mock.reRequire(dependencyPath) - } - } - reRequireDependencies() - - return { - /** - * Set (or unset) a mocked implementation of a function - * @param {string} functionName - * @param {Function | null | undefined} implementation - */ - mockFunction(functionName, implementation) { - if (implementation) { - functionReplacements[functionName] = implementation - } else { - delete functionReplacements[functionName] - } - }, - /** - * Remove all mocked function implementations - */ - resetMockedFunctions() { - functionReplacements = {} - }, - /** - * Temporarily disable the mock, sending all function calls to the real implementations - */ - disable() { - disabled = true - }, - /** - * Re-enable the mock, if it's been disabled before - */ - enable() { - disabled = false - }, - /** - * Unmock entirely - * Note: All requires done before this point will still point to the mocks - */ - destroy(reRequireDeps = true) { - for (const modulePath of modulePaths) { - mock.stop(modulePath) - } - - if (reRequireDeps) reRequireDependencies() - }, - /** - * Re-require specified dependencies - */ - reRequireDependencies - } -} - -module.exports = { - mockRequireModule -} diff --git a/packages/server/test/mockHelper.ts b/packages/server/test/mockHelper.ts new file mode 100644 index 000000000..e24ce564e --- /dev/null +++ b/packages/server/test/mockHelper.ts @@ -0,0 +1,222 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { MaybeAsync } from '@/modules/shared/helpers/typeHelper' +import { isArray, isFunction } from 'lodash' +import mock from 'mock-require' +import { ConditionalPick } from 'type-fest' + +export type MockedFunctionImplementation = (...args: any[]) => MaybeAsync + +/** + * Mock a module's exported functions with the possibility to conditionally disable & change the mock + * @param {string|string[]} modulePaths Absolute & relative paths to the module being mocked or if you sometimes require + * with the '/index' suffix and sometimes don't you need to specify both options. Multiple options are required + * because of a limitation of mock-require - it doesn't understand that all of these point to the same thing. + * @param {string|string[]} dependencyPaths Paths to modules that use the mocked module and that you + * want to re-require so that if they are already loaded in memory, they're re-required with the new mock. Basically, + * if you've mocked a module, but it's not being used, debug the test and see if the mocked module is maybe required + * by another module that you haven't specified in this list. + */ +export function mockRequireModule< + MockType extends Record = Record +>( + modulePaths: string | string[], + dependencyPaths: string | string[] = [], + params: { preventDestroy?: boolean } = {} +) { + type MockTypeFunctionsOnly = ConditionalPick + type MockTypeFunctionProp = keyof MockTypeFunctionsOnly + + type MockedFunc = ( + ...args: Parameters + ) => ReturnType + + const { preventDestroy } = params + modulePaths = isArray(modulePaths) ? modulePaths : [modulePaths] + dependencyPaths = isArray(dependencyPaths) ? dependencyPaths : [dependencyPaths] + + let isDisabled = false + let functionReplacements: Partial< + Record> + > = {} + + const originalModule = require(modulePaths[0]) as MockType + const mockDefinition = new Proxy(originalModule, { + get(target, prop) { + const realProp = prop as keyof MockTypeFunctionsOnly + const propVal = target[realProp] + + if (!isFunction(propVal)) return propVal + return function (this: unknown, ...args: Parameters[]) { + const potentialReplacement = functionReplacements[realProp] as typeof propVal + if (isDisabled || !potentialReplacement || !isFunction(potentialReplacement)) { + return propVal.apply(this, args) + } + + return potentialReplacement.apply(this, args) + } + } + }) + + // Initialize mock with all paths (relative path, absolute alias path - both need to be specified + // cause of a limitation in mock-require) + for (const modulePath of modulePaths) { + mock(modulePath, mockDefinition) + } + + /** + * Re-requires the specified modules, in case they were required before the mock was set up + * and thus don't have the mocked module + */ + const reRequireDependencies = () => { + for (const dependencyPath of dependencyPaths) { + mock.reRequire(dependencyPath) + } + } + reRequireDependencies() + + const core = { + /** + * Set (or unset) a mocked implementation of a function + */ + mockFunction( + functionName: F, + implementation: MockedFunc + ) { + if (implementation) { + functionReplacements[functionName] = implementation + } else { + delete functionReplacements[functionName] + } + }, + /** + * Remove all mocked function implementations + */ + resetMockedFunctions() { + functionReplacements = {} + }, + /** + * Remove a single function mock + */ + resetMockedFunction(functionName: MockTypeFunctionProp) { + delete functionReplacements[functionName] + }, + /** + * Temporarily disable the mock, sending all function calls to the real implementations + */ + disable() { + isDisabled = true + }, + /** + * Re-enable the mock, if it's been disabled before + */ + enable() { + isDisabled = false + }, + /** + * Unmock entirely + * Note: All requires done before this point will still point to the mocks + */ + destroy(reRequireDeps = true) { + if (preventDestroy) { + isDisabled = true + return + } + + for (const modulePath of modulePaths) { + mock.stop(modulePath) + } + + if (reRequireDeps) reRequireDependencies() + }, + /** + * Re-require specified dependencies + */ + reRequireDependencies + } + + const helpers = { + /** + * Mock a function temporarily + * + * Set 'times' parameter to control how many times will the function be invoked + * with the mocked implementation + * + * Use args & results arrays in result object to see the passed in arguments and function return values + * that were collected + */ + hijackFunction( + functionName: F, + implementation: MockedFunc, + params: { times: number } = { times: 1 } + ) { + let { times } = params + if (!isFunction(implementation)) + throw new Error('Implementation must be a function') + + const collectedReturns: Array>> = [] + const collectedArgs: Array>> = [] + + core.enable() + core.mockFunction( + functionName, + function (this: unknown, ...args: Parameters>) { + const returnVal = implementation.apply(this, args) + times-- + + if (times <= 0) { + core.resetMockedFunction(functionName) + } + + collectedArgs.push(args) + collectedReturns.push(returnVal) + + return returnVal + } + ) + + return { + /** + * Arguments that were used to call the mocked function. Each entry in this array is an array of arguments, so use the first array dimension to choose + * the invocation and the 2nd dimension to choose the specific argument. + */ + args: collectedArgs, + /** + * Return values that were returned from the mocked function. + */ + returns: collectedReturns, + /** + * Get the amount of invocations + */ + length: () => collectedArgs.length + } + } + } + + return { + ...core, + ...helpers + } +} + +export type MockApiType = ReturnType + +/** + * Create global mock. Essentially the same as mockRequireModule() but simplified + * with safeguards so that you can't destroy it and break it in other tests + * + * Note: Global mocks should be registered in test/hooks.js before everything else! + */ +export function createGlobalMock>( + modulePath: string +) { + const globalMock = mockRequireModule([modulePath], [], { + preventDestroy: true + }) + const { hijackFunction, resetMockedFunctions } = globalMock + + return { + hijackFunction, + resetMockedFunctions + } +} diff --git a/packages/server/test/mocks/global.ts b/packages/server/test/mocks/global.ts new file mode 100644 index 000000000..54fe13856 --- /dev/null +++ b/packages/server/test/mocks/global.ts @@ -0,0 +1,10 @@ +import { createGlobalMock } from '@/test/mockHelper' + +/** + * Global mocks that can be re-used. Remember to .enable() before use and .disable() + * after use to ensure that other tests work with the real module. + */ + +export const EmailSendingServiceMock = createGlobalMock< + typeof import('@/modules/emails/services/sending') +>('@/modules/emails/services/sending') diff --git a/packages/server/test/plugins/graphql.ts b/packages/server/test/plugins/graphql.ts new file mode 100644 index 000000000..7db48a92d --- /dev/null +++ b/packages/server/test/plugins/graphql.ts @@ -0,0 +1,47 @@ +import { Optional } from '@/modules/shared/helpers/typeHelper' +import { GraphQLResponse } from 'apollo-server-core' + +type ChaiPluginThis> = { + __flags: { + message: Optional + negate: Optional + object: O + } +} + +/** + * Adds various useful assertions for GraphQL API integration tests + */ +const graphqlChaiPlugin: Chai.ChaiPlugin = (_chai, utils) => { + const { Assertion } = _chai + + utils.addMethod( + Assertion.prototype, + 'haveGraphQLErrors', + function (this: ChaiPluginThis, matchMessage?: string) { + const { negate, object } = this.__flags + const { errors } = object + + const errorsArr = errors || [] + + if (negate) { + new Assertion(errorsArr).to.have.lengthOf(0) + } else { + new Assertion(errorsArr).to.have.lengthOf.greaterThanOrEqual(1) + } + + if (matchMessage) { + if (negate) { + new Assertion( + errorsArr.map((e) => e.message.toLowerCase()).join('\n') + ).to.not.contain(matchMessage.toLowerCase()) + } else { + new Assertion(errorsArr.map((e) => e.message).join('\n')).to.contain( + matchMessage.toLowerCase() + ) + } + } + } + ) +} +export default graphqlChaiPlugin diff --git a/packages/server/type-augmentations/chai.d.ts b/packages/server/type-augmentations/chai.d.ts new file mode 100644 index 000000000..6035eb795 --- /dev/null +++ b/packages/server/type-augmentations/chai.d.ts @@ -0,0 +1,13 @@ +declare global { + namespace Chai { + interface Assertion { + /** + * Check if GraphQLResponse object has any errors + * @param message Optionally check if any of the errors contain the specified message + */ + haveGraphQLErrors(message?: string): void + } + } +} + +export {} diff --git a/yarn.lock b/yarn.lock index 066b17ae6..9a16d283e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5554,9 +5554,11 @@ __metadata: "@types/express": ^4.17.13 "@types/lodash": ^4.14.180 "@types/mocha": ^7.0.2 + "@types/mock-require": ^2.0.1 "@types/module-alias": ^2.0.1 "@types/nodemailer": ^6.4.5 "@types/sanitize-html": ^2.6.2 + "@types/supertest": ^2.0.12 "@types/verror": ^1.10.6 "@types/yargs": ^17.0.10 "@types/zxcvbn": ^4.4.1 @@ -5589,6 +5591,7 @@ __metadata: ejs: ^3.1.8 eslint: ^8.11.0 eslint-config-prettier: ^8.5.0 + eventemitter2: ^6.4.7 express: ^4.17.3 express-async-errors: ^3.1.1 express-session: ^1.17.1 @@ -5631,6 +5634,7 @@ __metadata: supertest: ^4.0.2 ts-node: ^10.9.1 tsconfig-paths: ^4.0.0 + type-fest: ^2.19.0 typescript: ^4.6.4 undici: ^5.4.0 verror: ^1.10.1 @@ -6573,6 +6577,15 @@ __metadata: languageName: node linkType: hard +"@types/mock-require@npm:^2.0.1": + version: 2.0.1 + resolution: "@types/mock-require@npm:2.0.1" + dependencies: + "@types/node": "*" + checksum: 8749a4b3fcb9f3d6ebaeff442f00997ca59c4806bc00fea648a1fd06b1ea8510a6900b8e47070561ddf15ce98abc80dfe24ff21a307c2b0d1a6845bd865f708b + languageName: node + linkType: hard + "@types/module-alias@npm:^2.0.1": version: 2.0.1 resolution: "@types/module-alias@npm:2.0.1" @@ -6814,6 +6827,16 @@ __metadata: languageName: node linkType: hard +"@types/superagent@npm:*": + version: 4.1.15 + resolution: "@types/superagent@npm:4.1.15" + dependencies: + "@types/cookiejar": "*" + "@types/node": "*" + checksum: 347cd74ef0a29e6b9c6d32253c3fb0dd39a31618b50752f84d36b6a9246237bb6b68c9b436c1f94adabc2df89d9f1939e4782f4c850f98b9c2fe431ad4e565a4 + languageName: node + linkType: hard + "@types/superagent@npm:^3.8.3": version: 3.8.7 resolution: "@types/superagent@npm:3.8.7" @@ -6824,6 +6847,15 @@ __metadata: languageName: node linkType: hard +"@types/supertest@npm:^2.0.12": + version: 2.0.12 + resolution: "@types/supertest@npm:2.0.12" + dependencies: + "@types/superagent": "*" + checksum: f0e2b44f86bec2f708d6a3d0cb209055b487922040773049b0f8c6b557af52d4b5fa904e17dfaa4ce6e610172206bbec7b62420d158fa57b6ffc2de37b1730d3 + languageName: node + linkType: hard + "@types/three@npm:^0.136.0": version: 0.136.1 resolution: "@types/three@npm:0.136.1" @@ -14337,6 +14369,13 @@ __metadata: languageName: node linkType: hard +"eventemitter2@npm:^6.4.7": + version: 6.4.7 + resolution: "eventemitter2@npm:6.4.7" + checksum: 1b36a77e139d6965ebf3a36c01fa00c089ae6b80faa1911e52888f40b3a7057b36a2cc45dcd1ad87cda3798fe7b97a0aabcbb8175a8b96092a23bb7d0f039e66 + languageName: node + linkType: hard + "eventemitter3@npm:^2.0.3": version: 2.0.3 resolution: "eventemitter3@npm:2.0.3" @@ -27451,6 +27490,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^2.19.0": + version: 2.19.0 + resolution: "type-fest@npm:2.19.0" + checksum: a4ef07ece297c9fba78fc1bd6d85dff4472fe043ede98bd4710d2615d15776902b595abf62bd78339ed6278f021235fb28a96361f8be86ed754f778973a0d278 + languageName: node + linkType: hard + "type-is@npm:^1.6.16, type-is@npm:~1.6.16, type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" From 808e8d715c3fcbcda74705bf8bde94751315218d Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 1 Sep 2022 16:58:33 +0100 Subject: [PATCH 26/28] fix(circleci): builds commits on detached branches (#978) CircleCI failed to build tagged commit as it was checked out in an orphaned state without a branch. This commit allows tagged commits to be built. --- .circleci/config.yml | 5 +++-- .circleci/should_build.sh | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 77aef2544..0cd52fe36 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -193,9 +193,9 @@ jobs: docker: - image: cimg/base:2022.08 working_directory: *work-dir - environment: - # £ delimited strings of regex for matches which should be published + environment: &publishable-tags-branches PUBLISHABLE_TAGS: '^[0-9]+\.[0-9]+\.[0-9]+$' + # £ delimited strings of regex for matches which should be published PUBLISHABLE_BRANCHES: '^main$£^hotfix.*£^alpha.*' steps: - checkout @@ -215,6 +215,7 @@ jobs: docker: - image: cimg/base:2022.08 working_directory: *work-dir + environment: *publishable-tags-branches steps: - checkout - run: mkdir -p workspace diff --git a/.circleci/should_build.sh b/.circleci/should_build.sh index 9fcf29a06..22d81c8fd 100755 --- a/.circleci/should_build.sh +++ b/.circleci/should_build.sh @@ -1,6 +1,12 @@ #!/bin/bash set -eo pipefail +IFS='£' read -r -a PUB_TAGS <<< "${PUBLISHABLE_TAGS}" +# shellcheck disable=SC2068 +for item in ${PUB_TAGS[@]}; do + [[ "${CIRCLE_TAG}" =~ ${item} ]] && echo "true" && exit 0 +done + # it's on the main branch [[ "${CIRCLE_BRANCH}" == "main" ]] && echo "true" && exit 0 From 9f844648378cf1983fbfc16ac3111fa57ef2b55f Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Fri, 2 Sep 2022 11:34:21 +0100 Subject: [PATCH 27/28] fix(server prometheus metrics): speckle_server_apollo_calls prometheus metric should be published (#980) The move from app.js to app.ts had broken speckle_server_apollo_calls prometheus metric. This PR reverts the import of apolloPlugin to use the previous inline require. --- packages/server/app.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/server/app.ts b/packages/server/app.ts index 59729e377..db946f430 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -29,7 +29,6 @@ import { buildErrorFormatter } from '@/modules/core/graph/setup' import { isDevEnv, isTestEnv } from '@/modules/shared/helpers/envHelper' import * as ModulesSetup from '@/modules' import { Optional } from '@/modules/shared/helpers/typeHelper' -import apolloPlugin from '@/logging/apolloPlugin' import { get, has, isString, toNumber } from 'lodash' @@ -100,7 +99,7 @@ export function buildApolloServer( metricConnectedClients.dec() } }, - plugins: [apolloPlugin], + plugins: [require('@/logging/apolloPlugin')], tracing: debug, introspection: true, playground: true, From f991504932798f721c584a5950f0e1adaa0a1966 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Tue, 6 Sep 2022 11:10:38 +0300 Subject: [PATCH 28/28] feat: stream access requests (#976) --- package.json | 2 +- packages/frontend/.eslintrc.js | 3 +- packages/frontend/codegen.yml | 2 + packages/frontend/src/config/apolloConfig.ts | 3 + .../frontend/src/graphql/accessRequests.ts | 32 ++ .../src/graphql/fragments/accessRequests.ts | 22 ++ .../frontend/src/graphql/fragments/streams.ts | 12 + .../frontend/src/graphql/generated/graphql.ts | 196 ++++++++-- packages/frontend/src/graphql/streams.js | 5 + packages/frontend/src/graphql/userById.gql | 2 +- .../frontend/src/helpers/mainConstants.js | 25 -- .../frontend/src/helpers/mainConstants.ts | 46 +++ packages/frontend/src/helpers/typeHelpers.ts | 11 + .../frontend/src/helpers/vuetifyHelpers.ts | 1 + .../CommentThreadReplyAttachments.vue | 5 +- .../main/components/common/PreviewImage.vue | 2 +- .../src/main/components/common/UserAvatar.vue | 52 +-- .../main/components/common/UserAvatarIcon.vue | 3 + .../components/common/layout/BasicPanel.vue | 3 + .../common/layout/RoundedButtonList.vue | 16 + .../RoundedButtonListItem.vue | 69 ++++ .../src/main/components/feed/FeedTimeline.vue | 3 +- .../stream/StreamAccessRequestBanner.vue | 176 +++++++++ .../main/components/stream/StreamActivity.vue | 3 +- .../user/EmailVerificationBanner.vue | 4 +- .../components/viewer/FilterNumericActive.vue | 2 +- .../frontend/src/main/dialogs/NewStream.vue | 2 +- .../apollo/helpers/apolloOperationHelper.ts | 108 +++++- .../src/main/lib/core/composables/core.ts | 10 + .../main/lib/feed/helpers/activityStream.ts | 9 + .../lib/stream/mixins/streamInviteMixin.ts | 2 +- .../main/pages/stream/CommitObjectViewer.vue | 76 ++-- .../main/pages/stream/TheCollaborators.vue | 23 +- .../src/main/pages/stream/TheEmbed.vue | 4 +- .../src/main/pages/stream/TheSettings.vue | 2 +- .../src/main/pages/stream/TheStream.vue | 128 ++++++- .../src/type-augmentations/shims-vue.d.ts | 4 - .../frontend/src/type-augmentations/vue.d.ts | 10 + packages/frontend/tsconfig.json | 3 + packages/server/.eslintrc.js | 6 + .../typedefs/accessrequests.graphql | 47 +++ packages/server/codegen.yml | 10 +- .../modules/accessrequests/errors/index.ts | 13 + .../modules/accessrequests/events/emitter.ts | 31 ++ .../accessrequests/graph/resolvers/index.ts | 72 ++++ .../accessrequests/helpers/graphTypes.ts | 6 + .../server/modules/accessrequests/index.ts | 20 + ...102231_add_server_access_requests_table.ts | 35 ++ .../accessrequests/repositories/index.ts | 131 +++++++ .../accessrequests/services/eventListener.ts | 66 ++++ .../modules/accessrequests/services/stream.ts | 142 +++++++ .../tests/streamAccessRequests.spec.ts | 351 ++++++++++++++++++ .../services/accessRequestActivity.ts | 44 +++ .../activitystream/services/eventListener.ts | 88 ++++- .../modules/activitystream/services/index.js | 4 +- ...amActivityService.ts => streamActivity.ts} | 0 .../comments/services/notifications.ts | 2 +- packages/server/modules/core/dbSchema.ts | 20 + .../modules/core/events/usersEmitter.ts | 6 +- .../modules/core/graph/generated/graphql.ts | 87 ++++- .../server/modules/core/helpers/graphTypes.ts | 45 +++ .../modules/core/helpers/routeHelper.ts | 9 + .../modules/core/repositories/streams.ts | 19 +- .../server/modules/core/repositories/users.ts | 1 + .../services/streams/streamAccessService.js | 2 +- packages/server/modules/index.js | 3 +- .../modules/notifications/helpers/types.ts | 30 +- .../server/modules/notifications/index.ts | 8 + .../handlers/newStreamAccessRequest.ts | 118 ++++++ .../handlers/streamAccessRequestApproved.ts | 102 +++++ .../services/inviteCreationService.js | 2 +- .../services/inviteProcessingService.js | 2 +- packages/server/modules/shared/errors/base.ts | 4 +- .../modules/shared/helpers/errorHelper.ts | 13 + .../modules/shared/helpers/typeHelper.ts | 43 +-- .../server/test/graphql/accessRequests.ts | 110 ++++++ .../server/test/graphql/generated/graphql.ts | 69 ++++ packages/server/test/notificationsHelper.ts | 6 +- packages/server/test/plugins/graphql.ts | 6 +- .../speckle-helpers/activityStreamHelper.ts | 24 ++ .../server/type-augmentations/verror.d.ts | 69 ++++ yarn.lock | 61 ++- 82 files changed, 2747 insertions(+), 261 deletions(-) create mode 100644 packages/frontend/src/graphql/accessRequests.ts create mode 100644 packages/frontend/src/graphql/fragments/accessRequests.ts create mode 100644 packages/frontend/src/graphql/fragments/streams.ts delete mode 100644 packages/frontend/src/helpers/mainConstants.js create mode 100644 packages/frontend/src/helpers/mainConstants.ts create mode 100644 packages/frontend/src/main/components/common/layout/BasicPanel.vue create mode 100644 packages/frontend/src/main/components/common/layout/RoundedButtonList.vue create mode 100644 packages/frontend/src/main/components/common/layout/rounded-button-list/RoundedButtonListItem.vue create mode 100644 packages/frontend/src/main/components/stream/StreamAccessRequestBanner.vue create mode 100644 packages/frontend/src/main/lib/feed/helpers/activityStream.ts delete mode 100644 packages/frontend/src/type-augmentations/shims-vue.d.ts create mode 100644 packages/server/assets/accessrequests/typedefs/accessrequests.graphql create mode 100644 packages/server/modules/accessrequests/errors/index.ts create mode 100644 packages/server/modules/accessrequests/events/emitter.ts create mode 100644 packages/server/modules/accessrequests/graph/resolvers/index.ts create mode 100644 packages/server/modules/accessrequests/helpers/graphTypes.ts create mode 100644 packages/server/modules/accessrequests/index.ts create mode 100644 packages/server/modules/accessrequests/migrations/20220829102231_add_server_access_requests_table.ts create mode 100644 packages/server/modules/accessrequests/repositories/index.ts create mode 100644 packages/server/modules/accessrequests/services/eventListener.ts create mode 100644 packages/server/modules/accessrequests/services/stream.ts create mode 100644 packages/server/modules/accessrequests/tests/streamAccessRequests.spec.ts create mode 100644 packages/server/modules/activitystream/services/accessRequestActivity.ts rename packages/server/modules/activitystream/services/{streamActivityService.ts => streamActivity.ts} (100%) create mode 100644 packages/server/modules/core/helpers/graphTypes.ts create mode 100644 packages/server/modules/notifications/services/handlers/newStreamAccessRequest.ts create mode 100644 packages/server/modules/notifications/services/handlers/streamAccessRequestApproved.ts create mode 100644 packages/server/test/graphql/accessRequests.ts create mode 100644 packages/server/test/speckle-helpers/activityStreamHelper.ts create mode 100644 packages/server/type-augmentations/verror.d.ts diff --git a/package.json b/package.json index bf0ef4398..5567eba87 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "resolutions": { "tslib": "^2.3.1", "core-js": "3.22.4", - "vue-cli-plugin-apollo/graphql": "^15", + "graphql": "^15", "typescript": "^4.5.4", "vue-loader": "^15.10.0" }, diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js index 2a67cf571..a55211aa6 100644 --- a/packages/frontend/.eslintrc.js +++ b/packages/frontend/.eslintrc.js @@ -26,7 +26,8 @@ const config = { extends: ['plugin:vue/recommended', '@vue/eslint-config-typescript', 'prettier'], rules: { 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': ['error'] + '@typescript-eslint/no-unused-vars': ['error'], + 'vue/component-name-in-template-casing': ['warn', 'kebab-case'] } }, { diff --git a/packages/frontend/codegen.yml b/packages/frontend/codegen.yml index 9fc3aa0ca..b9e456eb1 100644 --- a/packages/frontend/codegen.yml +++ b/packages/frontend/codegen.yml @@ -15,3 +15,5 @@ generates: config: scalars: JSONObject: Record + DateTime: string + dedupeFragments: true diff --git a/packages/frontend/src/config/apolloConfig.ts b/packages/frontend/src/config/apolloConfig.ts index 5bb5282db..bb8dc7365 100644 --- a/packages/frontend/src/config/apolloConfig.ts +++ b/packages/frontend/src/config/apolloConfig.ts @@ -110,6 +110,9 @@ function createCache(): InMemoryCache { }, pendingCollaborators: { merge: incomingOverwritesExistingMergeFunction + }, + pendingAccessRequests: { + merge: incomingOverwritesExistingMergeFunction } } }, diff --git a/packages/frontend/src/graphql/accessRequests.ts b/packages/frontend/src/graphql/accessRequests.ts new file mode 100644 index 000000000..c8e40d40f --- /dev/null +++ b/packages/frontend/src/graphql/accessRequests.ts @@ -0,0 +1,32 @@ +import { basicStreamAccessRequestFieldsFragment } from '@/graphql/fragments/accessRequests' +import { gql } from '@apollo/client/core' + +export const getStreamAccessRequestQuery = gql` + query GetStreamAccessRequest($streamId: String!) { + streamAccessRequest(streamId: $streamId) { + ...BasicStreamAccessRequestFields + } + } + + ${basicStreamAccessRequestFieldsFragment} +` + +export const createStreamAccessRequestMutation = gql` + mutation CreateStreamAccessRequest($streamId: String!) { + streamAccessRequestCreate(streamId: $streamId) { + ...BasicStreamAccessRequestFields + } + } + + ${basicStreamAccessRequestFieldsFragment} +` + +export const useStreamAccessRequestMutation = gql` + mutation UseStreamAccessRequest( + $requestId: String! + $accept: Boolean! + $role: StreamRole = STREAM_CONTRIBUTOR + ) { + streamAccessRequestUse(requestId: $requestId, accept: $accept, role: $role) + } +` diff --git a/packages/frontend/src/graphql/fragments/accessRequests.ts b/packages/frontend/src/graphql/fragments/accessRequests.ts new file mode 100644 index 000000000..4cf58aa73 --- /dev/null +++ b/packages/frontend/src/graphql/fragments/accessRequests.ts @@ -0,0 +1,22 @@ +import { limitedUserFieldsFragment } from '@/graphql/fragments/user' +import { gql } from '@apollo/client/core' + +export const basicStreamAccessRequestFieldsFragment = gql` + fragment BasicStreamAccessRequestFields on StreamAccessRequest { + id + streamId + createdAt + } +` + +export const fullStreamAccessRequestFieldsFragment = gql` + fragment FullStreamAccessRequestFields on StreamAccessRequest { + ...BasicStreamAccessRequestFields + requester { + ...LimitedUserFields + } + } + + ${limitedUserFieldsFragment} + ${basicStreamAccessRequestFieldsFragment} +` diff --git a/packages/frontend/src/graphql/fragments/streams.ts b/packages/frontend/src/graphql/fragments/streams.ts new file mode 100644 index 000000000..713ba5bb6 --- /dev/null +++ b/packages/frontend/src/graphql/fragments/streams.ts @@ -0,0 +1,12 @@ +import { fullStreamAccessRequestFieldsFragment } from '@/graphql/fragments/accessRequests' +import { gql } from '@apollo/client/core' + +export const streamPendingAccessRequestsFragment = gql` + fragment StreamPendingAccessRequests on Stream { + pendingAccessRequests { + ...FullStreamAccessRequestFields + } + } + + ${fullStreamAccessRequestFieldsFragment} +` diff --git a/packages/frontend/src/graphql/generated/graphql.ts b/packages/frontend/src/graphql/generated/graphql.ts index 4e35bf252..0e0bb1cdb 100644 --- a/packages/frontend/src/graphql/generated/graphql.ts +++ b/packages/frontend/src/graphql/generated/graphql.ts @@ -15,7 +15,7 @@ export type Scalars = { /** The `BigInt` scalar type represents non-fractional signed whole numeric values. */ BigInt: any; /** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */ - DateTime: any; + DateTime: string; EmailAddress: any; /** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ JSONObject: Record; @@ -483,6 +483,10 @@ export type Mutation = { serverInviteBatchCreate: Scalars['Boolean']; /** Invite a new user to the speckle server and return the invite ID */ serverInviteCreate: Scalars['Boolean']; + /** Request access to a specific stream */ + streamAccessRequestCreate: StreamAccessRequest; + /** Accept or decline a stream access request. Must be a stream owner to invoke this. */ + streamAccessRequestUse: Scalars['Boolean']; /** Creates a new stream. */ streamCreate?: Maybe; /** Deletes an existing stream. */ @@ -651,6 +655,18 @@ export type MutationServerInviteCreateArgs = { }; +export type MutationStreamAccessRequestCreateArgs = { + streamId: Scalars['String']; +}; + + +export type MutationStreamAccessRequestUseArgs = { + accept: Scalars['Boolean']; + requestId: Scalars['String']; + role?: StreamRole; +}; + + export type MutationStreamCreateArgs = { stream: StreamCreateInput; }; @@ -879,6 +895,8 @@ export type Query = { * to see it. */ stream?: Maybe; + /** Get authed user's stream access request */ + streamAccessRequest?: Maybe; /** * Look for an invitation to a stream, for the current user (authed or not). If token * isn't specified, the server will look for any valid invite. @@ -948,6 +966,11 @@ export type QueryStreamArgs = { }; +export type QueryStreamAccessRequestArgs = { + streamId: Scalars['String']; +}; + + export type QueryStreamInviteArgs = { streamId: Scalars['String']; token?: InputMaybe; @@ -1174,6 +1197,8 @@ export type Stream = { isPublic: Scalars['Boolean']; name: Scalars['String']; object?: Maybe; + /** Pending stream access requests */ + pendingAccessRequests?: Maybe>; /** Collaborators who have been invited, but not yet accepted. */ pendingCollaborators?: Maybe>; /** Your role for this stream. `null` if request is not authenticated, or the stream is not explicitly shared with you. */ @@ -1241,6 +1266,18 @@ export type StreamWebhooksArgs = { id?: InputMaybe; }; +/** Created when a user requests to become a contributor on a stream */ +export type StreamAccessRequest = { + __typename?: 'StreamAccessRequest'; + createdAt: Scalars['DateTime']; + id: Scalars['ID']; + requester: LimitedUser; + requesterId: Scalars['String']; + /** Can only be selected if authed user has proper access */ + stream: Stream; + streamId: Scalars['String']; +}; + export type StreamCollaborator = { __typename?: 'StreamCollaborator'; avatar?: Maybe; @@ -1596,6 +1633,29 @@ export type WebhookUpdateInput = { url?: InputMaybe; }; +export type GetStreamAccessRequestQueryVariables = Exact<{ + streamId: Scalars['String']; +}>; + + +export type GetStreamAccessRequestQuery = { __typename?: 'Query', streamAccessRequest?: { __typename?: 'StreamAccessRequest', id: string, streamId: string, createdAt: string } | null }; + +export type CreateStreamAccessRequestMutationVariables = Exact<{ + streamId: Scalars['String']; +}>; + + +export type CreateStreamAccessRequestMutation = { __typename?: 'Mutation', streamAccessRequestCreate: { __typename?: 'StreamAccessRequest', id: string, streamId: string, createdAt: string } }; + +export type UseStreamAccessRequestMutationVariables = Exact<{ + requestId: Scalars['String']; + accept: Scalars['Boolean']; + role?: InputMaybe; +}>; + + +export type UseStreamAccessRequestMutation = { __typename?: 'Mutation', streamAccessRequestUse: boolean }; + export type StreamWithBranchQueryVariables = Exact<{ streamId: Scalars['String']; branchName: Scalars['String']; @@ -1603,7 +1663,7 @@ export type StreamWithBranchQueryVariables = Exact<{ }>; -export type StreamWithBranchQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, branch?: { __typename?: 'Branch', id: string, name: string, description?: string | null, commits?: { __typename?: 'CommitCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Commit', id: string, authorName?: string | null, authorId?: string | null, authorAvatar?: string | null, sourceApplication?: string | null, message?: string | null, referencedObject: string, createdAt?: any | null, commentCount: number } | null> | null } | null } | null } | null }; +export type StreamWithBranchQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, branch?: { __typename?: 'Branch', id: string, name: string, description?: string | null, commits?: { __typename?: 'CommitCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Commit', id: string, authorName?: string | null, authorId?: string | null, authorAvatar?: string | null, sourceApplication?: string | null, message?: string | null, referencedObject: string, createdAt?: string | null, commentCount: number } | null> | null } | null } | null } | null }; export type BranchCreatedSubscriptionVariables = Exact<{ streamId: Scalars['String']; @@ -1612,7 +1672,7 @@ export type BranchCreatedSubscriptionVariables = Exact<{ export type BranchCreatedSubscription = { __typename?: 'Subscription', branchCreated?: Record | null }; -export type CommentFullInfoFragment = { __typename?: 'Comment', id: string, archived: boolean, authorId: string, data?: Record | null, screenshot?: string | null, createdAt?: any | null, updatedAt?: any | null, viewedAt?: any | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string, fileType: string, fileSize?: number | null }> | null }, replies?: { __typename?: 'CommentCollection', totalCount: number } | null, resources: Array<{ __typename?: 'ResourceIdentifier', resourceId: string, resourceType: ResourceType } | null> }; +export type CommentFullInfoFragment = { __typename?: 'Comment', id: string, archived: boolean, authorId: string, data?: Record | null, screenshot?: string | null, createdAt?: string | null, updatedAt?: string | null, viewedAt?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string, fileType: string, fileSize?: number | null }> | null }, replies?: { __typename?: 'CommentCollection', totalCount: number } | null, resources: Array<{ __typename?: 'ResourceIdentifier', resourceId: string, resourceType: ResourceType } | null> }; export type StreamCommitQueryQueryVariables = Exact<{ streamId: Scalars['String']; @@ -1620,11 +1680,17 @@ export type StreamCommitQueryQueryVariables = Exact<{ }>; -export type StreamCommitQueryQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, role?: string | null, commit?: { __typename?: 'Commit', id: string, message?: string | null, referencedObject: string, authorName?: string | null, authorId?: string | null, authorAvatar?: string | null, createdAt?: any | null, branchName?: string | null, sourceApplication?: string | null } | null } | null }; +export type StreamCommitQueryQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, role?: string | null, commit?: { __typename?: 'Commit', id: string, message?: string | null, referencedObject: string, authorName?: string | null, authorId?: string | null, authorAvatar?: string | null, createdAt?: string | null, branchName?: string | null, sourceApplication?: string | null } | null } | null }; -export type ActivityMainFieldsFragment = { __typename?: 'Activity', id: string, actionType: string, info: Record, userId: string, streamId?: string | null, resourceId: string, resourceType: string, time: any, message: string }; +export type BasicStreamAccessRequestFieldsFragment = { __typename?: 'StreamAccessRequest', id: string, streamId: string, createdAt: string }; -export type LimitedCommitActivityFieldsFragment = { __typename?: 'Activity', id: string, info: Record, time: any, userId: string, message: string }; +export type FullStreamAccessRequestFieldsFragment = { __typename?: 'StreamAccessRequest', id: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } }; + +export type ActivityMainFieldsFragment = { __typename?: 'Activity', id: string, actionType: string, info: Record, userId: string, streamId?: string | null, resourceId: string, resourceType: string, time: string, message: string }; + +export type LimitedCommitActivityFieldsFragment = { __typename?: 'Activity', id: string, info: Record, time: string, userId: string, message: string }; + +export type StreamPendingAccessRequestsFragment = { __typename?: 'Stream', pendingAccessRequests?: Array<{ __typename?: 'StreamAccessRequest', id: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } }> | null }; export type LimitedUserFieldsFragment = { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null }; @@ -1734,30 +1800,30 @@ export type StreamCommitsQueryVariables = Exact<{ }>; -export type StreamCommitsQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, role?: string | null, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, authorId?: string | null, authorName?: string | null, authorAvatar?: string | null, createdAt?: any | null, message?: string | null, referencedObject: string, branchName?: string | null, sourceApplication?: string | null } | null> | null } | null } | null }; +export type StreamCommitsQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, role?: string | null, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, authorId?: string | null, authorName?: string | null, authorAvatar?: string | null, createdAt?: string | null, message?: string | null, referencedObject: string, branchName?: string | null, sourceApplication?: string | null } | null> | null } | null } | null }; export type StreamsQueryVariables = Exact<{ cursor?: InputMaybe; }>; -export type StreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: any, updatedAt: any, commentCount: number, favoritedDate?: any | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: any | null, message?: string | null, authorId?: string | null, branchName?: string | null, authorName?: string | null, authorAvatar?: string | null, referencedObject: string } | null> | null } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null } | null }; +export type StreamsQuery = { __typename?: 'Query', streams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, createdAt?: string | null, message?: string | null, authorId?: string | null, branchName?: string | null, authorName?: string | null, authorAvatar?: string | null, referencedObject: string } | null> | null } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null } | null }; -export type CommonStreamFieldsFragment = { __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: any, updatedAt: any, commentCount: number, favoritedDate?: any | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }; +export type CommonStreamFieldsFragment = { __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }; export type StreamQueryVariables = Exact<{ id: Scalars['String']; }>; -export type StreamQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: any, updatedAt: any, commentCount: number, favoritedDate?: any | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null } | null }; +export type StreamQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null } | null }; export type StreamWithCollaboratorsQueryVariables = Exact<{ id: Scalars['String']; }>; -export type StreamWithCollaboratorsQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, isPublic: boolean, role?: string | null, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, role: string, company?: string | null, avatar?: string | null }>, pendingCollaborators?: Array<{ __typename?: 'PendingStreamCollaborator', title: string, inviteId: string, role: string, user?: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } | null }> | null } | null }; +export type StreamWithCollaboratorsQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, isPublic: boolean, role?: string | null, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, role: string, company?: string | null, avatar?: string | null }>, pendingCollaborators?: Array<{ __typename?: 'PendingStreamCollaborator', title: string, inviteId: string, role: string, user?: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } | null }> | null, pendingAccessRequests?: Array<{ __typename?: 'StreamAccessRequest', id: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } }> | null } | null }; export type StreamWithActivityQueryVariables = Exact<{ id: Scalars['String']; @@ -1765,7 +1831,7 @@ export type StreamWithActivityQueryVariables = Exact<{ }>; -export type StreamWithActivityQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, createdAt: any, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null, activity?: { __typename?: 'ActivityCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Activity', id: string, actionType: string, info: Record, userId: string, streamId?: string | null, resourceId: string, resourceType: string, time: any, message: string } | null> | null } | null } | null }; +export type StreamWithActivityQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, createdAt: string, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null, activity?: { __typename?: 'ActivityCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Activity', id: string, actionType: string, info: Record, userId: string, streamId?: string | null, resourceId: string, resourceType: string, time: string, message: string } | null> | null } | null } | null }; export type LeaveStreamMutationVariables = Exact<{ streamId: Scalars['String']; @@ -1796,24 +1862,24 @@ export type StreamBranchFirstCommitQueryVariables = Exact<{ export type StreamBranchFirstCommitQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, branch?: { __typename?: 'Branch', commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, referencedObject: string } | null> | null } | null } | null } | null }; -export type CommonUserFieldsFragment = { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null }; +export type CommonUserFieldsFragment = { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: string | null } | null> | null } | null }; export type UserFavoriteStreamsQueryVariables = Exact<{ cursor?: InputMaybe; }>; -export type UserFavoriteStreamsQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, favoriteStreams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: any, updatedAt: any, commentCount: number, favoritedDate?: any | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null } | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; +export type UserFavoriteStreamsQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, favoriteStreams?: { __typename?: 'StreamCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Stream', id: string, name: string, description?: string | null, role?: string | null, isPublic: boolean, createdAt: string, updatedAt: string, commentCount: number, favoritedDate?: string | null, favoritesCount: number, collaborators: Array<{ __typename?: 'StreamCollaborator', id: string, name: string, company?: string | null, avatar?: string | null, role: string }>, commits?: { __typename?: 'CommitCollection', totalCount: number } | null, branches?: { __typename?: 'BranchCollection', totalCount: number } | null }> | null } | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: string | null } | null> | null } | null } | null }; export type MainUserDataQueryVariables = Exact<{ [key: string]: never; }>; -export type MainUserDataQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; +export type MainUserDataQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: string | null } | null> | null } | null } | null }; export type ExtraUserDataQueryVariables = Exact<{ [key: string]: never; }>; -export type ExtraUserDataQuery = { __typename?: 'Query', user?: { __typename?: 'User', totalOwnedStreamsFavorites: number, id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: any | null } | null> | null } | null } | null }; +export type ExtraUserDataQuery = { __typename?: 'Query', user?: { __typename?: 'User', totalOwnedStreamsFavorites: number, id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, hasPendingVerification?: boolean | null, profiles?: Record | null, role?: string | null, streams?: { __typename?: 'StreamCollection', totalCount: number } | null, commits?: { __typename?: 'CommitCollectionUser', totalCount: number, items?: Array<{ __typename?: 'CommitCollectionUserNode', id: string, createdAt?: string | null } | null> | null } | null } | null }; export type UserSearchQueryVariables = Exact<{ query: Scalars['String']; @@ -1844,7 +1910,7 @@ export type UserTimelineQueryVariables = Exact<{ }>; -export type UserTimelineQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, timeline?: { __typename?: 'ActivityCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Activity', id: string, actionType: string, info: Record, userId: string, streamId?: string | null, resourceId: string, resourceType: string, time: any, message: string } | null> | null } | null } | null }; +export type UserTimelineQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, timeline?: { __typename?: 'ActivityCollection', totalCount: number, cursor?: string | null, items?: Array<{ __typename?: 'Activity', id: string, actionType: string, info: Record, userId: string, streamId?: string | null, resourceId: string, resourceType: string, time: string, message: string } | null> | null } | null } | null }; export type ValidatePasswordStrengthQueryVariables = Exact<{ pwd: Scalars['String']; @@ -1863,12 +1929,12 @@ export type RequestVerificationMutationVariables = Exact<{ [key: string]: never; export type RequestVerificationMutation = { __typename?: 'Mutation', requestVerification: boolean }; -export type UserQueryVariables = Exact<{ +export type UserByIdQueryVariables = Exact<{ id: Scalars['String']; }>; -export type UserQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, profiles?: Record | null, role?: string | null } | null }; +export type UserByIdQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, email?: string | null, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null, profiles?: Record | null, role?: string | null } | null }; export type UserProfileQueryVariables = Exact<{ id: Scalars['String']; @@ -1890,7 +1956,7 @@ export type WebhooksQueryVariables = Exact<{ }>; -export type WebhooksQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, role?: string | null, webhooks?: { __typename?: 'WebhookCollection', items?: Array<{ __typename?: 'Webhook', id: string, streamId: string, url: string, description?: string | null, triggers: Array, enabled?: boolean | null, history?: { __typename?: 'WebhookEventCollection', items?: Array<{ __typename?: 'WebhookEvent', status: number, statusInfo: string, lastUpdate: any } | null> | null } | null } | null> | null } | null } | null }; +export type WebhooksQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', id: string, name: string, role?: string | null, webhooks?: { __typename?: 'WebhookCollection', items?: Array<{ __typename?: 'Webhook', id: string, streamId: string, url: string, description?: string | null, triggers: Array, enabled?: boolean | null, history?: { __typename?: 'WebhookEventCollection', items?: Array<{ __typename?: 'WebhookEvent', status: number, statusInfo: string, lastUpdate: string } | null> | null } | null } | null> | null } | null } | null }; export const CommentFullInfo = gql` fragment CommentFullInfo on Comment { @@ -1943,13 +2009,11 @@ export const LimitedCommitActivityFields = gql` message } `; -export const StreamCollaboratorFields = gql` - fragment StreamCollaboratorFields on StreamCollaborator { +export const BasicStreamAccessRequestFields = gql` + fragment BasicStreamAccessRequestFields on StreamAccessRequest { id - name - role - company - avatar + streamId + createdAt } `; export const LimitedUserFields = gql` @@ -1962,6 +2026,30 @@ export const LimitedUserFields = gql` verified } `; +export const FullStreamAccessRequestFields = gql` + fragment FullStreamAccessRequestFields on StreamAccessRequest { + ...BasicStreamAccessRequestFields + requester { + ...LimitedUserFields + } +} + `; +export const StreamPendingAccessRequests = gql` + fragment StreamPendingAccessRequests on Stream { + pendingAccessRequests { + ...FullStreamAccessRequestFields + } +} + `; +export const StreamCollaboratorFields = gql` + fragment StreamCollaboratorFields on StreamCollaborator { + id + name + role + company + avatar +} + `; export const UsersOwnInviteFields = gql` fragment UsersOwnInviteFields on PendingStreamCollaborator { id @@ -1973,7 +2061,7 @@ export const UsersOwnInviteFields = gql` ...LimitedUserFields } } - ${LimitedUserFields}`; + `; export const ServerInfoBlobSizeFields = gql` fragment ServerInfoBlobSizeFields on ServerInfo { blobSizeLimitBytes @@ -2059,6 +2147,25 @@ export const CommonUserFields = gql` } } `; +export const GetStreamAccessRequest = gql` + query GetStreamAccessRequest($streamId: String!) { + streamAccessRequest(streamId: $streamId) { + ...BasicStreamAccessRequestFields + } +} + ${BasicStreamAccessRequestFields}`; +export const CreateStreamAccessRequest = gql` + mutation CreateStreamAccessRequest($streamId: String!) { + streamAccessRequestCreate(streamId: $streamId) { + ...BasicStreamAccessRequestFields + } +} + ${BasicStreamAccessRequestFields}`; +export const UseStreamAccessRequest = gql` + mutation UseStreamAccessRequest($requestId: String!, $accept: Boolean!, $role: StreamRole = STREAM_CONTRIBUTOR) { + streamAccessRequestUse(requestId: $requestId, accept: $accept, role: $role) +} + `; export const StreamWithBranch = gql` query StreamWithBranch($streamId: String!, $branchName: String!, $cursor: String) { stream(id: $streamId) { @@ -2118,14 +2225,16 @@ export const StreamInvite = gql` ...UsersOwnInviteFields } } - ${UsersOwnInviteFields}`; + ${UsersOwnInviteFields} +${LimitedUserFields}`; export const UserStreamInvites = gql` query UserStreamInvites { streamInvites { ...UsersOwnInviteFields } } - ${UsersOwnInviteFields}`; + ${UsersOwnInviteFields} +${LimitedUserFields}`; export const UseStreamInvite = gql` mutation UseStreamInvite($accept: Boolean!, $streamId: String!, $token: String!) { streamInviteUse(accept: $accept, streamId: $streamId, token: $token) @@ -2299,10 +2408,15 @@ export const StreamWithCollaborators = gql` ...LimitedUserFields } } + pendingAccessRequests { + ...FullStreamAccessRequestFields + } } } ${StreamCollaboratorFields} -${LimitedUserFields}`; +${LimitedUserFields} +${FullStreamAccessRequestFields} +${BasicStreamAccessRequestFields}`; export const StreamWithActivity = gql` query StreamWithActivity($id: String!, $cursor: DateTime) { stream(id: $id) { @@ -2484,8 +2598,8 @@ export const RequestVerification = gql` requestVerification } `; -export const User = gql` - query User($id: String!) { +export const UserById = gql` + query UserById($id: String!) { user(id: $id) { id email @@ -2564,20 +2678,26 @@ export const Webhooks = gql` export const CommentFullInfoFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommentFullInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"archived"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"attachments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"fileType"}},{"kind":"Field","name":{"kind":"Name","value":"fileSize"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"replies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"resources"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resourceId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"viewedAt"}}]}}]} as unknown as DocumentNode; export const ActivityMainFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ActivityMainFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Activity"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"actionType"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceType"}},{"kind":"Field","name":{"kind":"Name","value":"time"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]} as unknown as DocumentNode; export const LimitedCommitActivityFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedCommitActivityFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Activity"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"time"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]} as unknown as DocumentNode; -export const StreamCollaboratorFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"StreamCollaboratorFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]} as unknown as DocumentNode; +export const BasicStreamAccessRequestFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const LimitedUserFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]} as unknown as DocumentNode; -export const UsersOwnInviteFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UsersOwnInviteFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingStreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"streamName"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserFields"}}]}}]}},...LimitedUserFieldsFragmentDoc.definitions]} as unknown as DocumentNode; +export const FullStreamAccessRequestFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserFields"}}]}}]}}]} as unknown as DocumentNode; +export const StreamPendingAccessRequestsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"StreamPendingAccessRequests"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pendingAccessRequests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullStreamAccessRequestFields"}}]}}]}}]} as unknown as DocumentNode; +export const StreamCollaboratorFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"StreamCollaboratorFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]} as unknown as DocumentNode; +export const UsersOwnInviteFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UsersOwnInviteFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingStreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"streamName"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserFields"}}]}}]}}]} as unknown as DocumentNode; export const ServerInfoBlobSizeFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerInfoBlobSizeFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"blobSizeLimitBytes"}}]}}]} as unknown as DocumentNode; export const MainServerInfoFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"MainServerInfoFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"adminContact"}},{"kind":"Field","name":{"kind":"Name","value":"canonicalUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfService"}},{"kind":"Field","name":{"kind":"Name","value":"inviteOnly"}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]} as unknown as DocumentNode; export const ServerInfoRolesFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerInfoRolesFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"roles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"resourceTarget"}}]}}]}}]} as unknown as DocumentNode; export const ServerInfoScopesFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerInfoScopesFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scopes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]} as unknown as DocumentNode; export const CommonStreamFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommonStreamFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Stream"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"commentCount"}},{"kind":"Field","name":{"kind":"Name","value":"collaborators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"branches"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"favoritedDate"}},{"kind":"Field","name":{"kind":"Name","value":"favoritesCount"}}]}}]} as unknown as DocumentNode; export const CommonUserFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommonUserFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"hasPendingVerification"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"streams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},...BasicStreamAccessRequestFieldsFragmentDoc.definitions]} as unknown as DocumentNode; +export const CreateStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequestCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},...BasicStreamAccessRequestFieldsFragmentDoc.definitions]} as unknown as DocumentNode; +export const UseStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UseStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"requestId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"accept"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"role"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamRole"}},"defaultValue":{"kind":"EnumValue","value":"STREAM_CONTRIBUTOR"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequestUse"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"requestId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"requestId"}}},{"kind":"Argument","name":{"kind":"Name","value":"accept"},"value":{"kind":"Variable","name":{"kind":"Name","value":"accept"}}},{"kind":"Argument","name":{"kind":"Name","value":"role"},"value":{"kind":"Variable","name":{"kind":"Name","value":"role"}}}]}]}}]} as unknown as DocumentNode; export const StreamWithBranchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamWithBranch"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"branchName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"branch"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"branchName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"4"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"authorAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"commentCount"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const BranchCreatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"BranchCreated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchCreated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; export const StreamCommitQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamCommitQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"commit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"authorAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"branchName"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}}]}}]}}]}}]} as unknown as DocumentNode; -export const StreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInvite"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UsersOwnInviteFields"}}]}}]}},...UsersOwnInviteFieldsFragmentDoc.definitions]} as unknown as DocumentNode; -export const UserStreamInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserStreamInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UsersOwnInviteFields"}}]}}]}},...UsersOwnInviteFieldsFragmentDoc.definitions]} as unknown as DocumentNode; +export const StreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInvite"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UsersOwnInviteFields"}}]}}]}},...UsersOwnInviteFieldsFragmentDoc.definitions,...LimitedUserFieldsFragmentDoc.definitions]} as unknown as DocumentNode; +export const UserStreamInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserStreamInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UsersOwnInviteFields"}}]}}]}},...UsersOwnInviteFieldsFragmentDoc.definitions,...LimitedUserFieldsFragmentDoc.definitions]} as unknown as DocumentNode; export const UseStreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UseStreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"accept"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInviteUse"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"accept"},"value":{"kind":"Variable","name":{"kind":"Name","value":"accept"}}},{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; export const CancelStreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelStreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInviteCancel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}]}]}}]} as unknown as DocumentNode; export const DeleteInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}]}]}}]} as unknown as DocumentNode; @@ -2592,7 +2712,7 @@ export const ServerInfoBlobSizeLimitDocument = {"kind":"Document","definitions": export const StreamCommitsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamCommits"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"branchName"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const StreamsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Streams"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streams"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"commentCount"}},{"kind":"Field","name":{"kind":"Name","value":"collaborators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"authorId"}},{"kind":"Field","name":{"kind":"Name","value":"branchName"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"branches"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"favoritedDate"}},{"kind":"Field","name":{"kind":"Name","value":"favoritesCount"}}]}}]}}]}}]} as unknown as DocumentNode; export const StreamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Stream"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CommonStreamFields"}}]}}]}},...CommonStreamFieldsFragmentDoc.definitions]} as unknown as DocumentNode; -export const StreamWithCollaboratorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamWithCollaborators"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"collaborators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"StreamCollaboratorFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pendingCollaborators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserFields"}}]}}]}}]}}]}},...StreamCollaboratorFieldsFragmentDoc.definitions,...LimitedUserFieldsFragmentDoc.definitions]} as unknown as DocumentNode; +export const StreamWithCollaboratorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamWithCollaborators"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"collaborators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"StreamCollaboratorFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pendingCollaborators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserFields"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pendingAccessRequests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullStreamAccessRequestFields"}}]}}]}}]}},...StreamCollaboratorFieldsFragmentDoc.definitions,...LimitedUserFieldsFragmentDoc.definitions,...FullStreamAccessRequestFieldsFragmentDoc.definitions,...BasicStreamAccessRequestFieldsFragmentDoc.definitions]} as unknown as DocumentNode; export const StreamWithActivityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"StreamWithActivity"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"commits"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"branches"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"activity"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ActivityMainFields"}}]}}]}}]}}]}},...ActivityMainFieldsFragmentDoc.definitions]} as unknown as DocumentNode; export const LeaveStreamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"LeaveStream"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamLeave"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; export const UpdateStreamPermissionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateStreamPermission"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"params"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamUpdatePermissionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamUpdatePermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permissionParams"},"value":{"kind":"Variable","name":{"kind":"Name","value":"params"}}}]}]}}]} as unknown as DocumentNode; @@ -2608,7 +2728,7 @@ export const UserTimelineDocument = {"kind":"Document","definitions":[{"kind":"O export const ValidatePasswordStrengthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidatePasswordStrength"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pwd"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userPwdStrength"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pwd"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pwd"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"feedback"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"suggestions"}}]}}]}}]}}]} as unknown as DocumentNode; export const EmailVerificationBannerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EmailVerificationBannerState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"hasPendingVerification"}}]}}]}}]} as unknown as DocumentNode; export const RequestVerificationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RequestVerification"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestVerification"}}]}}]} as unknown as DocumentNode; -export const UserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"User"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; +export const UserByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"profiles"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; export const UserProfileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserProfile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]}}]} as unknown as DocumentNode; export const WebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"webhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"webhookId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"webhooks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"webhookId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"triggers"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"statusInfo"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const WebhooksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"webhooks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"webhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"triggers"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"50"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"statusInfo"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdate"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/packages/frontend/src/graphql/streams.js b/packages/frontend/src/graphql/streams.js index 8ca8c6cd6..443e213cd 100644 --- a/packages/frontend/src/graphql/streams.js +++ b/packages/frontend/src/graphql/streams.js @@ -1,3 +1,4 @@ +import { fullStreamAccessRequestFieldsFragment } from '@/graphql/fragments/accessRequests' import { activityMainFieldsFragment } from '@/graphql/fragments/activity' import { limitedUserFieldsFragment, @@ -70,10 +71,14 @@ export const streamWithCollaboratorsQuery = gql` ...LimitedUserFields } } + pendingAccessRequests { + ...FullStreamAccessRequestFields + } } } ${limitedUserFieldsFragment} ${streamCollaboratorFieldsFragment} + ${fullStreamAccessRequestFieldsFragment} ` export const streamWithActivityQuery = gql` diff --git a/packages/frontend/src/graphql/userById.gql b/packages/frontend/src/graphql/userById.gql index 44e717ff2..cde75676b 100644 --- a/packages/frontend/src/graphql/userById.gql +++ b/packages/frontend/src/graphql/userById.gql @@ -1,4 +1,4 @@ -query User($id: String!) { +query UserById($id: String!) { user(id: $id) { id email diff --git a/packages/frontend/src/helpers/mainConstants.js b/packages/frontend/src/helpers/mainConstants.js deleted file mode 100644 index 7670c98fd..000000000 --- a/packages/frontend/src/helpers/mainConstants.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Speckle role constants - */ -export const Roles = Object.freeze({ - Stream: { - Owner: 'stream:owner', - Contributor: 'stream:contributor', - Reviewer: 'stream:reviewer' - }, - Server: { - Admin: 'server:admin', - User: 'server:user', - ArchivedUser: 'server:archived-user' - } -}) - -/** - * Keys for values stored in localStorage - */ -export const LocalStorageKeys = Object.freeze({ - AuthToken: 'AuthToken', - RefreshToken: 'RefreshToken', - Uuid: 'uuid', - ShouldRedirectTo: 'shouldRedirectTo' -}) diff --git a/packages/frontend/src/helpers/mainConstants.ts b/packages/frontend/src/helpers/mainConstants.ts new file mode 100644 index 000000000..96699a7e9 --- /dev/null +++ b/packages/frontend/src/helpers/mainConstants.ts @@ -0,0 +1,46 @@ +import { StreamRole } from '@/graphql/generated/graphql' + +/** + * Speckle role constants + */ +export const Roles = Object.freeze({ + Stream: { + Owner: 'stream:owner', + Contributor: 'stream:contributor', + Reviewer: 'stream:reviewer' + }, + Server: { + Admin: 'server:admin', + User: 'server:user', + ArchivedUser: 'server:archived-user' + } +}) + +export type ServerRoles = typeof Roles['Server'][keyof typeof Roles['Server']] +export type StreamRoles = typeof Roles['Stream'][keyof typeof Roles['Stream']] + +/** + * Keys for values stored in localStorage + */ +export const LocalStorageKeys = Object.freeze({ + AuthToken: 'AuthToken', + RefreshToken: 'RefreshToken', + Uuid: 'uuid', + ShouldRedirectTo: 'shouldRedirectTo' +}) + +/** + * Our GQL schema has a StreamRoles enum that unfortunately can't have the same exact values as our roles constants, because + * we can't use colons (:) there. So you can use this function to map from our constant value to the GQL one. + */ +export function streamRoleToGraphQLEnum(role: StreamRoles): StreamRole { + switch (role) { + case Roles.Stream.Owner: + return StreamRole.StreamOwner + case Roles.Stream.Reviewer: + return StreamRole.StreamReviewer + case Roles.Stream.Contributor: + default: + return StreamRole.StreamContributor + } +} diff --git a/packages/frontend/src/helpers/typeHelpers.ts b/packages/frontend/src/helpers/typeHelpers.ts index 5744b125b..bdc679904 100644 --- a/packages/frontend/src/helpers/typeHelpers.ts +++ b/packages/frontend/src/helpers/typeHelpers.ts @@ -1,4 +1,5 @@ import { ReactiveVar } from '@apollo/client/core' +import { isUndefined } from 'lodash' import Vue, { VueConstructor } from 'vue' export type Nullable = T | null @@ -7,6 +8,16 @@ export type Optional = T | undefined export type MaybeFalsy = T | null | undefined | false | '' | 0 +export type MaybeNullOrUndefined = T | null | undefined + +export type MaybeAsync = T | Promise + +/** + * In TS undefined !== void, so use this type guard to check for both + */ +export const isUndefinedOrVoid = (val: unknown): val is void | undefined => + isUndefined(val) + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type GetReactiveVarType> = V extends ReactiveVar< infer T diff --git a/packages/frontend/src/helpers/vuetifyHelpers.ts b/packages/frontend/src/helpers/vuetifyHelpers.ts index 74c0bb3e6..aeb2fb7d7 100644 --- a/packages/frontend/src/helpers/vuetifyHelpers.ts +++ b/packages/frontend/src/helpers/vuetifyHelpers.ts @@ -17,5 +17,6 @@ export type VFormInstance = CombinedVueInstance< validate(): boolean }, unknown, + unknown, unknown > diff --git a/packages/frontend/src/main/components/comments/CommentThreadReplyAttachments.vue b/packages/frontend/src/main/components/comments/CommentThreadReplyAttachments.vue index 1cd782bd9..b2059a0be 100644 --- a/packages/frontend/src/main/components/comments/CommentThreadReplyAttachments.vue +++ b/packages/frontend/src/main/components/comments/CommentThreadReplyAttachments.vue @@ -3,7 +3,7 @@ - diff --git a/packages/frontend/src/main/components/common/UserAvatarIcon.vue b/packages/frontend/src/main/components/common/UserAvatarIcon.vue index 22153ffcb..c4bda1fb4 100644 --- a/packages/frontend/src/main/components/common/UserAvatarIcon.vue +++ b/packages/frontend/src/main/components/common/UserAvatarIcon.vue @@ -17,6 +17,9 @@ export default { required: true }, avatar: { + /** + * @type {import('vue').PropType} + */ type: String, default: null } diff --git a/packages/frontend/src/main/components/common/layout/BasicPanel.vue b/packages/frontend/src/main/components/common/layout/BasicPanel.vue new file mode 100644 index 000000000..82fc731cf --- /dev/null +++ b/packages/frontend/src/main/components/common/layout/BasicPanel.vue @@ -0,0 +1,3 @@ + diff --git a/packages/frontend/src/main/components/common/layout/RoundedButtonList.vue b/packages/frontend/src/main/components/common/layout/RoundedButtonList.vue new file mode 100644 index 000000000..f9558d0de --- /dev/null +++ b/packages/frontend/src/main/components/common/layout/RoundedButtonList.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/frontend/src/main/components/common/layout/rounded-button-list/RoundedButtonListItem.vue b/packages/frontend/src/main/components/common/layout/rounded-button-list/RoundedButtonListItem.vue new file mode 100644 index 000000000..77e1723db --- /dev/null +++ b/packages/frontend/src/main/components/common/layout/rounded-button-list/RoundedButtonListItem.vue @@ -0,0 +1,69 @@ + + diff --git a/packages/frontend/src/main/components/feed/FeedTimeline.vue b/packages/frontend/src/main/components/feed/FeedTimeline.vue index e44440a51..b5170fd81 100644 --- a/packages/frontend/src/main/components/feed/FeedTimeline.vue +++ b/packages/frontend/src/main/components/feed/FeedTimeline.vue @@ -82,6 +82,7 @@ import { UserTimelineDocument } from '@/graphql/generated/graphql' import { useQuery } from '@vue/apollo-composable' import { computed } from 'vue' import { AppLocalStorage } from '@/utils/localStorage' +import { SKIPPABLE_ACTION_TYPES } from '@/main/lib/feed/helpers/activityStream' export default { name: 'FeedTimeline', @@ -112,7 +113,7 @@ export default { const data = timelineResult.value if (!data) return [] - const skippableActionTypes = ['stream_invite_sent', 'stream_invite_declined'] + const skippableActionTypes = SKIPPABLE_ACTION_TYPES const groupedTimeline = data.user.timeline.items.reduce(function (prev, curr) { if (skippableActionTypes.includes(curr.actionType)) { return prev diff --git a/packages/frontend/src/main/components/stream/StreamAccessRequestBanner.vue b/packages/frontend/src/main/components/stream/StreamAccessRequestBanner.vue new file mode 100644 index 000000000..1e2187322 --- /dev/null +++ b/packages/frontend/src/main/components/stream/StreamAccessRequestBanner.vue @@ -0,0 +1,176 @@ + + + diff --git a/packages/frontend/src/main/components/stream/StreamActivity.vue b/packages/frontend/src/main/components/stream/StreamActivity.vue index d93abd99f..7f150118f 100644 --- a/packages/frontend/src/main/components/stream/StreamActivity.vue +++ b/packages/frontend/src/main/components/stream/StreamActivity.vue @@ -41,6 +41,7 @@ import { StreamWithActivityDocument } from '@/graphql/generated/graphql' import { useQuery } from '@vue/apollo-composable' import { useRoute } from '@/main/lib/core/composables/router' import { computed } from 'vue' +import { SKIPPABLE_ACTION_TYPES } from '@/main/lib/feed/helpers/activityStream' export default { name: 'StreamActivity', @@ -61,7 +62,7 @@ export default { })) const stream = computed(() => result.value?.stream || null) - const skippableActionTypes = ['stream_invite_sent', 'stream_invite_declined'] + const skippableActionTypes = SKIPPABLE_ACTION_TYPES const groupedActivity = computed(() => (stream.value?.activity?.items || []).reduce(function (prev, curr) { if (skippableActionTypes.includes(curr.actionType)) { diff --git a/packages/frontend/src/main/components/user/EmailVerificationBanner.vue b/packages/frontend/src/main/components/user/EmailVerificationBanner.vue index 895bf2df9..3da8d0e82 100644 --- a/packages/frontend/src/main/components/user/EmailVerificationBanner.vue +++ b/packages/frontend/src/main/components/user/EmailVerificationBanner.vue @@ -1,7 +1,7 @@