From f074be89aaa66d83f032ec5172ef69d25f46724e Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 20 Jun 2024 13:11:05 +0300 Subject: [PATCH 01/18] fix(fe2): error msg templating + Filters undefined error fix (#2411) * fix(fe2): error msg templating + Filters undefined error fix * fixx * fixx --- .../components/projects/Dashboard.vue | 11 ---- .../viewer/compare-changes/Panel.vue | 2 +- .../components/viewer/explorer/Explorer.vue | 2 +- .../components/viewer/explorer/Filters.vue | 30 +++++------ .../components/viewer/explorer/TreeItem.vue | 2 +- .../components/viewer/selection/Object.vue | 2 +- .../lib/common/helpers/sceneExplorer.ts | 15 ------ .../frontend-2/lib/core/composables/error.ts | 15 ++++-- .../frontend-2/lib/core/configs/apollo.ts | 11 ++-- .../lib/core/helpers/observability.ts | 50 +++++++++++-------- .../frontend-2/lib/object-sidebar/helpers.ts | 2 +- .../lib/viewer/composables/setup.ts | 2 +- .../lib/viewer/composables/setup/selection.ts | 2 +- .../frontend-2/lib/viewer/composables/ui.ts | 2 +- .../lib/viewer/composables/viewer.ts | 2 +- .../lib/viewer/helpers/sceneExplorer.ts | 29 +++++++++++ packages/frontend-2/plugins/001-logger.ts | 23 ++++----- packages/frontend-2/plugins/002-rum.ts | 20 ++++++-- 18 files changed, 126 insertions(+), 96 deletions(-) delete mode 100644 packages/frontend-2/lib/common/helpers/sceneExplorer.ts create mode 100644 packages/frontend-2/lib/viewer/helpers/sceneExplorer.ts diff --git a/packages/frontend-2/components/projects/Dashboard.vue b/packages/frontend-2/components/projects/Dashboard.vue index 52f4c5a83..e294c1926 100644 --- a/packages/frontend-2/components/projects/Dashboard.vue +++ b/packages/frontend-2/components/projects/Dashboard.vue @@ -18,10 +18,6 @@ -
- Test error -
-
([ } ]) -const route = useRoute() const { activeUser, isGuest } = useActiveUser() const { triggerNotification } = useGlobalToast() const areQueriesLoading = useQueryLoading() @@ -160,8 +155,6 @@ const { onResult: onUserProjectsUpdate } = useSubscription( onUserProjectsUpdateSubscription ) -const showErrorTest = computed(() => route.query.showErrorButton === '1') - const projects = computed(() => projectsPanelResult.value?.activeUser?.projects) const showEmptyState = computed(() => { const isFiltering = @@ -340,8 +333,4 @@ const clearSearch = () => { selectedRoles.value = [] updateSearchImmediately() } - -const testError = () => { - throw new Error('what duhh hell') -} diff --git a/packages/frontend-2/components/viewer/compare-changes/Panel.vue b/packages/frontend-2/components/viewer/compare-changes/Panel.vue index 725aab0c2..dccf00e71 100644 --- a/packages/frontend-2/components/viewer/compare-changes/Panel.vue +++ b/packages/frontend-2/components/viewer/compare-changes/Panel.vue @@ -66,7 +66,7 @@ import { ChevronLeftIcon } from '@heroicons/vue/24/solid' import { VisualDiffMode } from '@speckle/viewer' import { useInjectedViewerState } from '~~/lib/viewer/composables/setup' import { uniqBy, debounce } from 'lodash-es' -import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer' +import type { SpeckleObject } from '~~/lib/viewer/helpers/sceneExplorer' import { useMixpanel } from '~~/lib/core/composables/mp' defineEmits<{ diff --git a/packages/frontend-2/components/viewer/explorer/Explorer.vue b/packages/frontend-2/components/viewer/explorer/Explorer.vue index db9277dc1..0cc816be5 100644 --- a/packages/frontend-2/components/viewer/explorer/Explorer.vue +++ b/packages/frontend-2/components/viewer/explorer/Explorer.vue @@ -75,7 +75,7 @@ import { CodeBracketIcon } from '@heroicons/vue/24/solid' import { ViewerEvent } from '@speckle/viewer' -import type { ExplorerNode } from '~~/lib/common/helpers/sceneExplorer' +import type { ExplorerNode } from '~~/lib/viewer/helpers/sceneExplorer' import { useInjectedViewer, useInjectedViewerLoadedResources, diff --git a/packages/frontend-2/components/viewer/explorer/Filters.vue b/packages/frontend-2/components/viewer/explorer/Filters.vue index f73b27bf9..2c1cd40f7 100644 --- a/packages/frontend-2/components/viewer/explorer/Filters.vue +++ b/packages/frontend-2/components/viewer/explorer/Filters.vue @@ -82,11 +82,11 @@
@@ -95,13 +95,13 @@ diff --git a/packages/frontend-2/components/viewer/explorer/TreeItem.vue b/packages/frontend-2/components/viewer/explorer/TreeItem.vue index df2ae82c3..d26d58942 100644 --- a/packages/frontend-2/components/viewer/explorer/TreeItem.vue +++ b/packages/frontend-2/components/viewer/explorer/TreeItem.vue @@ -95,10 +95,13 @@
-
+
-
+
props.treeItem.atomic === true) -const speckleData = props.treeItem?.raw as SpeckleObject -const rawSpeckleData = props.treeItem?.raw as SpeckleObject +const isAtomic = computed(() => props.treeItem.rawNode.atomic === true) +const rawSpeckleData = computed(() => props.treeItem?.rawNode.raw as SpeckleObject) +const speckleData = rawSpeckleData function getNestedModelHeader(name: string): string { const parts = name.split('/') @@ -196,7 +203,9 @@ function getNestedModelHeader(name: string): string { } const headerAndSubheader = computed(() => { - const { header, subheader } = getHeaderAndSubheaderForSpeckleObject(rawSpeckleData) + const { header, subheader } = getHeaderAndSubheaderForSpeckleObject( + rawSpeckleData.value + ) return { header: getNestedModelHeader(header), subheader @@ -204,35 +213,45 @@ const headerAndSubheader = computed(() => { }) const childrenLength = computed(() => { - if (rawSpeckleData.elements && Array.isArray(rawSpeckleData.elements)) - return rawSpeckleData.elements.length - if (rawSpeckleData.children && Array.isArray(rawSpeckleData.children)) - return rawSpeckleData.children.length + if (rawSpeckleData.value.elements && Array.isArray(rawSpeckleData.value.elements)) + return rawSpeckleData.value.elements.length + if (rawSpeckleData.value.children && Array.isArray(rawSpeckleData.value.children)) + return rawSpeckleData.value.children.length return 0 }) const isSingleCollection = computed(() => { return ( - isNonEmptyObjectArray(speckleData.children) || - isNonEmptyObjectArray(speckleData.elements) + isNonEmptyObjectArray(speckleData.value.children) || + isNonEmptyObjectArray(speckleData.value.elements) ) }) const singleCollectionItems = computed(() => { - const treeItems = props.treeItem.children.filter( + const treeItems = props.treeItem.rawNode.children.filter( (child) => !!child.raw?.id && isAllowedType(child) // filter out random tree children (no id means they're not actual objects) ) // Handle the case of a wall, roof or other atomic objects that have nested children - if (isNonEmptyObjectArray(speckleData.elements) && isAtomic.value) { + if (isNonEmptyObjectArray(speckleData.value.elements) && isAtomic.value) { // We need to filter out children that are not direct descendants of `elements` // Note: this is a current assumption convention. - const ids = (speckleData.elements as SpeckleReference[]).map( + const ids = (speckleData.value.elements as SpeckleReference[]).map( (obj) => obj.referencedId ) - return treeItems.filter((item) => ids.includes(item.raw?.id as string)) + return treeItems + .filter((item) => ids.includes(item.raw?.id as string)) + .map( + (i): TreeItemComponentModel => ({ + rawNode: i + }) + ) } - return treeItems + return treeItems.map( + (i): TreeItemComponentModel => ({ + rawNode: i + }) + ) }) const itemCount = ref(10) @@ -245,16 +264,16 @@ const singleCollectionItemsPaginated = computed(() => { // object { @boat: [obj, obj, obj], @harbour: [obj, obj, obj], etc. } // @boat and @harbour would ideally be model collections, but, alas, connectors don't have that yet. const arrayCollections = computed(() => { - const arr = [] as ExplorerNode[] + const arr = [] as TreeItemComponentModel[] for (const k of Object.keys(rawSpeckleData)) { if (k === 'children' || k === 'elements' || k.includes('displayValue')) continue - const val = rawSpeckleData[k] as SpeckleReference[] + const val = rawSpeckleData.value[k] as SpeckleReference[] if (!isNonEmptyObjectArray(val)) continue const ids = val.map((ref) => ref.referencedId) // NOTE: we're assuming all collections have refs inside; might revisit/to think re edge cases - const actualRawRefs = props.treeItem.children.filter( + const actualRawRefs = props.treeItem.rawNode.children.filter( (node) => ids.includes(node.raw?.id as string) && isAllowedType(node) ) @@ -270,7 +289,9 @@ const arrayCollections = computed(() => { children: actualRawRefs, expanded: false } - arr.push(modelCollectionItem) + arr.push({ + rawNode: modelCollectionItem + }) } return arr @@ -326,7 +347,7 @@ const manualUnfoldToggle = () => { } const isSelected = computed(() => { - return !!objects.value.find((o) => o.id === speckleData.id) + return !!objects.value.find((o) => o.id === speckleData.value.id) }) const setSelection = (e: MouseEvent) => { @@ -335,19 +356,19 @@ const setSelection = (e: MouseEvent) => { return } if (isSelected.value && e.shiftKey) { - removeFromSelection(rawSpeckleData) + removeFromSelection(rawSpeckleData.value) return } if (!e.shiftKey) clearSelection() - addToSelection(rawSpeckleData) + addToSelection(rawSpeckleData.value) } const highlightObject = () => { - highlightObjects(getTargetObjectIds(rawSpeckleData)) + highlightObjects(getTargetObjectIds(rawSpeckleData.value)) } const unhighlightObject = () => { - unhighlightObjects(getTargetObjectIds(rawSpeckleData)) + unhighlightObjects(getTargetObjectIds(rawSpeckleData.value)) } const hiddenObjects = computed(() => filteringState.value?.hiddenObjects) @@ -355,7 +376,7 @@ const isolatedObjects = computed(() => filteringState.value?.isolatedObjects) const isHidden = computed(() => { if (!hiddenObjects.value) return false - const ids = getTargetObjectIds(rawSpeckleData) + const ids = getTargetObjectIds(rawSpeckleData.value) return containsAll(ids, hiddenObjects.value) }) @@ -366,12 +387,12 @@ const stateHasIsolatedObjectsInGeneral = computed(() => { const isIsolated = computed(() => { if (!isolatedObjects.value) return false - const ids = getTargetObjectIds(rawSpeckleData) + const ids = getTargetObjectIds(rawSpeckleData.value) return containsAll(ids, isolatedObjects.value) }) const hideOrShowObject = () => { - const ids = getTargetObjectIds(rawSpeckleData) + const ids = getTargetObjectIds(rawSpeckleData.value) if (!isHidden.value) { hideObjects(ids) return @@ -381,7 +402,7 @@ const hideOrShowObject = () => { } const isolateOrUnisolateObject = () => { - const ids = getTargetObjectIds(rawSpeckleData) + const ids = getTargetObjectIds(rawSpeckleData.value) if (!isIsolated.value) { isolateObjects(ids) return diff --git a/packages/frontend-2/lib/viewer/composables/setup.ts b/packages/frontend-2/lib/viewer/composables/setup.ts index a4abd0e5b..654f59533 100644 --- a/packages/frontend-2/lib/viewer/composables/setup.ts +++ b/packages/frontend-2/lib/viewer/composables/setup.ts @@ -111,6 +111,9 @@ export type InjectableViewerState = Readonly<{ * Various values that represent the current Viewer instance state */ metadata: { + /** + * Based on a shallow ref + */ worldTree: ComputedRef> availableFilters: ComputedRef> views: ComputedRef diff --git a/packages/frontend-2/lib/viewer/helpers/sceneExplorer.ts b/packages/frontend-2/lib/viewer/helpers/sceneExplorer.ts index 50db63f29..5102f6d8e 100644 --- a/packages/frontend-2/lib/viewer/helpers/sceneExplorer.ts +++ b/packages/frontend-2/lib/viewer/helpers/sceneExplorer.ts @@ -6,6 +6,7 @@ import { type SpeckleReference, type StringPropertyInfo } from '@speckle/viewer' +import type { Raw } from 'vue' export const isStringPropertyInfo = ( info: MaybeNullOrUndefined @@ -26,4 +27,8 @@ export type ExplorerNode = { children: ExplorerNode[] } +export type TreeItemComponentModel = { + rawNode: Raw +} + export type { SpeckleObject, SpeckleReference } From d019e327c1aebf69a5a1d45b42eb2aa4b4bd2ad9 Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle <139135120+andrewwallacespeckle@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:46:50 +0100 Subject: [PATCH 14/18] feat(fe2): Improve property name display and search functionality in filtering (#2396) * feat(fe2): Improve property name display and search functionality in filtering * Improve Revit property handling and type safety * Improve getPropertyName * Revert "Improve getPropertyName" This reverts commit 21aaabdd13294bdae7a678fc8fb5454b6b174703. --- .../components/viewer/explorer/Filters.vue | 65 +++++++++++-------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/packages/frontend-2/components/viewer/explorer/Filters.vue b/packages/frontend-2/components/viewer/explorer/Filters.vue index 2c1cd40f7..741610108 100644 --- a/packages/frontend-2/components/viewer/explorer/Filters.vue +++ b/packages/frontend-2/components/viewer/explorer/Filters.vue @@ -70,7 +70,7 @@ refreshColorsIfSetOrActiveFilterIsNumeric() " > - {{ filter.key }} + {{ getPropertyName(filter.key) }}
@@ -95,7 +95,7 @@ From 2e59d231b5d2bf86f2c4f92c5908e64eb6ba447b Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Fri, 21 Jun 2024 17:04:23 +0100 Subject: [PATCH 15/18] feat(preview-service): allow metrics port to be configured (#2421) * feat(preview-service): allow metrics port to be configured * Allow configurable port --- packages/preview-service/bin/www | 2 +- .../templates/preview_service/deployment.yml | 8 +++++++- .../templates/preview_service/service.yml | 2 +- utils/helm/speckle-server/values.schema.json | 15 +++++++++++++++ utils/helm/speckle-server/values.yaml | 7 +++++++ 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/preview-service/bin/www b/packages/preview-service/bin/www index c075d2132..aff48a639 100755 --- a/packages/preview-service/bin/www +++ b/packages/preview-service/bin/www @@ -83,7 +83,7 @@ function onError(error) { function onListening() { const addr = server.address() - const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port + const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr?.port serverLogger.info('Listening on ' + bind) startPreviewService() diff --git a/utils/helm/speckle-server/templates/preview_service/deployment.yml b/utils/helm/speckle-server/templates/preview_service/deployment.yml index 9d022d955..f7cf8ed58 100644 --- a/utils/helm/speckle-server/templates/preview_service/deployment.yml +++ b/utils/helm/speckle-server/templates/preview_service/deployment.yml @@ -25,7 +25,7 @@ spec: ports: - name: metrics - containerPort: 9094 + containerPort: {{ .Values.preview_service.monitoring.metricsPort }} protocol: TCP livenessProbe: @@ -62,6 +62,12 @@ spec: {{- end }} env: + - name: PORT + value: {{ .Values.preview_service.port | quote }} + + - name: PROMETHEUS_METRICS_PORT + value: {{ .Values.preview_service.monitoring.metricsPort | quote }} + - name: PG_CONNECTION_STRING valueFrom: secretKeyRef: diff --git a/utils/helm/speckle-server/templates/preview_service/service.yml b/utils/helm/speckle-server/templates/preview_service/service.yml index 37f423fa7..41d1fc579 100644 --- a/utils/helm/speckle-server/templates/preview_service/service.yml +++ b/utils/helm/speckle-server/templates/preview_service/service.yml @@ -12,5 +12,5 @@ spec: ports: - protocol: TCP name: web - port: 9094 + port: {{ .Values.preview_service.monitoring.metricsPort }} targetPort: metrics diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index 5720b2887..ac2c5940f 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -1601,6 +1601,21 @@ "description": "The Docker image to be used for the Speckle Preview Service component. If blank, defaults to speckle/speckle-preview-service:{{ .Values.docker_image_tag }}. If provided, this value should be the full path including tag. The docker_image_tag value will be ignored.", "default": "" }, + "port": { + "type": "string", + "description": "The port on which the Preview Service will run. This is not exposed, but used within its own local network within the pod.", + "default": "3001" + }, + "monitoring": { + "type": "object", + "properties": { + "metricsPort": { + "type": "string", + "description": "The port on which the metrics server will be exposed.", + "default": "9094" + } + } + }, "requests": { "type": "object", "properties": { diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 7e8202167..5e8342780 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -1022,6 +1022,13 @@ preview_service: ## image: '' + ## @param preview_service.port The port on which the Preview Service will run. This is not exposed, but used within its own local network within the pod. + port: '3001' + + monitoring: + ## @param preview_service.monitoring.metricsPort The port on which the metrics server will be exposed. + metricsPort: '9094' + requests: ## @param preview_service.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 08d1bffdf645a118521c8120e6a84c898ec36785 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Fri, 21 Jun 2024 17:26:04 +0100 Subject: [PATCH 16/18] chore(do/1-click): sunset DigitalOcean 1-click app (#2074) --- README.md | 5 +- utils/1click_image_scripts/download.sh | 17 -- utils/1click_image_scripts/setup.py | 211 +----------------- .../template-docker-compose.yml | 129 ----------- .../template-nginx-site.conf | 35 --- 5 files changed, 14 insertions(+), 383 deletions(-) delete mode 100644 utils/1click_image_scripts/download.sh delete mode 100644 utils/1click_image_scripts/template-docker-compose.yml delete mode 100644 utils/1click_image_scripts/template-nginx-site.conf diff --git a/README.md b/README.md index c388b4abe..e929eabad 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ What is Speckle? Check our [![YouTube Video Views](https://img.shields.io/youtub Give Speckle a try in no time by: -- [![speckle](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account -- [![create a droplet](https://img.shields.io/badge/Create%20a%20Droplet-0069ff?style=flat-square&logo=digitalocean&logoColor=white)](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click +- [![app.speckle.systems](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ Create an account at app.speckle.systems +- [![Deploy on your own infrastructure with docker compose](https://img.shields.io/badge/https://-speckle.guide-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](<[https://](https://speckle.guide/dev/server-manualsetup.html)>) ⇒ Deploy on your own infrastructure with Docker Compose +- [![Deploy on your own infrastructure with docker compose](https://img.shields.io/badge/https://-speckle.guide-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](<[https://](https://speckle.guide/dev/server-setup-k8s.html)>) ⇒ Deploy on your own infrastructure with Kubernetes ## Resources diff --git a/utils/1click_image_scripts/download.sh b/utils/1click_image_scripts/download.sh deleted file mode 100644 index 68d446a25..000000000 --- a/utils/1click_image_scripts/download.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -set -euo pipefail - -echo "* Getting latest version of SpeckleServer Setup files..." - -mkdir -p /opt/speckle-server -cd /opt/speckle-server || exit 1 - -wget https://raw.githubusercontent.com/specklesystems/speckle-server/main/utils/1click_image_scripts/setup.py -O setup.py -wget https://raw.githubusercontent.com/specklesystems/speckle-server/main/utils/1click_image_scripts/template-nginx-site.conf -O template-nginx-site.conf -wget https://raw.githubusercontent.com/specklesystems/speckle-server/main/utils/1click_image_scripts/template-docker-compose.yml -O template-docker-compose.yml - -docker image rm {speckle/speckle-preview-service:2,speckle/speckle-webhook-service:2,speckle/speckle-server:2,speckle/speckle-frontend:2} || true -chmod +x setup.py - -echo "* Getting the docker images for the latest SpeckleServer release..." -docker compose -f template-docker-compose.yml pull diff --git a/utils/1click_image_scripts/setup.py b/utils/1click_image_scripts/setup.py index 0d8f133b1..c93cb184e 100755 --- a/utils/1click_image_scripts/setup.py +++ b/utils/1click_image_scripts/setup.py @@ -1,14 +1,5 @@ #!/usr/bin/env python3 -import os -import socket -import subprocess -import secrets -import ruamel.yaml # this module preserves yaml comments and whitespaces -from ruamel.yaml.scalarstring import DoubleQuotedScalarString - -FILE_PATH = os.path.dirname(os.path.abspath(__file__)) - LOGO_STR = """ _____ _ _ _____ / ___| | | | | / ___| @@ -21,202 +12,22 @@ LOGO_STR = """ """ -def get_local_ip(): - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() - if not ip: - print("Error: Can't get local IP address") - exit(1) - return ip - - -def read_domain(ip): - print("\nYou can set up a domain name for this Speckle server.") - print( - "Important: To use a domain name, you must first configure it to point to this VM address (so we can issue the SSL certificate)" - ) - print(f"VM address: {ip}") - while True: - domain = input("Domain name (leave blank to use the IP address): ").strip() - if not domain: - return None - try: - domain_ip = socket.gethostbyname(domain.strip()) - except Exception as ex: - print(f"Error: Domain '{domain}' cannot be resolved: {str(ex)}") - continue - - if domain_ip != ip: - print(f"Error: Domain '{domain}' points to {domain_ip} instead of {ip}") - continue - - return domain - - -def read_email_settings(domain): - print( - "\nYou should configure an email provider to allow the Speckle Server to send emails." - ) - print( - "Supported vendors: Any email provider that can provide SMTP connection details (mailjet, mailgun, etc)." - ) - print( - "Important: If you don't configure email details, some features that require sending emails will not work, nevertheless the server should be functional." - ) - while True: - enable_email = False - while True: - enable_email = input("Enable emails? [Y/n]: ").strip().lower() - if enable_email in ["n", "no"]: - enable_email = False - break - elif enable_email in ["", "y", "yes"]: - enable_email = True - break - else: - print("Unrecognized option") - continue - - if not enable_email: - return None - - print("Enter your SMTP connection details offered by your email provider") - smtp_host = input("SMTP server / host: ").strip() - smtp_port = input("SMTP port: ").strip() - try: - int(smtp_port) - except Exception: - print("Error: SMTP port must be a number. Retrying...") - continue - smtp_user = input("SMTP Username: ").strip() - smtp_pass = input("SMTP Password: ").strip() - - if domain: - default_from_email = "no-reply@" + domain - else: - default_from_email = "" - email_from = input(f"Email address to send email as [{default_from_email}]: ") - if not email_from.strip(): - email_from = default_from_email - - if ( - not smtp_host - or not smtp_port - or not smtp_user - or not smtp_pass - or not email_from - ): - print("Error: One or more fields were empty. Retrying...") - continue - - return { - "host": smtp_host, - "port": smtp_port, - "user": smtp_user, - "pass": smtp_pass, - "from": email_from, - } - - def main(): print(LOGO_STR) - ip = get_local_ip() - - ### - ### Read user input - ######### - domain = read_domain(ip) - if domain: - canonical_url = f"https://{domain}" - else: - canonical_url = f"http://{ip}" - - email = read_email_settings(domain) - - ### - ### Create docker-compose.yml from the template - ######### - print("\nConfiguring docker containers...") - - yaml = ruamel.yaml.YAML() - yaml.preserve_quotes = True - with open(os.path.join(FILE_PATH, "template-docker-compose.yml"), "r") as f: - yml_doc = yaml.load(f) - env = yml_doc["services"]["speckle-server"]["environment"] - env["CANONICAL_URL"] = DoubleQuotedScalarString(canonical_url) - env["FRONTEND_ORIGIN"] = DoubleQuotedScalarString(canonical_url) - env["SESSION_SECRET"] = DoubleQuotedScalarString(secrets.token_hex(32)) - if email: - env["EMAIL"] = DoubleQuotedScalarString("true") - env["EMAIL_HOST"] = DoubleQuotedScalarString(email["host"]) - env["EMAIL_PORT"] = DoubleQuotedScalarString(email["port"]) - env["EMAIL_USERNAME"] = DoubleQuotedScalarString(email["user"]) - env["EMAIL_PASSWORD"] = DoubleQuotedScalarString(email["pass"]) - env["EMAIL_FROM"] = DoubleQuotedScalarString(email["from"]) - else: - env["EMAIL"] = DoubleQuotedScalarString("false") - - fe2env = yml_doc["services"]["speckle-frontend-2"]["environment"] - fe2env["NUXT_PUBLIC_SERVER_NAME"] = DoubleQuotedScalarString(canonical_url) - fe2env["NUXT_PUBLIC_API_ORIGIN"] = DoubleQuotedScalarString(canonical_url) - fe2env["NUXT_PUBLIC_BASE_URL"] = DoubleQuotedScalarString(canonical_url) - - with open(os.path.join(FILE_PATH, "docker-compose.yml"), "w") as f: - f.write("# This file was generated by SpeckleServer setup.\n") - f.write("# If the setup is re-run, this file will be overwritten.\n\n") - yaml.dump(yml_doc, f) - - ### - ### Run the new docker compose file (will update containers if already running) - ######### - subprocess.run( - ["bash", "-c", f'cd "{FILE_PATH}"; docker compose up -d'], check=True - ) - - ### - ### Update nginx config and restart nginx - ######### - print("\nConfiguring local nginx...") - - nginx_conf_str = "# This file is managed by SpeckleServer setup script.\n" - nginx_conf_str += ( - "# Any modifications will be removed when the setup script is re-executed\n\n" - ) - with open(os.path.join(FILE_PATH, "template-nginx-site.conf"), "r") as f: - nginx_conf_str += f.read() - if domain: - nginx_conf_str = nginx_conf_str.replace("TODO_REPLACE_WITH_SERVER_NAME", domain) - else: - nginx_conf_str = nginx_conf_str.replace("TODO_REPLACE_WITH_SERVER_NAME", "_") - with open("/etc/nginx/sites-available/speckle-server", "w") as f: - f.write(nginx_conf_str) - subprocess.run(["nginx", "-s", "reload"], check=True) - - ### - ### Run letsencrypt on new config - ######### - if domain: - print("\n***") - print( - "*** Will now run LetsEncrypt utility to generate https certificate. Please answer any questions that are presented" - ) - print( - "*** We highly recommend setting a good email address so that you are notified if there is any action needed to renew certificates" - ) - print("***") - subprocess.run(["certbot", "--nginx", "-d", domain]) - - print("\nConfiguration complete!") - print("You can access your speckle server at: " + canonical_url) - print(LOGO_STR) - print("\nOne more thing and you are ready to roll:") print( - f" - Go to {canonical_url} in your browser and create an account. The first user to register will be granted administrator rights." + "\nAs of March 2024, Speckle server DigitalOcean 1-click setup script is no longer supported." ) print( - " - Fill in information about your server under your profile page (in the lower left corner)." + "\nPlease use the official Speckle Server installation guide to install Speckle Server on your own infrastructure." + ) + print( + "\nThis could be on a DigitalOcean Droplet that you have created yourself using an Ubuntu image." + ) + print( + "\nOur documentation can be found at https://speckle.guide/dev/server-manualsetup.html" + ) + print( + "\nIf you require to view previous versions of the 1-click setup script, please review the history of the speckle-server repository on GitHub." ) print("\nHappy Speckling!") diff --git a/utils/1click_image_scripts/template-docker-compose.yml b/utils/1click_image_scripts/template-docker-compose.yml deleted file mode 100644 index b57d90ee8..000000000 --- a/utils/1click_image_scripts/template-docker-compose.yml +++ /dev/null @@ -1,129 +0,0 @@ -version: '2.3' -services: - #### - # Speckle Server dependencies - ####### - postgres: - image: 'postgres:14.5-alpine' - restart: always - environment: - POSTGRES_DB: speckle - POSTGRES_USER: speckle - POSTGRES_PASSWORD: speckle - volumes: - - ./postgres-data:/var/lib/postgresql/data/ - ports: - - '127.0.0.1:5432:5432' - - redis: - image: 'redis:7.0-alpine' - restart: always - volumes: - - ./redis-data:/data - ports: - - '127.0.0.1:6379:6379' - - minio: - image: 'minio/minio' - command: server /data --console-address ":9001" - restart: always - volumes: - - ./minio-data:/data - ports: - - '127.0.0.1:9000:9000' - - '127.0.0.1:9001:9001' - - #### - # Speckle Server - ####### - speckle-frontend-2: - image: speckle/speckle-frontend-2:2 - restart: always - ports: - - '127.0.0.1:8080:8080' - environment: - NUXT_PUBLIC_SERVER_NAME: 'TODO: change' # e.g. 'my-speckle-server' - NUXT_PUBLIC_API_ORIGIN: 'TODO: change' # e.g. 'http://127.0.0.1' - NUXT_PUBLIC_BASE_URL: 'TODO: change' # e.g. 'http://127.0.0.1' - NUXT_PUBLIC_BACKEND_API_ORIGIN: 'http://speckle-server:3000' - NUXT_PUBLIC_LOG_LEVEL: 'warn' - NUXT_REDIS_URL: 'redis://redis' - - speckle-server: - image: speckle/speckle-server:2 - restart: always - healthcheck: - test: - - CMD - - node - - -e - - "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/liveness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(res.statusCode != 200 || body.toLowerCase().includes('error'));}); }).end(); } catch { process.exit(1); }" - interval: 10s - timeout: 10s - retries: 3 - start_period: 90s - ports: - - '127.0.0.1:3000:3000' - environment: - CANONICAL_URL: 'TODO: change' - SESSION_SECRET: 'TODO: change' - - STRATEGY_LOCAL: 'true' - LOG_LEVEL: 'info' - - POSTGRES_URL: 'postgres' - POSTGRES_USER: 'speckle' - POSTGRES_PASSWORD: 'speckle' - POSTGRES_DB: 'speckle' - - REDIS_URL: 'redis://redis' - WAIT_HOSTS: 'postgres:5432, redis:6379, minio:9000' - - EMAIL: 'false' - EMAIL_HOST: 'TODO' - EMAIL_PORT: 'TODO' - EMAIL_USERNAME: 'TODO' - EMAIL_PASSWORD: 'TODO' - EMAIL_FROM: 'TODO' - - EMAIL_SECURE: 'false' - - S3_ENDPOINT: 'http://minio:9000' - S3_ACCESS_KEY: 'minioadmin' - S3_SECRET_KEY: 'minioadmin' - S3_BUCKET: 'speckle-server' - S3_CREATE_BUCKET: 'true' - S3_REGION: '' # optional, defaults to 'us-east-1' - - FILE_SIZE_LIMIT_MB: 100 - - USE_FRONTEND_2: 'true' - FRONTEND_ORIGIN: 'TODO: change' - - speckle-preview-service: - image: speckle/speckle-preview-service:2 - restart: always - mem_limit: '1000m' - memswap_limit: '1000m' - - environment: - LOG_LEVEL: 'info' - PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle' - WAIT_HOSTS: 'postgres:5432' - - speckle-webhook-service: - image: speckle/speckle-webhook-service:2 - restart: always - environment: - LOG_LEVEL: 'info' - PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle' - WAIT_HOSTS: 'postgres:5432' - - fileimport-service: - image: speckle/speckle-fileimport-service:2 - restart: always - environment: - LOG_LEVEL: 'info' - PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle' - WAIT_HOSTS: 'postgres:5432' - SPECKLE_SERVER_URL: 'http://speckle-server:3000' diff --git a/utils/1click_image_scripts/template-nginx-site.conf b/utils/1click_image_scripts/template-nginx-site.conf deleted file mode 100644 index b978f6cfa..000000000 --- a/utils/1click_image_scripts/template-nginx-site.conf +++ /dev/null @@ -1,35 +0,0 @@ -server { - listen 80 default_server; - listen [::]:80 default_server; - - server_name TODO_REPLACE_WITH_SERVER_NAME; - - client_max_body_size 100m; - - location / { - client_max_body_size 100m; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_pass http://localhost:8080; - - proxy_buffering off; - proxy_request_buffering off; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - location ~* ^/(graphql|explorer|(auth/.*)|(objects/.*)|(preview/.*)|(api/.*)|(static/.*)) { - client_max_body_size 100m; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_pass http://localhost:3000; - - proxy_buffering off; - proxy_request_buffering off; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } -} - From ac242f5f2fc0e7d3fa151ef21b02b6cdfd26459a Mon Sep 17 00:00:00 2001 From: iltabe Date: Mon, 24 Jun 2024 09:45:09 +0200 Subject: [PATCH 17/18] allows empty strings and zeros props to be serialized --- packages/objectsender/src/utils/Serializer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objectsender/src/utils/Serializer.ts b/packages/objectsender/src/utils/Serializer.ts index e263e416e..33a3be01c 100644 --- a/packages/objectsender/src/utils/Serializer.ts +++ b/packages/objectsender/src/utils/Serializer.ts @@ -53,7 +53,7 @@ export class Serializer implements IDisposable { for (const propKey in obj) { const value = obj[propKey] // 0. skip some props - if (!value || propKey === 'id' || propKey.startsWith('_')) continue + if (value === undefined || propKey === 'id' || propKey.startsWith('_')) continue // 1. primitives (numbers, bools, strings) if (typeof value !== 'object') { From c6cd4c311d96168cf1321fe4e82546e54a8fe3ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= <57442769+gjedlicska@users.noreply.github.com> Date: Tue, 25 Jun 2024 13:24:37 +0200 Subject: [PATCH 18/18] feat(serverinvites): create domain module in server invites (#2401) * chore(serverinvites): repository refactor for multiregion * chore(serverinvites): remove migrated functions from old repository * chore(serverinvites): refactor serverInviteForToken resolver for multiregion * chore(serverinvites): invite processing service refactor for multiregion * chore(serverinvites): subscription refactor for multiregion * chore(serverinvites): move buildEmailContents to dedicated file * chore(serverinvites): deleteAllStreamInvites function multiregion refactor * chore(serverinvites): refactor deleteServerOnlyInvites multiregion repository * chore(serverinvites): complete repository refactor for multiregion * feat(serverinvites): create domain module in server invites * fix(serverinvites): no relative imports * feat(serverinvites): extract individual types from repository * feat(serverinvites): move interfaces to operations * fix(serverinvites): update imports referencing old interfaces file * fix(serverinvites): type mismatch for insert invite and delete old * chore(serverinvites): refactor to single repo function * test(serverinvites): fix tests * fix(serverinvites): use domain types in all places * feat(serverinvites): WIP unity * feat(serverinvites): move to new facory names and types * feat(serverinvites): fix tests * fix(serverinvites): use factory name --------- Co-authored-by: Alessandro Magionami --- .../modules/accessrequests/services/stream.ts | 2 +- .../modules/auth/strategies/azure-ad.js | 15 +- .../server/modules/auth/strategies/github.js | 15 +- .../server/modules/auth/strategies/google.js | 15 +- .../server/modules/auth/strategies/local.js | 17 +- .../server/modules/auth/strategies/oidc.js | 15 +- .../server/modules/auth/tests/auth.spec.js | 11 +- .../modules/core/domain/tokens/types.ts | 9 + .../modules/core/graph/resolvers/projects.ts | 60 ++- .../modules/core/graph/resolvers/streams.js | 8 +- .../modules/core/graph/resolvers/users.js | 24 +- packages/server/modules/core/helpers/token.ts | 11 +- packages/server/modules/core/helpers/types.ts | 2 +- packages/server/modules/core/loaders.ts | 7 +- .../modules/core/repositories/streams.ts | 4 +- .../server/modules/core/services/admin.ts | 14 +- .../core/services/streams/management.ts | 37 +- .../services/streams/streamAccessService.js | 6 +- .../server/modules/core/services/users.js | 37 +- .../services/users/adminUsersListService.js | 113 ++-- .../server/modules/core/tests/users.spec.js | 4 +- .../modules/core/tests/usersAdmin.spec.js | 2 +- .../modules/core/tests/usersAdminList.spec.ts | 5 +- .../serverinvites/domain/operations.ts | 114 ++++ .../{helpers => domain}/types.ts | 0 .../graph/resolvers/serverInvites.ts | 111 +++- .../serverinvites/helpers/inviteHelper.js | 6 +- .../serverinvites/repositories/index.js | 398 -------------- .../repositories/serverInvites.ts | 352 +++++++++++++ .../services/buildEmailContents.ts | 273 ++++++++++ .../services/inviteCreationService.js | 486 ------------------ .../services/inviteCreationService.ts | 197 +++++++ .../services/inviteProcessingService.js | 196 ------- .../services/inviteProcessingService.ts | 212 ++++++++ .../services/inviteRetrievalService.ts | 133 ++--- .../serverinvites/services/management.ts | 125 ++--- .../serverinvites/services/operations.ts | 21 + .../serverinvites/services/validation.ts | 120 +++++ .../serverinvites/tests/invites.spec.js | 31 +- packages/server/modules/shared/authz.ts | 7 +- packages/server/modules/shared/index.js | 2 +- .../test/speckle-helpers/inviteHelper.js | 44 -- .../test/speckle-helpers/inviteHelper.ts | 58 +++ packages/shared/src/environment/index.ts | 18 +- 44 files changed, 1912 insertions(+), 1425 deletions(-) create mode 100644 packages/server/modules/core/domain/tokens/types.ts create mode 100644 packages/server/modules/serverinvites/domain/operations.ts rename packages/server/modules/serverinvites/{helpers => domain}/types.ts (100%) delete mode 100644 packages/server/modules/serverinvites/repositories/index.js create mode 100644 packages/server/modules/serverinvites/repositories/serverInvites.ts create mode 100644 packages/server/modules/serverinvites/services/buildEmailContents.ts delete mode 100644 packages/server/modules/serverinvites/services/inviteCreationService.js create mode 100644 packages/server/modules/serverinvites/services/inviteCreationService.ts delete mode 100644 packages/server/modules/serverinvites/services/inviteProcessingService.js create mode 100644 packages/server/modules/serverinvites/services/inviteProcessingService.ts create mode 100644 packages/server/modules/serverinvites/services/operations.ts create mode 100644 packages/server/modules/serverinvites/services/validation.ts delete mode 100644 packages/server/test/speckle-helpers/inviteHelper.js create mode 100644 packages/server/test/speckle-helpers/inviteHelper.ts diff --git a/packages/server/modules/accessrequests/services/stream.ts b/packages/server/modules/accessrequests/services/stream.ts index d44e567c0..f5126abf9 100644 --- a/packages/server/modules/accessrequests/services/stream.ts +++ b/packages/server/modules/accessrequests/services/stream.ts @@ -15,7 +15,7 @@ import { ServerAccessRequestRecord } from '@/modules/accessrequests/repositories' import { StreamInvalidAccessError } from '@/modules/core/errors/stream' -import { TokenResourceIdentifier } from '@/modules/core/graph/generated/graphql' +import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' import { Roles, StreamRoles } from '@/modules/core/helpers/mainConstants' import { getStream } from '@/modules/core/repositories/streams' import { diff --git a/packages/server/modules/auth/strategies/azure-ad.js b/packages/server/modules/auth/strategies/azure-ad.js index 63a5f69fe..22719595e 100644 --- a/packages/server/modules/auth/strategies/azure-ad.js +++ b/packages/server/modules/auth/strategies/azure-ad.js @@ -16,6 +16,11 @@ const { UserInputError, UnverifiedEmailSSOLoginError } = require('@/modules/core/errors/userinput') +const db = require('@/db/knex') +const { + deleteServerOnlyInvitesFactory, + updateAllInviteTargetsFactory +} = require('@/modules/serverinvites/repositories/serverInvites') module.exports = async (app, session, sessionStorage, finalizeAuth) => { const strategy = new OIDCStrategy( @@ -95,7 +100,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { // process invites if (myUser.isNewUser) { - await finalizeInvitedServerRegistration(user.email, myUser.id) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, myUser.id) } return next() @@ -126,7 +134,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { req.log = req.log.child({ userId: myUser.id }) // use the invite - await finalizeInvitedServerRegistration(user.email, myUser.id) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, myUser.id) // Resolve redirect path req.authRedirectPath = resolveAuthRedirectPath(validInvite) diff --git a/packages/server/modules/auth/strategies/github.js b/packages/server/modules/auth/strategies/github.js index 595d54a86..432278624 100644 --- a/packages/server/modules/auth/strategies/github.js +++ b/packages/server/modules/auth/strategies/github.js @@ -17,6 +17,11 @@ const { UserInputError, UnverifiedEmailSSOLoginError } = require('@/modules/core/errors/userinput') +const db = require('@/db/knex') +const { + deleteServerOnlyInvitesFactory, + updateAllInviteTargetsFactory +} = require('@/modules/serverinvites/repositories/serverInvites') module.exports = async (app, session, sessionStorage, finalizeAuth) => { const strategy = { @@ -72,7 +77,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { // process invites if (myUser.isNewUser) { - await finalizeInvitedServerRegistration(user.email, myUser.id) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, myUser.id) } return done(null, myUser) @@ -98,7 +106,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { }) // use the invite - await finalizeInvitedServerRegistration(user.email, myUser.id) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, myUser.id) // Resolve redirect path req.authRedirectPath = resolveAuthRedirectPath(validInvite) diff --git a/packages/server/modules/auth/strategies/google.js b/packages/server/modules/auth/strategies/google.js index 99432a7fe..26fae70ce 100644 --- a/packages/server/modules/auth/strategies/google.js +++ b/packages/server/modules/auth/strategies/google.js @@ -15,6 +15,11 @@ const { UserInputError, UnverifiedEmailSSOLoginError } = require('@/modules/core/errors/userinput') +const db = require('@/db/knex') +const { + deleteServerOnlyInvitesFactory, + updateAllInviteTargetsFactory +} = require('@/modules/serverinvites/repositories/serverInvites') module.exports = async (app, session, sessionStorage, finalizeAuth) => { const strategy = { @@ -66,7 +71,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { // process invites if (myUser.isNewUser) { - await finalizeInvitedServerRegistration(user.email, myUser.id) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, myUser.id) } return done(null, myUser) @@ -92,7 +100,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { }) // use the invite - await finalizeInvitedServerRegistration(user.email, myUser.id) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, myUser.id) // Resolve redirect path req.authRedirectPath = resolveAuthRedirectPath(validInvite) diff --git a/packages/server/modules/auth/strategies/local.js b/packages/server/modules/auth/strategies/local.js index ae46eabbc..290818969 100644 --- a/packages/server/modules/auth/strategies/local.js +++ b/packages/server/modules/auth/strategies/local.js @@ -21,6 +21,12 @@ const { UserInputError, PasswordTooShortError } = require('@/modules/core/errors/userinput') +const { + findServerInviteFactory, + deleteServerOnlyInvitesFactory, + updateAllInviteTargetsFactory +} = require('@/modules/serverinvites/repositories/serverInvites') +const db = require('@/db/knex') module.exports = async (app, session, sessionAppId, finalizeAuth) => { const strategy = { @@ -82,10 +88,12 @@ module.exports = async (app, session, sessionAppId, finalizeAuth) => { ) // 2. if you have an invite it must be valid, both for invite only and public servers - /** @type {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} */ + /** @type {import('@/modules/serverinvites/domain/types').ServerInviteRecord} */ let invite if (req.session.token) { - invite = await validateServerInvite(user.email, req.session.token) + invite = await validateServerInvite({ + findServerInvite: findServerInviteFactory({ db }) + })(user.email, req.session.token) } // 3. at this point we know, that we have one of these cases: @@ -106,7 +114,10 @@ module.exports = async (app, session, sessionAppId, finalizeAuth) => { req.log = req.log.child({ userId }) // 4. use up all server-only invites the email had attached to it - await finalizeInvitedServerRegistration(user.email, userId) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, userId) // Resolve redirect path req.authRedirectPath = resolveAuthRedirectPath(invite) diff --git a/packages/server/modules/auth/strategies/oidc.js b/packages/server/modules/auth/strategies/oidc.js index 95ca8fe77..31a9176a9 100644 --- a/packages/server/modules/auth/strategies/oidc.js +++ b/packages/server/modules/auth/strategies/oidc.js @@ -21,6 +21,11 @@ const { } = require('@/modules/shared/helpers/envHelper') const { passportAuthenticate } = require('@/modules/auth/services/passportService') const { UnverifiedEmailSSOLoginError } = require('@/modules/core/errors/userinput') +const { + deleteServerOnlyInvitesFactory, + updateAllInviteTargetsFactory +} = require('@/modules/serverinvites/repositories/serverInvites') +const db = require('@/db/knex') const { getNameFromUserInfo } = require('@/modules/auth/domain/logic') module.exports = async (app, session, sessionStorage, finalizeAuth) => { @@ -83,7 +88,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { // process invites if (myUser.isNewUser) { - await finalizeInvitedServerRegistration(user.email, myUser.id) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, myUser.id) } return done(null, myUser) } @@ -108,7 +116,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => { rawProfile: userinfo }) - await finalizeInvitedServerRegistration(user.email, myUser.id) + await finalizeInvitedServerRegistration({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + })(user.email, myUser.id) // Resolve redirect path req.authRedirectPath = resolveAuthRedirectPath(validInvite) diff --git a/packages/server/modules/auth/tests/auth.spec.js b/packages/server/modules/auth/tests/auth.spec.js index 53969e074..f8a28e159 100644 --- a/packages/server/modules/auth/tests/auth.spec.js +++ b/packages/server/modules/auth/tests/auth.spec.js @@ -9,10 +9,15 @@ const { getUserByEmail } = require('@/modules/core/services/users') const { TIME } = require('@speckle/shared') const { RATE_LIMITERS, createConsumer } = require('@/modules/core/services/ratelimiter') const { beforeEachContext, initializeTestServer } = require('@/test/hooks') -const { createInviteDirectly } = require('@/test/speckle-helpers/inviteHelper') -const { getInvite } = require('@/modules/serverinvites/repositories') +const { createInviteDirectlyFactory } = require('@/test/speckle-helpers/inviteHelper') const { RateLimiterMemory } = require('rate-limiter-flexible') +const { + findInviteFactory +} = require('@/modules/serverinvites/repositories/serverInvites') +const db = require('@/db/knex') +const createInviteDirectly = createInviteDirectlyFactory({ db }) +const findInvite = findInviteFactory({ db }) const expect = chai.expect let app @@ -155,7 +160,7 @@ describe('Auth @auth', () => { expect(newUser).to.be.ok // Check that in the case of a stream invite, it remainds valid post registration - const inviteRecord = await getInvite(inviteId) + const inviteRecord = await findInvite(inviteId) if (streamInvite) { expect(inviteRecord).to.be.ok } else { diff --git a/packages/server/modules/core/domain/tokens/types.ts b/packages/server/modules/core/domain/tokens/types.ts new file mode 100644 index 000000000..33b58e5bb --- /dev/null +++ b/packages/server/modules/core/domain/tokens/types.ts @@ -0,0 +1,9 @@ +export const TokenResourceIdentifierType = { + Project: 'project' +} as const + +export type TokenResourceIdentifierType = + (typeof TokenResourceIdentifierType)[keyof typeof TokenResourceIdentifierType] + +// TODO: these should be moved to domain +export type TokenResourceIdentifier = { id: string; type: TokenResourceIdentifierType } diff --git a/packages/server/modules/core/graph/resolvers/projects.ts b/packages/server/modules/core/graph/resolvers/projects.ts index 3f1760072..8929b869b 100644 --- a/packages/server/modules/core/graph/resolvers/projects.ts +++ b/packages/server/modules/core/graph/resolvers/projects.ts @@ -1,3 +1,4 @@ +import db from '@/db/knex' import { RateLimitError } from '@/modules/core/errors/ratelimit' import { StreamNotFoundError } from '@/modules/core/errors/stream' import { @@ -26,13 +27,27 @@ import { import { createOnboardingStream } from '@/modules/core/services/streams/onboarding' import { removeStreamCollaborator } from '@/modules/core/services/streams/streamAccessService' import { InviteCreateValidationError } from '@/modules/serverinvites/errors' -import { cancelStreamInvite } from '@/modules/serverinvites/services/inviteProcessingService' +import { + deleteInvitesByTargetFactory, + deleteStreamInviteFactory, + findResourceFactory, + findStreamInviteFactory, + findUserByTargetFactory, + insertInviteAndDeleteOldFactory, + queryAllStreamInvitesFactory, + queryAllUserStreamInvitesFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/inviteCreationService' +import { + cancelStreamInvite, + finalizeStreamInvite +} from '@/modules/serverinvites/services/inviteProcessingService' import { getPendingStreamCollaborators, getUserPendingStreamInvites } from '@/modules/serverinvites/services/inviteRetrievalService' import { - createStreamInviteAndNotify, + createStreamInviteAndNotifyFactory, useStreamInviteAndNotify } from '@/modules/serverinvites/services/management' import { authorizeResolver, validateScopes } from '@/modules/shared' @@ -129,7 +144,14 @@ export = { Roles.Stream.Owner, ctx.resourceAccessRules ) - await createStreamInviteAndNotify( + const createAndSendInvite = createAndSendInviteFactory({ + findResource: findResourceFactory(), + findUserByTarget: findUserByTargetFactory(), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }) + }) + await createStreamInviteAndNotifyFactory({ + createAndSendInvite + })( { ...args.input, projectId: args.projectId @@ -158,7 +180,15 @@ export = { for (const batch of inputBatches) { await Promise.all( batch.map((i) => - createStreamInviteAndNotify( + createStreamInviteAndNotifyFactory({ + createAndSendInvite: createAndSendInviteFactory({ + findResource: findResourceFactory(), + findUserByTarget: findUserByTargetFactory(), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ + db + }) + }) + })( { ...i, projectId: args.projectId }, ctx.userId!, ctx.resourceAccessRules @@ -169,7 +199,12 @@ export = { return ctx.loaders.streams.getStream.load(args.projectId) }, async use(_parent, args, ctx) { - await useStreamInviteAndNotify(args.input, ctx.userId!, ctx.resourceAccessRules) + await useStreamInviteAndNotify({ + finalizeStreamInvite: finalizeStreamInvite({ + findStreamInvite: findStreamInviteFactory({ db }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }) + }) + })(args.input, ctx.userId!, ctx.resourceAccessRules) return true }, async cancel(_parent, args, ctx) { @@ -179,7 +214,10 @@ export = { Roles.Stream.Owner, ctx.resourceAccessRules ) - await cancelStreamInvite(args.projectId, args.inviteId) + await cancelStreamInvite({ + findStreamInvite: findStreamInviteFactory({ db }), + deleteStreamInvite: deleteStreamInviteFactory({ db }) + })(args.projectId, args.inviteId) return ctx.loaders.streams.getStream.load(args.projectId) } }, @@ -216,7 +254,11 @@ export = { }, async projectInvites(_parent, _args, context) { const { userId } = context - return await getUserPendingStreamInvites(userId!) + return await getUserPendingStreamInvites({ + queryAllUserStreamInvites: queryAllUserStreamInvitesFactory({ + db + }) + })(userId!) } }, Project: { @@ -238,7 +280,9 @@ export = { return ctx.loaders.streams.getSourceApps.load(parent.id) || [] }, async invitedTeam(parent) { - return await getPendingStreamCollaborators(parent.id) + return getPendingStreamCollaborators({ + queryAllStreamInvites: queryAllStreamInvitesFactory({ db }) + })(parent.id) }, async visibility(parent) { const { isPublic, isDiscoverable } = parent diff --git a/packages/server/modules/core/graph/resolvers/streams.js b/packages/server/modules/core/graph/resolvers/streams.js index de772304f..4d96c9d47 100644 --- a/packages/server/modules/core/graph/resolvers/streams.js +++ b/packages/server/modules/core/graph/resolvers/streams.js @@ -57,6 +57,10 @@ const { const { TokenResourceIdentifierType } = require('@/modules/core/graph/generated/graphql') +const { + queryAllStreamInvitesFactory +} = require('@/modules/serverinvites/repositories/serverInvites') +const db = require('@/db/knex') // subscription events const USER_STREAM_ADDED = StreamPubsubEvents.UserStreamAdded @@ -167,7 +171,9 @@ module.exports = { async pendingCollaborators(parent) { const { id: streamId } = parent - return await getPendingStreamCollaborators(streamId) + return await getPendingStreamCollaborators({ + queryAllStreamInvites: queryAllStreamInvitesFactory({ db }) + })(streamId) }, async favoritedDate(parent, _args, ctx) { diff --git a/packages/server/modules/core/graph/resolvers/users.js b/packages/server/modules/core/graph/resolvers/users.js index f3732d49f..396d89dd2 100644 --- a/packages/server/modules/core/graph/resolvers/users.js +++ b/packages/server/modules/core/graph/resolvers/users.js @@ -14,13 +14,20 @@ const { ActionTypes } = require('@/modules/activitystream/helpers/types') const { validateScopes } = require(`@/modules/shared`) const zxcvbn = require('zxcvbn') const { - getAdminUsersListCollection + getAdminUsersListCollection, + getTotalCounts } = require('@/modules/core/services/users/adminUsersListService') const { Roles, Scopes } = require('@speckle/shared') const { markOnboardingComplete } = require('@/modules/core/repositories/users') const { UsersMeta } = require('@/modules/core/dbSchema') const { getServerInfo } = require('@/modules/core/services/generic') const { throwForNotHavingServerRole } = require('@/modules/shared/authz') +const { + deleteAllUserInvitesFactory, + countServerInvitesFactory, + findServerInvitesFactory +} = require('@/modules/serverinvites/repositories/serverInvites') +const db = require('@/db/knex') /** @type {import('@/modules/core/graph/generated/graphql').Resolvers} */ module.exports = { @@ -60,7 +67,12 @@ module.exports = { }, async adminUsers(_parent, args) { - return await getAdminUsersListCollection(args) + return await getAdminUsersListCollection({ + findServerInvites: findServerInvitesFactory({ db }), + getTotalCounts: getTotalCounts({ + countServerInvites: countServerInvitesFactory({ db }) + }) + })(args) }, async userSearch(parent, args, context) { @@ -151,7 +163,9 @@ module.exports = { const user = await getUserByEmail({ email: args.userConfirmation.email }) if (!user) return false - await deleteUser(user.id) + await deleteUser({ + deleteAllUserInvites: deleteAllUserInvitesFactory({ db }) + })(user.id) return true }, @@ -168,7 +182,9 @@ module.exports = { await throwForNotHavingServerRole(context, Roles.Server.Guest) await validateScopes(context.scopes, Scopes.Profile.Delete) - await deleteUser(context.userId, args.user) + await deleteUser({ + deleteAllUserInvites: deleteAllUserInvitesFactory({ db }) + })(context.userId, args.user) await saveActivity({ streamId: null, diff --git a/packages/server/modules/core/helpers/token.ts b/packages/server/modules/core/helpers/token.ts index 9cf24c43b..d1752ca12 100644 --- a/packages/server/modules/core/helpers/token.ts +++ b/packages/server/modules/core/helpers/token.ts @@ -1,10 +1,11 @@ -import { TokenCreateError } from '@/modules/core/errors/user' import { TokenResourceIdentifier, TokenResourceIdentifierType -} from '@/modules/core/graph/generated/graphql' +} from '@/modules/core/domain/tokens/types' +import { TokenCreateError } from '@/modules/core/errors/user' import { TokenResourceAccessRecord } from '@/modules/core/helpers/types' import { ResourceTargets } from '@/modules/serverinvites/helpers/inviteHelper' +import {} from '@/modules/serverinvites/services/operations' import { MaybeNullOrUndefined, Nullable, Optional, Scopes } from '@speckle/shared' import { differenceBy } from 'lodash' @@ -24,7 +25,7 @@ export const roleResourceTypeToTokenResourceType = ( ): Nullable => { switch (type) { case ResourceTargets.Streams: - return TokenResourceIdentifierType.Project + return 'project' default: return null } @@ -52,9 +53,7 @@ export const isNewResourceAllowed = (params: { export const toProjectIdWhitelist = ( resourceAccessRules: ContextResourceAccessRules ): Optional => { - const projectRules = resourceAccessRules?.filter( - (r) => r.type === TokenResourceIdentifierType.Project - ) + const projectRules = resourceAccessRules?.filter((r) => r.type === 'project') return projectRules?.map((r) => r.id) } diff --git a/packages/server/modules/core/helpers/types.ts b/packages/server/modules/core/helpers/types.ts index 04f783eba..7fbf3342c 100644 --- a/packages/server/modules/core/helpers/types.ts +++ b/packages/server/modules/core/helpers/types.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' +import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types' import { BaseMetaRecord } from '@/modules/core/helpers/meta' import { Nullable } from '@/modules/shared/helpers/typeHelper' import { ServerRoles } from '@speckle/shared' diff --git a/packages/server/modules/core/loaders.ts b/packages/server/modules/core/loaders.ts index 5bbcd32e3..82735b4de 100644 --- a/packages/server/modules/core/loaders.ts +++ b/packages/server/modules/core/loaders.ts @@ -12,7 +12,6 @@ import { } from '@/modules/core/repositories/streams' import { UserWithOptionalRole, getUsers } from '@/modules/core/repositories/users' import { keyBy } from 'lodash' -import { getInvites } from '@/modules/serverinvites/repositories' import { AuthContext } from '@/modules/shared/authz' import { BranchRecord, @@ -23,7 +22,7 @@ import { UsersMetaRecord } from '@/modules/core/helpers/types' import { Nullable } from '@/modules/shared/helpers/typeHelper' -import { ServerInviteRecord } from '@/modules/serverinvites/helpers/types' +import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' import { getCommitBranches, getCommits, @@ -84,6 +83,8 @@ import { ExecutionEngineFailedResponseError, ExecutionEngineNetworkError } from '@/modules/automate/errors/executionEngine' +import { queryInvitesFactory } from '@/modules/serverinvites/repositories/serverInvites' +import db from '@/db/knex' const simpleTupleCacheKey = (key: [string, string]) => `${key[0]}:${key[1]}` @@ -518,7 +519,7 @@ export function buildRequestLoaders( */ getInvite: createLoader>( async (inviteIds) => { - const results = keyBy(await getInvites(inviteIds), 'id') + const results = keyBy(await queryInvitesFactory({ db })(inviteIds), 'id') return inviteIds.map((i) => results[i] || null) } ) diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index dc7221210..8eecdca12 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -143,9 +143,9 @@ export async function getStreams( * Get a single stream. If userId is specified, the role will be resolved as well. */ export async function getStream( - params: { streamId: string; userId?: string }, + params: { streamId?: string; userId?: string }, options?: Partial<{ trx: Knex.Transaction }> -) { +): Promise> { const { streamId, userId } = params if (!streamId) throw new InvalidArgumentError('Invalid stream ID') diff --git a/packages/server/modules/core/services/admin.ts b/packages/server/modules/core/services/admin.ts index 6d4dddca3..043971a23 100644 --- a/packages/server/modules/core/services/admin.ts +++ b/packages/server/modules/core/services/admin.ts @@ -1,12 +1,13 @@ +import db from '@/db/knex' import { ServerInviteGraphQLReturnType } from '@/modules/core/helpers/graphTypes' import { StreamRecord, UserRecord } from '@/modules/core/helpers/types' import { listUsers, countUsers } from '@/modules/core/repositories/users' import { getStreams } from '@/modules/core/services/streams' -import { ServerInviteRecord } from '@/modules/serverinvites/helpers/types' +import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' import { - countServerInvites, - queryServerInvites -} from '@/modules/serverinvites/repositories' + countServerInvitesFactory, + queryServerInvitesFactory +} from '@/modules/serverinvites/repositories/serverInvites' import { BaseError } from '@/modules/shared/errors/base' import { ServerRoles } from '@speckle/shared' @@ -75,9 +76,10 @@ export const adminInviteList = async ( args: CollectionQueryArgs ): Promise> => { const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null + // TODO: injection const [totalCount, inviteItems] = await Promise.all([ - countServerInvites(args.query), - queryServerInvites(args.query, args.limit, parsedCursor) + countServerInvitesFactory({ db })(args.query), + queryServerInvitesFactory({ db })(args.query, args.limit, parsedCursor) ]) const items = inviteItems.map((invite: ServerInviteRecord) => { return { diff --git a/packages/server/modules/core/services/streams/management.ts b/packages/server/modules/core/services/streams/management.ts index 1cada4ddd..83ca399d7 100644 --- a/packages/server/modules/core/services/streams/management.ts +++ b/packages/server/modules/core/services/streams/management.ts @@ -11,9 +11,7 @@ import { StreamCreateInput, StreamRevokePermissionInput, StreamUpdateInput, - StreamUpdatePermissionInput, - TokenResourceIdentifier, - TokenResourceIdentifierType + StreamUpdatePermissionInput } from '@/modules/core/graph/generated/graphql' import { StreamRecord } from '@/modules/core/helpers/types' import { @@ -23,7 +21,10 @@ import { updateStream } from '@/modules/core/repositories/streams' import { createBranch } from '@/modules/core/services/branches' -import { inviteUsersToStream } from '@/modules/serverinvites/services/inviteCreationService' +import { + createAndSendInviteFactory, + inviteUsersToStreamFactory +} from '@/modules/serverinvites/services/inviteCreationService' import { StreamInvalidAccessError, StreamUpdateError @@ -35,12 +36,22 @@ import { isStreamCollaborator, removeStreamCollaborator } from '@/modules/core/services/streams/streamAccessService' -import { deleteAllStreamInvites } from '@/modules/serverinvites/repositories' import { ContextResourceAccessRules, isNewResourceAllowed } from '@/modules/core/helpers/token' import { authorizeResolver } from '@/modules/shared' +import { + deleteAllStreamInvitesFactory, + findResourceFactory, + findUserByTargetFactory, + insertInviteAndDeleteOldFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import db from '@/db/knex' +import { + TokenResourceIdentifier, + TokenResourceIdentifierType +} from '@/modules/core/domain/tokens/types' export async function createStreamReturnRecord( params: (StreamCreateInput | ProjectCreateInput) & { @@ -75,12 +86,14 @@ export async function createStreamReturnRecord( // Invite contributors? if (!isProjectCreateInput(params) && params.withContributors?.length) { - await inviteUsersToStream( - ownerId, - streamId, - params.withContributors, - ownerResourceAccessRules - ) + // TODO: should be injected in the resolver + await inviteUsersToStreamFactory({ + createAndSendInvite: createAndSendInviteFactory({ + findResource: findResourceFactory(), + findUserByTarget: findUserByTargetFactory(), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }) + }) + })(ownerId, streamId, params.withContributors, ownerResourceAccessRules) } // Save activity @@ -126,7 +139,9 @@ export async function deleteStreamAndNotify( // delay deletion by a bit so we can do auth checks await wait(250) + // TODO: use proper injection once we refactor this module // Delete after event so we can do authz + const deleteAllStreamInvites = deleteAllStreamInvitesFactory({ db }) await Promise.all([deleteAllStreamInvites(streamId), deleteStream(streamId)]) return true } diff --git a/packages/server/modules/core/services/streams/streamAccessService.js b/packages/server/modules/core/services/streams/streamAccessService.js index 75d78db85..1e1c281ad 100644 --- a/packages/server/modules/core/services/streams/streamAccessService.js +++ b/packages/server/modules/core/services/streams/streamAccessService.js @@ -41,7 +41,7 @@ async function isStreamCollaborator(userId, streamId) { * @param {string} [userId] If falsy, will throw for non-public streams * @param {string} streamId * @param {string} [expectedRole] Defaults to reviewer - * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits] + * @param {import('@/modules/serverinvites/services/operations').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits] * @returns {Promise} */ async function validateStreamAccess( @@ -90,7 +90,7 @@ async function validateStreamAccess( * @param {string} streamId * @param {string} userId ID of user that should be removed * @param {string} removedById ID of user that is doing the removing - * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [removerResourceAccessRules] Resource access rules (if any) for the user doing the removing + * @param {import('@/modules/serverinvites/services/operations').TokenResourceIdentifier[] | undefined | null} [removerResourceAccessRules] Resource access rules (if any) for the user doing the removing */ async function removeStreamCollaborator( streamId, @@ -134,7 +134,7 @@ async function removeStreamCollaborator( * @param {string} userId ID of user who is being added * @param {string} role * @param {string} addedById ID of user who is adding the new collaborator - * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [adderResourceAccessRules] Resource access rules (if any) for the user doing the adding + * @param {import('@/modules/serverinvites/services/operations').TokenResourceIdentifier[] | undefined | null} [adderResourceAccessRules] Resource access rules (if any) for the user doing the adding * @param {{ * fromInvite?: boolean, * }} param4 diff --git a/packages/server/modules/core/services/users.js b/packages/server/modules/core/services/users.js index 19a250ac1..b7c46e95e 100644 --- a/packages/server/modules/core/services/users.js +++ b/packages/server/modules/core/services/users.js @@ -17,7 +17,6 @@ const Acl = () => ServerAclSchema.knex() const { deleteStream } = require('./streams') const { LIMITED_USER_FIELDS } = require('@/modules/core/helpers/userHelper') -const { deleteAllUserInvites } = require('@/modules/serverinvites/repositories') const { getUserByEmail } = require('@/modules/core/repositories/users') const { UsersEmitter, UsersEvents } = require('@/modules/core/events/usersEmitter') const { pick } = require('lodash') @@ -228,12 +227,16 @@ module.exports = { }) }, - async deleteUser(id) { - //TODO: check for the last admin user to survive - dbLogger.info('Deleting user ' + id) - await _ensureAtleastOneAdminRemains(id) - const streams = await knex.raw( - ` + /** + * @param {{ deleteAllUserInvites: import('@/modules/serverinvites/domain/operations').DeleteAllUserInvites }} param0 + */ + deleteUser({ deleteAllUserInvites }) { + return async (id) => { + //TODO: check for the last admin user to survive + dbLogger.info('Deleting user ' + id) + await _ensureAtleastOneAdminRemains(id) + const streams = await knex.raw( + ` -- Get the stream ids with only this user as owner SELECT "resourceId" as id FROM ( @@ -251,16 +254,18 @@ module.exports = { ) AS soc WHERE cnt = 1 `, - [id] - ) - for (const i in streams.rows) { - await deleteStream({ streamId: streams.rows[i].id }) + [id] + ) + for (const i in streams.rows) { + await deleteStream({ streamId: streams.rows[i].id }) + } + + // Delete all invites (they don't have a FK, so we need to do this manually) + // THIS REALLY SHOULD BE A REACTION TO THE USER DELETED EVENT EMITTED HER + await deleteAllUserInvites(id) + + return await Users().where({ id }).del() } - - // Delete all invites (they don't have a FK, so we need to do this manually) - await deleteAllUserInvites(id) - - return await Users().where({ id }).del() }, /** diff --git a/packages/server/modules/core/services/users/adminUsersListService.js b/packages/server/modules/core/services/users/adminUsersListService.js index 85d0d381c..21383bf28 100644 --- a/packages/server/modules/core/services/users/adminUsersListService.js +++ b/packages/server/modules/core/services/users/adminUsersListService.js @@ -1,9 +1,5 @@ const { countUsers, getUsers } = require('@/modules/core/services/users') const { resolveTarget } = require('@/modules/serverinvites/helpers/inviteHelper') -const { - countServerInvites, - findServerInvites -} = require('@/modules/serverinvites/repositories') const { clamp } = require('lodash') /** @@ -64,21 +60,27 @@ function sanitizeParams(params) { /** * Get total users & invites that we can find using these params - * @param {PaginationParams} params - * @returns {Promise} + * @param {{ countServerInvites: import('@/modules/serverinvites/domain/operations').CountServerInvites}} param0 */ -async function getTotalCounts(params) { - const { query } = params +function getTotalCounts({ countServerInvites }) { + /** + * Get total users & invites that we can find using these params + * @param {PaginationParams} params + * @returns {Promise} + */ + return async (params) => { + const { query } = params - const [userCount, inviteCount] = await Promise.all([ - // Actual users - countUsers(query), - // Invites - countServerInvites(query) - ]) - const totalCount = userCount + inviteCount + const [userCount, inviteCount] = await Promise.all([ + // Actual users + countUsers(query), + // Invites + countServerInvites(query) + ]) + const totalCount = userCount + inviteCount - return { userCount, inviteCount, totalCount } + return { userCount, inviteCount, totalCount } + } } /** @@ -118,7 +120,7 @@ function mapUserToListItem(user) { } /** - * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/domain/types').ServerInviteRecord} invite * @returns {AdminUsersListItem} */ function mapInviteToListItem(invite) { @@ -134,49 +136,62 @@ function mapInviteToListItem(invite) { } /** - * Retrieve all list items from DB and convert them to the target model - * @param {PaginationParams} params - * @param {TotalCounts} counts - * @returns {Promise} + * + * @param {{findServerInvites: import('@/modules/serverinvites/domain/operations').FindServerInvites}} param0 */ -async function retrieveItems(params, counts) { - const { invitesFilter, usersFilter } = resolveLimitsAndOffsets(params, counts) - const { query } = params +function retrieveItems({ findServerInvites }) { + /** + * Retrieve all list items from DB and convert them to the target model + * @param {PaginationParams} params + * @param {TotalCounts} counts + * @returns {Promise} + */ + return async (params, counts) => { + const { invitesFilter, usersFilter } = resolveLimitsAndOffsets(params, counts) + const { query } = params - const [invites, users] = await Promise.all([ - // Invites - invitesFilter - ? findServerInvites(query, invitesFilter.limit, invitesFilter.offset) - : [], - // Users - usersFilter ? getUsers(usersFilter.limit, usersFilter.offset, query) : [] - ]) + const [invites, users] = await Promise.all([ + // Invites + invitesFilter + ? findServerInvites(query, invitesFilter.limit, invitesFilter.offset) + : [], + // Users + usersFilter ? getUsers(usersFilter.limit, usersFilter.offset, query) : [] + ]) - return [ - // Invites first - ...invites.map((i) => mapInviteToListItem(i)), - // Users after - ...users.map((u) => mapUserToListItem(u)) - ] + return [ + // Invites first + ...invites.map((i) => mapInviteToListItem(i)), + // Users after + ...users.map((u) => mapUserToListItem(u)) + ] + } } /** - * Resolve admin users list data using the specified filter params - * @param {PaginationParams} params - * @returns {Promise} + * + * @param {{ getTotalCounts: (params: PaginationParams) => Promise, findServerInvites: import('@/modules/serverinvites/domain/operations').FindServerInvites}} param0 */ -async function getAdminUsersListCollection(params) { - sanitizeParams(params) +function getAdminUsersListCollection({ getTotalCounts, findServerInvites }) { + /** + * Resolve admin users list data using the specified filter params + * @param {PaginationParams} params + * @returns {Promise} + */ + return async (params) => { + sanitizeParams(params) - const totalCounts = await getTotalCounts(params) - const items = await retrieveItems(params, totalCounts) + const totalCounts = await getTotalCounts(params) + const items = await retrieveItems({ findServerInvites })(params, totalCounts) - return { - items, - totalCount: totalCounts.totalCount + return { + items, + totalCount: totalCounts.totalCount + } } } module.exports = { - getAdminUsersListCollection + getAdminUsersListCollection, + getTotalCounts } diff --git a/packages/server/modules/core/tests/users.spec.js b/packages/server/modules/core/tests/users.spec.js index 0c9a487e8..23d79f32f 100644 --- a/packages/server/modules/core/tests/users.spec.js +++ b/packages/server/modules/core/tests/users.spec.js @@ -226,7 +226,7 @@ describe('Actors & Tokens @user-services', () => { authorId: ballmerUserId }) - await deleteUser(ballmerUserId) + await deleteUser({ deleteAllUserInvites: async () => true })(ballmerUserId) if ((await getStream({ streamId: soloOwnerStream.id })) !== undefined) { assert.fail('user stream not deleted') @@ -263,7 +263,7 @@ describe('Actors & Tokens @user-services', () => { it('Should not delete the last admin user', async () => { try { - await deleteUser(myTestActor.id) + await deleteUser({ deleteAllUserInvites: async () => true })(myTestActor.id) assert.fail('boom') } catch (err) { expect(err.message).to.equal( diff --git a/packages/server/modules/core/tests/usersAdmin.spec.js b/packages/server/modules/core/tests/usersAdmin.spec.js index de9162192..919fbc398 100644 --- a/packages/server/modules/core/tests/usersAdmin.spec.js +++ b/packages/server/modules/core/tests/usersAdmin.spec.js @@ -48,7 +48,7 @@ describe('User admin @user-services', () => { expect(await countUsers()).to.equal(2) - await deleteUser(actorId) + await deleteUser({ deleteAllUserInvites: async () => true })(actorId) expect(await countUsers()).to.equal(1) }) diff --git a/packages/server/modules/core/tests/usersAdminList.spec.ts b/packages/server/modules/core/tests/usersAdminList.spec.ts index ac389e18c..de30065b6 100644 --- a/packages/server/modules/core/tests/usersAdminList.spec.ts +++ b/packages/server/modules/core/tests/usersAdminList.spec.ts @@ -3,7 +3,7 @@ import { truncateTables } from '@/test/hooks' import { createUser } from '@/modules/core/services/users' import { createStream } from '@/modules/core/services/streams' import { times, clamp } from 'lodash' -import { createInviteDirectly } from '@/test/speckle-helpers/inviteHelper' +import { createInviteDirectlyFactory } from '@/test/speckle-helpers/inviteHelper' import { getAdminUsersList } from '@/test/graphql/users' import { buildApolloServer } from '@/app' import { addLoadersToCtx } from '@/modules/shared/middleware' @@ -12,10 +12,13 @@ import { expect } from 'chai' import { ApolloServer } from 'apollo-server-express' import { Optional } from '@/modules/shared/helpers/typeHelper' import { wait } from '@speckle/shared' +import db from '@/db/knex' // To ensure that the invites are created in the correct order, we need to wait a bit between each creation const WAIT_TIMEOUT = 5 +const createInviteDirectly = createInviteDirectlyFactory({ db }) + function randomEl(array: T[]): T { return array[Math.floor(Math.random() * array.length)] } diff --git a/packages/server/modules/serverinvites/domain/operations.ts b/packages/server/modules/serverinvites/domain/operations.ts new file mode 100644 index 000000000..673e39e2b --- /dev/null +++ b/packages/server/modules/serverinvites/domain/operations.ts @@ -0,0 +1,114 @@ +import { ServerRoles, StreamRoles } from '@speckle/shared' +import { UserWithOptionalRole } from '@/modules/core/repositories/users' +import { ResourceTargets } from '@/modules/serverinvites/helpers/inviteHelper' +import { + ServerInviteRecord, + StreamInviteRecord +} from '@/modules/serverinvites/domain/types' +import { StreamWithOptionalRole } from '@/modules/core/repositories/streams' + +export type QueryAllUserStreamInvites = ( + userId: string +) => Promise + +type FindStreamInviteArgs = { + target?: string | null + token?: string | null + inviteId?: string | null +} + +export type FindStreamInvite = ( + streamId: string, + args: FindStreamInviteArgs +) => Promise + +export type FindUserByTarget = (target: string) => Promise + +type Invite = { + resourceId?: string | null + resourceTarget?: typeof ResourceTargets.Streams | null +} + +export type FindResource = ( + args: Invite +) => Promise + +type ServerInviteRecordInsertModel = Pick< + ServerInviteRecord, + | 'id' + | 'target' + | 'inviterId' + | 'message' + | 'resourceTarget' + | 'resourceId' + | 'role' + | 'token' + | 'serverRole' +> + +export type InsertInviteAndDeleteOld = ( + invite: ServerInviteRecordInsertModel, + alternateTargets: string[] +) => Promise + +export type FindServerInvite = ( + email?: string, + token?: string +) => Promise + +export type QueryAllStreamInvites = (streamId: string) => Promise + +export type DeleteAllStreamInvites = (streamId: string) => Promise + +export type DeleteServerOnlyInvites = (email?: string) => Promise + +export type UpdateAllInviteTargets = ( + oldTargets?: string | string[], + newTarget?: string +) => Promise + +export type DeleteStreamInvite = (inviteId?: string) => Promise + +export type CountServerInvites = (searchQuery: string | null) => Promise + +export type FindServerInvites = ( + searchQuery: string | null, + limit: number, + offset: number +) => Promise + +export type QueryServerInvites = ( + searchQuery: string | null, + limit: number, + cursor: Date | null +) => Promise + +export type FindInvite = (inviteId?: string) => Promise + +export type DeleteInvite = (inviteId?: string) => Promise + +export type DeleteInvitesByTarget = ( + targets?: string | string[], + resourceTarget?: string, + resourceId?: string +) => Promise + +export type QueryInvites = ( + inviteIds?: readonly string[] +) => Promise + +export type DeleteAllUserInvites = (userId: string) => Promise + +export type FindInviteByToken = ( + inviteToken?: string +) => Promise + +export type CreateInviteParams = { + target: string + inviterId: string + message?: string | null + resourceTarget?: typeof ResourceTargets.Streams + resourceId?: string + role?: StreamRoles + serverRole?: ServerRoles | null +} diff --git a/packages/server/modules/serverinvites/helpers/types.ts b/packages/server/modules/serverinvites/domain/types.ts similarity index 100% rename from packages/server/modules/serverinvites/helpers/types.ts rename to packages/server/modules/serverinvites/domain/types.ts diff --git a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts index be5b1af36..749537649 100644 --- a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts +++ b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts @@ -5,15 +5,19 @@ import { buildUserTarget, ResourceTargets } from '@/modules/serverinvites/helpers/inviteHelper' -import { createAndSendInvite } from '@/modules/serverinvites/services/inviteCreationService' import { - createStreamInviteAndNotify, + createAndSendInviteFactory, + resendInviteEmailFactory +} from '@/modules/serverinvites/services/inviteCreationService' +import { + createStreamInviteAndNotifyFactory, useStreamInviteAndNotify } from '@/modules/serverinvites/services/management' import { cancelStreamInvite, resendInvite, - deleteInvite + deleteInvite, + finalizeStreamInvite } from '@/modules/serverinvites/services/inviteProcessingService' import { getServerInviteForToken, @@ -23,24 +27,48 @@ import { import { authorizeResolver } from '@/modules/shared' import { chunk } from 'lodash' import { Resolvers } from '@/modules/core/graph/generated/graphql' +import db from '@/db/knex' +import { ServerRoles, StreamRoles } from '@speckle/shared' +import { + deleteInvitesByTargetFactory, + deleteStreamInviteFactory, + findInviteFactory, + findResourceFactory, + findServerInviteFactory, + findStreamInviteFactory, + findUserByTargetFactory, + insertInviteAndDeleteOldFactory, + queryAllUserStreamInvitesFactory, + deleteInviteFactory +} from '@/modules/serverinvites/repositories/serverInvites' export = { Query: { async streamInvite(_parent, args, context) { const { streamId, token } = args - return await getUserPendingStreamInvite(streamId, context.userId, token) + return getUserPendingStreamInvite({ + findStreamInvite: findStreamInviteFactory({ db }) + })(streamId, context.userId, token) }, async projectInvite(_parent, args, context) { const { projectId, token } = args - return await getUserPendingStreamInvite(projectId, context.userId, token) + return await getUserPendingStreamInvite({ + findStreamInvite: findStreamInviteFactory({ db }) + })(projectId, context.userId, token) }, async streamInvites(_parent, _args, context) { const { userId } = context - return await getUserPendingStreamInvites(userId!) + return getUserPendingStreamInvites({ + queryAllUserStreamInvites: queryAllUserStreamInvitesFactory({ + db + }) + })(userId!) }, async serverInviteByToken(_parent, args) { const { token } = args - return await getServerInviteForToken(token) + return getServerInviteForToken({ + findServerInvite: findServerInviteFactory({ db }) + })(token) } }, ServerInvite: { @@ -54,12 +82,16 @@ export = { }, Mutation: { async serverInviteCreate(_parent, args, context) { - await createAndSendInvite( + await createAndSendInviteFactory({ + findResource: findResourceFactory(), + findUserByTarget: findUserByTargetFactory(), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }) + })( { target: args.input.email, inviterId: context.userId!, message: args.input.message, - serverRole: args.input.serverRole + serverRole: args.input.serverRole as null | undefined | ServerRoles }, context.resourceAccessRules ) @@ -74,11 +106,15 @@ export = { Roles.Stream.Owner, context.resourceAccessRules ) - await createStreamInviteAndNotify( - args.input, - context.userId!, - context.resourceAccessRules - ) + await createStreamInviteAndNotifyFactory({ + createAndSendInvite: createAndSendInviteFactory({ + findResource: findResourceFactory(), + findUserByTarget: findUserByTargetFactory(), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ + db + }) + }) + })(args.input, context.userId!, context.resourceAccessRules) return true }, @@ -98,12 +134,18 @@ export = { for (const paramsBatchArray of batches) { await Promise.all( paramsBatchArray.map((params) => - createAndSendInvite( + createAndSendInviteFactory({ + findResource: findResourceFactory(), + findUserByTarget: findUserByTargetFactory(), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ + db + }) + })( { target: params.email, inviterId: context.userId!, message: params.message, - serverRole: params.serverRole + serverRole: params.serverRole as ServerRoles | null | undefined }, context.resourceAccessRules ) @@ -134,15 +176,21 @@ export = { paramsBatchArray.map((params) => { const { email, userId, message, streamId, role, serverRole } = params const target = (userId ? buildUserTarget(userId) : email)! - return createAndSendInvite( + return createAndSendInviteFactory({ + findResource: findResourceFactory(), + findUserByTarget: findUserByTargetFactory(), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ + db + }) + })( { target, inviterId: context.userId!, message, resourceTarget: ResourceTargets.Streams, resourceId: streamId, - role: role || Roles.Stream.Contributor, - serverRole + role: (role as unknown as StreamRoles) || Roles.Stream.Contributor, + serverRole: serverRole as ServerRoles | null | undefined }, context.resourceAccessRules ) @@ -154,7 +202,12 @@ export = { }, async streamInviteUse(_parent, args, ctx) { - await useStreamInviteAndNotify(args, ctx.userId!, ctx.resourceAccessRules) + await useStreamInviteAndNotify({ + finalizeStreamInvite: finalizeStreamInvite({ + findStreamInvite: findStreamInviteFactory({ db }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }) + }) + })(args, ctx.userId!, ctx.resourceAccessRules) return true }, @@ -163,7 +216,10 @@ export = { const { userId, resourceAccessRules } = ctx await authorizeResolver(userId, streamId, Roles.Stream.Owner, resourceAccessRules) - await cancelStreamInvite(streamId, inviteId) + await cancelStreamInvite({ + findStreamInvite: findStreamInviteFactory({ db }), + deleteStreamInvite: deleteStreamInviteFactory({ db }) + })(streamId, inviteId) return true }, @@ -171,7 +227,13 @@ export = { async inviteResend(_parent, args) { const { inviteId } = args - await resendInvite(inviteId) + await resendInvite({ + findInvite: findInviteFactory({ db }), + resendInviteEmail: resendInviteEmailFactory({ + findResource: findResourceFactory(), + findUserByTarget: findUserByTargetFactory() + }) + })(inviteId) return true }, @@ -179,7 +241,10 @@ export = { async inviteDelete(_parent, args) { const { inviteId } = args - await deleteInvite(inviteId) + await deleteInvite({ + findInvite: findInviteFactory({ db }), + deleteInvite: deleteInviteFactory({ db }) + })(inviteId) return true } diff --git a/packages/server/modules/serverinvites/helpers/inviteHelper.js b/packages/server/modules/serverinvites/helpers/inviteHelper.js index e1ad6e077..d06fc5aa9 100644 --- a/packages/server/modules/serverinvites/helpers/inviteHelper.js +++ b/packages/server/modules/serverinvites/helpers/inviteHelper.js @@ -4,8 +4,8 @@ const ResourceTargets = Object.freeze({ /** * @typedef {{ - * resourceTarget?: string, - * resourceId?: string + * resourceTarget?: string | null, + * resourceId?: string | null * }} InviteResourceData */ @@ -77,7 +77,7 @@ function buildUserTarget(userId) { /** * Resolve a display name for the user being invited - * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite + * @param {import('@/modules/serverinvites/domain/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/repositories/index.js b/packages/server/modules/serverinvites/repositories/index.js deleted file mode 100644 index 95d8712ab..000000000 --- a/packages/server/modules/serverinvites/repositories/index.js +++ /dev/null @@ -1,398 +0,0 @@ -const { ServerInvites, Streams, knex } = require('@/modules/core/dbSchema') -const { getUserByEmail, getUser } = require('@/modules/core/repositories/users') -const { ResourceNotResolvableError } = require('@/modules/serverinvites/errors') -const { - resolveTarget, - ResourceTargets, - buildUserTarget, - isServerInvite -} = require('@/modules/serverinvites/helpers/inviteHelper') -const { uniq, isArray } = require('lodash') -const { getStream } = require('@/modules/core/repositories/streams') - -/** - * Use this wherever you're retrieving invites, not necessarily where you're writing to them - */ -const getInvitesBaseQuery = (sort = 'asc') => { - const q = ServerInvites.knex().select(ServerInvites.cols) - - // join just to ensure we don't retrieve invalid invites - q.leftJoin(Streams.name, (j) => { - j.onNotNull(ServerInvites.col.resourceId) - .andOnVal(ServerInvites.col.resourceTarget, ResourceTargets.Streams) - .andOn(Streams.col.id, ServerInvites.col.resourceId) - }).where((w1) => { - w1.whereNull(ServerInvites.col.resourceId).orWhereNotNull(Streams.col.id) - }) - - q.orderBy(ServerInvites.col.createdAt, sort) - - return q -} - -/** - * - * Resolve resource from invite - * @param {import('@/modules/serverinvites/helpers/inviteHelper').InviteResourceData} invite - * @returns {Promise} - */ -async function getResource(invite) { - if (isServerInvite(invite)) return null - - const { resourceId, resourceTarget } = invite - if (resourceTarget === ResourceTargets.Streams) { - return await getStream({ streamId: resourceId }) - } else { - throw new ResourceNotResolvableError('Unexpected invite resource type') - } -} - -/** - * Try to find a user using the target value - * @param {string} target - * @returns {Promise} - */ -async function getUserFromTarget(target) { - const { userEmail, userId } = resolveTarget(target) - return userEmail - ? await getUserByEmail(userEmail, { withRole: true }) - : await getUser(userId, { withRole: true }) -} - -/** - * Insert a new invite and delete the old ones - * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite - * @param {string[]} alternateTargets If there are alternate targets for the same user - * (e.g. user ID & email), you can specify them to ensure those will be cleaned up - * also - */ -async function insertInviteAndDeleteOld(invite, alternateTargets = []) { - const allTargets = uniq( - [invite.target, ...alternateTargets].map((t) => t.toLowerCase()) - ) - - // Delete old - await ServerInvites.knex() - .where({ - [ServerInvites.col.resourceId]: invite.resourceId || null, - [ServerInvites.col.resourceTarget]: invite.resourceTarget || null - }) - .whereIn(ServerInvites.col.target, allTargets) - .delete() - - // Insert new - invite.target = invite.target.toLowerCase() // Extra safety cause our schema is case sensitive - await ServerInvites.knex().insert(invite) -} - -/** - * Retrieve a valid server invite for the specified target - * @param {string|undefined} email Email address - * @param {string|undefined} token Specify an invite token, if you're looking for - * a specific invite. For backwards compatibility purposes, the token can also just be the invite ID. - * @returns {import('@/modules/serverinvites/helpers/types').ServerInviteRecord | null} - */ -async function getServerInvite(email = undefined, token = undefined) { - if (!email && !token) return null - - const q = getInvitesBaseQuery() - - if (email) { - q.andWhere({ - [ServerInvites.col.target]: email.toLowerCase() - }) - } - - if (token) { - q.andWhere(ServerInvites.col.token, token) - } - - return await q.first() -} - -/** - * Use up/delete all server-only for the specified email - * @param {string} email - */ -async function deleteServerOnlyInvites(email) { - if (!email) return - - await ServerInvites.knex() - .where({ - [ServerInvites.col.target]: email.toLowerCase(), - [ServerInvites.col.resourceTarget]: null - }) - .delete() -} - -/** - * Update all invites that have the specified targets to have a new target value - * @param {string[]|string} oldTargets A single target or an array of targets - * @param {string} newTarget - * @returns - */ -async function updateAllInviteTargets(oldTargets, newTarget) { - if (!oldTargets || !newTarget) return - oldTargets = isArray(oldTargets) ? oldTargets : [oldTargets] - oldTargets = oldTargets.map((t) => t.toLowerCase()) - if (!oldTargets.length) return - - // PostgreSQL doesn't support aliases in update calls for some reason... - const ServerInvitesCols = ServerInvites.with({ withoutTablePrefix: true }).col - await ServerInvites.knex() - .whereIn(ServerInvitesCols.target, oldTargets) - .update(ServerInvitesCols.target, newTarget.toLowerCase()) -} - -/** - * Get all pending stream invites - * @param {string} streamId - * @returns {Promise} - */ -async function getAllStreamInvites(streamId) { - if (!streamId) return [] - - const q = getInvitesBaseQuery().where({ - [ServerInvites.col.resourceTarget]: ResourceTargets.Streams, - [ServerInvites.col.resourceId]: streamId - }) - - return await q -} - -/** - * Get all invitations to streams that the specified user has - * @param {string} userId - * @returns {Promise} - */ -async function getAllUserStreamInvites(userId) { - if (!userId) return [] - const target = buildUserTarget(userId) - - const q = getInvitesBaseQuery().where({ - [ServerInvites.col.target]: target, - [ServerInvites.col.resourceTarget]: ResourceTargets.Streams - }) - - return await q -} - -/** - * Retrieve a stream invite for the specified target, token or both. - * Note: Either the target, inviteId or token must be set - * @param {string} streamId - * @param {{target?: string|null|undefined, token?: string|null|undefined, inviteId?: string|null|undefined}} [param2] - * @returns {Promise} - */ -async function getStreamInvite( - streamId, - { target = null, token = null, inviteId = null } = {} -) { - if (!target && !token && !inviteId) return null - - const q = getInvitesBaseQuery().where({ - [ServerInvites.col.resourceTarget]: ResourceTargets.Streams, - [ServerInvites.col.resourceId]: streamId - }) - - if (target) { - q.andWhere({ - [ServerInvites.col.target]: target.toLowerCase() - }) - } else if (inviteId) { - q.andWhere({ - [ServerInvites.col.id]: inviteId - }) - } else if (token) { - q.andWhere({ - [ServerInvites.col.token]: token - }) - } - - return await q.first() -} - -/** - * Delete a single stream invite - * @param {string} inviteId - */ -async function deleteStreamInvite(inviteId) { - if (!inviteId) return - - await ServerInvites.knex() - .where({ - [ServerInvites.col.id]: inviteId, - [ServerInvites.col.resourceTarget]: ResourceTargets.Streams - }) - .delete() -} - -function findServerInvitesBaseQuery(searchQuery, sort) { - const q = getInvitesBaseQuery(sort) - - if (searchQuery) { - // TODO: Is this safe from SQL injection? - q.andWhere(ServerInvites.col.target, 'ILIKE', `%${searchQuery}%`) - } - - // Not an invite for an already registered user - q.andWhere(ServerInvites.col.target, 'NOT ILIKE', '@%') - - return q -} - -/** - * Count all server invites, optionally filtering out unnecessary ones with the search query - * @param {string|null} searchQuery - * @returns {Promise} - */ -async function countServerInvites(searchQuery) { - const q = findServerInvitesBaseQuery(searchQuery) - const [count] = await knex().count().from(q.as('sq1')) - return parseInt(count.count) -} - -/** - * - * @param {string|null} searchQuery - * @param {number} limit - * @param {number} offset - * @returns {Promise} - */ -async function findServerInvites(searchQuery, limit, offset) { - const q = findServerInvitesBaseQuery(searchQuery) - q.limit(limit).offset(offset) - - return await q -} - -/** - * - * @param {string|null} searchQuery - * @param {number} limit - * @param {Date|null} cursor - * @returns {Promise} - */ -async function queryServerInvites(searchQuery, limit, cursor) { - const q = findServerInvitesBaseQuery(searchQuery, 'desc').limit(limit) - - if (cursor) q.where(ServerInvites.col.createdAt, '<', cursor.toISOString()) - return await q -} - -/** - * Retrieve a specific invite (irregardless of the type) - * @param {string} inviteId - * @returns {Promise} - */ -async function getInvite(inviteId) { - if (!inviteId) return null - return await getInvitesBaseQuery().where(ServerInvites.col.id, inviteId).first() -} - -/** - * Retrieve a specific invite (irregardless of the type) by the token - * @param {string} inviteId - * @returns {Promise} - */ -async function getInviteByToken(inviteToken) { - if (!inviteToken) return null - return await getInvitesBaseQuery().where(ServerInvites.col.token, inviteToken).first() -} - -/** - * Delete a specific invite (irregardless of the type) - * @param {string} inviteId - * @returns {Promise} - */ -async function deleteInvite(inviteId) { - if (!inviteId) return false - await ServerInvites.knex().where(ServerInvites.col.id, inviteId).delete() - return true -} - -/** - * Delete invites by target - useful when there are potentially duplicate invites that need cleaning up - * (e.g. same target, but multiple inviters) - * @param {string|string[]} targets - * @param {string} resourceTarget - * @param {string} resourceId - * @returns - */ -async function deleteInvitesByTarget(targets, resourceTarget, resourceId) { - if (!targets) return false - targets = isArray(targets) ? targets : [targets] - if (!targets.length) return - - resourceTarget = resourceTarget || null - resourceId = resourceId || null - - await ServerInvites.knex() - .where({ - [ServerInvites.col.resourceTarget]: resourceTarget, - [ServerInvites.col.resourceId]: resourceId - }) - .whereIn(ServerInvites.col.target, targets) - .delete() - - return true -} - -/** - * Delete all invites that target the specified user - * @param {string} userId - * @returns {Promise} - */ -async function deleteAllUserInvites(userId) { - if (!userId) return false - await ServerInvites.knex() - .where(ServerInvites.col.target, buildUserTarget(userId)) - .delete() - return true -} - -/** - * Delete all invites for the specified stream - * @param {string} streamId - * @returns {Promise} - */ -async function deleteAllStreamInvites(streamId) { - if (!streamId) return false - await ServerInvites.knex() - .where(ServerInvites.col.resourceId, streamId) - .andWhere(ServerInvites.col.resourceTarget, ResourceTargets.Streams) - .delete() - return true -} - -/** - * Get all invites by IDs - * @returns {Promise} - */ -async function getInvites(inviteIds) { - if (!inviteIds?.length) return [] - return await getInvitesBaseQuery().whereIn(ServerInvites.col.id, inviteIds) -} - -module.exports = { - insertInviteAndDeleteOld, - getServerInvite, - deleteServerOnlyInvites, - getUserFromTarget, - updateAllInviteTargets, - getStreamInvite, - deleteStreamInvite, - getAllStreamInvites, - countServerInvites, - findServerInvites, - getInvite, - deleteInvite, - deleteInvitesByTarget, - deleteAllUserInvites, - getResource, - getAllUserStreamInvites, - getInvites, - getInviteByToken, - deleteAllStreamInvites, - queryServerInvites -} diff --git a/packages/server/modules/serverinvites/repositories/serverInvites.ts b/packages/server/modules/serverinvites/repositories/serverInvites.ts new file mode 100644 index 000000000..c71396e39 --- /dev/null +++ b/packages/server/modules/serverinvites/repositories/serverInvites.ts @@ -0,0 +1,352 @@ +import { ServerInvites, Streams } from '@/modules/core/dbSchema' +import { + getUserByEmail, + getUser, + UserWithOptionalRole +} from '@/modules/core/repositories/users' +import { ResourceNotResolvableError } from '@/modules/serverinvites/errors' +import { + resolveTarget, + ResourceTargets, + buildUserTarget, + isServerInvite +} from '@/modules/serverinvites/helpers/inviteHelper' +import { uniq } from 'lodash' +import { StreamWithOptionalRole, getStream } from '@/modules/core/repositories/streams' +import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' +import { Knex } from 'knex' +import { + CountServerInvites, + DeleteAllStreamInvites, + DeleteAllUserInvites, + DeleteInvite, + DeleteInvitesByTarget, + DeleteServerOnlyInvites, + DeleteStreamInvite, + FindInvite, + FindInviteByToken, + FindServerInvite, + FindServerInvites, + FindStreamInvite, + InsertInviteAndDeleteOld, + QueryAllStreamInvites, + QueryAllUserStreamInvites, + QueryInvites, + QueryServerInvites, + UpdateAllInviteTargets +} from '@/modules/serverinvites/domain/operations' + +/** + * Use this wherever you're retrieving invites, not necessarily where you're writing to them + */ +const buildInvitesBaseQuery = + ({ db }: { db: Knex }) => + (sort: 'asc' | 'desc' = 'asc') => { + // join just to ensure we don't retrieve invalid invites + const q = db(ServerInvites.name) + .select(ServerInvites.cols) + .leftJoin(Streams.name, (j) => { + j.onNotNull(ServerInvites.col.resourceId) + .andOnVal(ServerInvites.col.resourceTarget, ResourceTargets.Streams) + .andOn(Streams.col.id, ServerInvites.col.resourceId) + }) + .where((w1) => { + w1.whereNull(ServerInvites.col.resourceId).orWhereNotNull(Streams.col.id) + }) + .orderBy(ServerInvites.col.createdAt, sort) + return q + } + +/** + * Resolve resource from invite + */ +export const findResourceFactory = + () => + async (invite: { + resourceId?: string | null + resourceTarget?: typeof ResourceTargets.Streams | null + }): Promise => { + if (isServerInvite(invite)) return null + + const { resourceId, resourceTarget } = invite + if (resourceTarget === ResourceTargets.Streams) { + return getStream({ streamId: resourceId ?? undefined }) + } else { + throw new ResourceNotResolvableError('Unexpected invite resource type') + } + } + +/** + * Try to find a user using the target value + */ +export const findUserByTargetFactory = + () => + (target: string): Promise => { + const { userEmail, userId } = resolveTarget(target) + return userEmail + ? getUserByEmail(userEmail, { withRole: true }) + : getUser(userId!, { withRole: true }) + } + +/** + * Insert a new invite and delete the old ones + * If there are alternate targets for the same user + * (e.g. user ID & email), you can specify them to ensure those will be cleaned up + * also + */ +export const insertInviteAndDeleteOldFactory = + ({ db }: { db: Knex }): InsertInviteAndDeleteOld => + async (invite, alternateTargets = []) => { + const allTargets = uniq( + [invite.target, ...alternateTargets].map((t) => t.toLowerCase()) + ) + + // Delete old + await db(ServerInvites.name) + .where({ + [ServerInvites.col.resourceId]: invite.resourceId || null, + [ServerInvites.col.resourceTarget]: invite.resourceTarget || null + }) + .whereIn(ServerInvites.col.target, allTargets) + .delete() + + // Insert new + invite.target = invite.target.toLowerCase() // Extra safety cause our schema is case sensitive + return db(ServerInvites.name).insert(invite) + } + +/** + * Get all invitations to streams that the specified user has + */ +export const queryAllUserStreamInvitesFactory = + ({ db }: { db: Knex }): QueryAllUserStreamInvites => + async (userId) => { + if (!userId) return [] + const target = buildUserTarget(userId) + + return buildInvitesBaseQuery({ db })().where({ + [ServerInvites.col.target]: target, + [ServerInvites.col.resourceTarget]: ResourceTargets.Streams + }) + } + +/** + * Retrieve a stream invite for the specified target, token or both. + * Note: Either the target, inviteId or token must be set + */ +export const findStreamInviteFactory = + ({ db }: { db: Knex }): FindStreamInvite => + async (streamId, { target = null, token = null, inviteId = null } = {}) => { + if (!target && !token && !inviteId) return null + + const q = buildInvitesBaseQuery({ db })().where({ + [ServerInvites.col.resourceTarget]: ResourceTargets.Streams, + [ServerInvites.col.resourceId]: streamId + }) + + if (target) { + q.andWhere({ + [ServerInvites.col.target]: target.toLowerCase() + }) + } else if (inviteId) { + q.andWhere({ + [ServerInvites.col.id]: inviteId + }) + } else if (token) { + q.andWhere({ + [ServerInvites.col.token]: token + }) + } + + return q.first() + } + +export const findServerInviteFactory = + ({ db }: { db: Knex }): FindServerInvite => + async (email, token) => { + if (!email && !token) return null + + const q = buildInvitesBaseQuery({ db })() + + if (email) { + q.andWhere({ + [ServerInvites.col.target]: email.toLowerCase() + }) + } + + if (token) { + q.andWhere(ServerInvites.col.token, token) + } + + return q.first() + } + +export const queryAllStreamInvitesFactory = + ({ db }: { db: Knex }): QueryAllStreamInvites => + async (streamId) => { + if (!streamId) return [] + + return buildInvitesBaseQuery({ db })().where({ + [ServerInvites.col.resourceTarget]: ResourceTargets.Streams, + [ServerInvites.col.resourceId]: streamId + }) + } + +export const deleteAllStreamInvitesFactory = + ({ db }: { db: Knex }): DeleteAllStreamInvites => + async (streamId) => { + if (!streamId) return false + await db(ServerInvites.name) + .where(ServerInvites.col.resourceId, streamId) + .andWhere(ServerInvites.col.resourceTarget, ResourceTargets.Streams) + .delete() + return true + } + +export const deleteServerOnlyInvitesFactory = + ({ db }: { db: Knex }): DeleteServerOnlyInvites => + async (email) => { + if (!email) return + + return db(ServerInvites.name) + .where({ + [ServerInvites.col.target]: email.toLowerCase(), + [ServerInvites.col.resourceTarget]: null + }) + .delete() + } + +export const updateAllInviteTargetsFactory = + ({ db }: { db: Knex }): UpdateAllInviteTargets => + async (oldTargets, newTarget) => { + if (!oldTargets || !newTarget) return + oldTargets = Array.isArray(oldTargets) ? oldTargets : [oldTargets] + oldTargets = oldTargets.map((t) => t.toLowerCase()) + if (!oldTargets.length) return + + // PostgreSQL doesn't support aliases in update calls for some reason... + const ServerInvitesCols = ServerInvites.with({ withoutTablePrefix: true }).col + return db(ServerInvites.name) + .whereIn(ServerInvitesCols.target, oldTargets) + .update(ServerInvitesCols.target, newTarget.toLowerCase()) + } + +export const deleteStreamInviteFactory = + ({ db }: { db: Knex }): DeleteStreamInvite => + async (inviteId) => { + if (!inviteId) return + + return db(ServerInvites.name) + .where({ + [ServerInvites.col.id]: inviteId, + [ServerInvites.col.resourceTarget]: ResourceTargets.Streams + }) + .delete() + } + +const findServerInvitesBaseQueryFactory = + ({ db }: { db: Knex }) => + (searchQuery: string | null, sort: 'asc' | 'desc' = 'asc'): Knex.QueryBuilder => { + const q = buildInvitesBaseQuery({ db })(sort) + + if (searchQuery) { + // TODO: Is this safe from SQL injection? + q.andWhere(ServerInvites.col.target, 'ILIKE', `%${searchQuery}%`) + } + + // Not an invite for an already registered user + q.andWhere(ServerInvites.col.target, 'NOT ILIKE', '@%') + return q + } + +export const countServerInvitesFactory = + ({ db }: { db: Knex }): CountServerInvites => + async (searchQuery) => { + const q = findServerInvitesBaseQueryFactory({ db })(searchQuery) + const [count] = await db() + .count() + .from((q as Knex.QueryBuilder).as('sq1')) + return parseInt(count.count.toString()) + } + +export const findServerInvitesFactory = + ({ db }: { db: Knex }): FindServerInvites => + async (searchQuery, limit, offset) => { + const q = findServerInvitesBaseQueryFactory({ db })( + searchQuery + ) as Knex.QueryBuilder + return q.limit(limit).offset(offset) as Promise + } + +export const queryServerInvitesFactory = + ({ db }: { db: Knex }): QueryServerInvites => + async (searchQuery, limit, cursor) => { + const q = findServerInvitesBaseQueryFactory({ db })(searchQuery, 'desc') + q.limit(limit) + + if (cursor) q.where(ServerInvites.col.createdAt, '<', cursor.toISOString()) + return q + } + +export const findInviteFactory = + ({ db }: { db: Knex }): FindInvite => + async (inviteId) => { + if (!inviteId) return null + return buildInvitesBaseQuery({ db })().where(ServerInvites.col.id, inviteId).first() + } + +export const deleteInviteFactory = + ({ db }: { db: Knex }): DeleteInvite => + async (inviteId) => { + if (!inviteId) return false + await db(ServerInvites.name).where(ServerInvites.col.id, inviteId).delete() + return true + } + +/** + * Delete invites by target - useful when there are potentially duplicate invites that need cleaning up + * (e.g. same target, but multiple inviters) + */ +export const deleteInvitesByTargetFactory = + ({ db }: { db: Knex }): DeleteInvitesByTarget => + async (targets, resourceTarget, resourceId) => { + if (!targets) return false + targets = Array.isArray(targets) ? targets : [targets] + if (!targets.length) return false + + await db(ServerInvites.name) + .where({ + [ServerInvites.col.resourceTarget]: resourceTarget, + [ServerInvites.col.resourceId]: resourceId + }) + .whereIn(ServerInvites.col.target, targets) + .delete() + + return true + } + +export const queryInvitesFactory = + ({ db }: { db: Knex }): QueryInvites => + async (inviteIds) => { + if (!inviteIds?.length) return [] + return buildInvitesBaseQuery({ db })().whereIn(ServerInvites.col.id, inviteIds) + } + +export const deleteAllUserInvitesFactory = + ({ db }: { db: Knex }): DeleteAllUserInvites => + async (userId) => { + if (!userId) return false + await db(ServerInvites.name) + .where(ServerInvites.col.target, buildUserTarget(userId)) + .delete() + return true + } + +export const findInviteByTokenFactory = + ({ db }: { db: Knex }): FindInviteByToken => + async (inviteToken) => { + if (!inviteToken) return null + return buildInvitesBaseQuery({ db })() + .where(ServerInvites.col.token, inviteToken) + .first() + } diff --git a/packages/server/modules/serverinvites/services/buildEmailContents.ts b/packages/server/modules/serverinvites/services/buildEmailContents.ts new file mode 100644 index 000000000..7491bd1a9 --- /dev/null +++ b/packages/server/modules/serverinvites/services/buildEmailContents.ts @@ -0,0 +1,273 @@ +import { ServerInfo, UserRecord } from '@/modules/core/helpers/types' +import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' +import { getServerInfo } from '@/modules/core/services/generic' +import { + ResourceTargets, + isServerInvite +} from '@/modules/serverinvites/helpers/inviteHelper' +import { + getRegistrationRoute, + getStreamRoute +} from '@/modules/core/helpers/routeHelper' +import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' +import { InviteCreateValidationError } from '@/modules/serverinvites/errors' +import { + EmailTemplateParams, + renderEmail +} from '@/modules/emails/services/emailRendering' +import sanitizeHtml from 'sanitize-html' +import { CreateInviteParams } from '@/modules/serverinvites/domain/operations' + +type InviteOrInputParams = + | CreateInviteParams + | Pick< + ServerInviteRecord, + | 'id' + | 'target' + | 'inviterId' + | 'message' + | 'resourceTarget' + | 'resourceId' + | 'role' + | 'token' + | 'serverRole' + > + +/** + * Build invite email contents + */ +export async function buildEmailContents( + invite: Pick< + ServerInviteRecord, + | 'id' + | 'target' + | 'inviterId' + | 'message' + | 'resourceTarget' + | 'resourceId' + | 'role' + | 'token' + | 'serverRole' + >, + inviter: UserRecord, + resource?: { name: string } | null, + targetUser?: UserRecord | null +): Promise<{ to: string; subject: string; text: string; html: string }> { + const email = targetUser ? targetUser.email : invite.target + const serverInfo = await getServerInfo() + const inviteLink = buildInviteLink(invite) + const resourceName = resolveResourceName(invite, resource) + + const templateParams = buildEmailTemplateParams( + invite, + inviter, + serverInfo, + inviteLink, + resourceName + ) + const subject = buildEmailSubject(invite, inviter, resourceName) + + const { text, html } = await renderEmail( + templateParams, + serverInfo, + targetUser || null + ) + return { + to: email, + subject, + text, + html + } +} + +function buildInviteLink( + invite: Pick< + ServerInviteRecord, + | 'id' + | 'target' + | 'inviterId' + | 'message' + | 'resourceTarget' + | 'resourceId' + | 'role' + | 'token' + | 'serverRole' + > +) { + const { resourceTarget, resourceId, token } = invite + + if (isServerInvite(invite)) { + return new URL( + `${getRegistrationRoute()}?token=${token}`, + getFrontendOrigin() + ).toString() + } + + if (resourceTarget === 'streams') { + return new URL( + `${getStreamRoute(resourceId!)}?token=${token}&accept=true`, + getFrontendOrigin() + ).toString() + } else { + throw new InviteCreateValidationError('Unexpected resource target type') + } +} + +/** + * Build the email subject line + */ +function buildEmailSubject( + invite: Pick< + ServerInviteRecord, + | 'id' + | 'target' + | 'inviterId' + | 'message' + | 'resourceTarget' + | 'resourceId' + | 'role' + | 'token' + | 'serverRole' + >, + inviter: UserRecord, + resourceName: string | null +): string { + const { resourceTarget } = invite + + if (isServerInvite(invite)) { + return 'Speckle Invitation from ' + inviter.name + } + + if (resourceTarget === 'streams') { + return `${inviter.name} wants to share the project "${resourceName}" on Speckle with you` + } else { + throw new InviteCreateValidationError('Unexpected resource target type') + } +} + +function buildEmailTemplateParams( + invite: Pick< + ServerInviteRecord, + | 'id' + | 'target' + | 'inviterId' + | 'message' + | 'resourceTarget' + | 'resourceId' + | 'role' + | 'token' + | 'serverRole' + >, + inviter: UserRecord, + serverInfo: ServerInfo, + inviteLink: string, + resourceName: string | null +): EmailTemplateParams { + return { + mjml: buildMjmlPreamble(invite, inviter, serverInfo, resourceName), // TODO: what happens when resourceName is null? + text: buildTextPreamble(invite, inviter, serverInfo, resourceName), // TODO: what happens when resourceName is null? + cta: { + title: 'Accept the invitation', + url: inviteLink + } + } +} + +function buildMjmlPreamble( + invite: Pick< + ServerInviteRecord, + | 'id' + | 'target' + | 'inviterId' + | 'message' + | 'resourceTarget' + | 'resourceId' + | 'role' + | 'token' + | 'serverRole' + >, + inviter: UserRecord, + serverInfo: ServerInfo, + resourceName: string | null +) { + const { message } = invite + const forServer = isServerInvite(invite) + + const dynamicText = forServer + ? `join the ${serverInfo.name} Speckle Server` + : `become a collaborator on the ${resourceName} project` + + const bodyStart = ` + + Hello! +
+
+ ${inviter.name} has just sent you this invitation to ${dynamicText}! + ${message ? inviter.name + ' said: "' + message + '"' : ''} +
+ ` + + return { + bodyStart, + bodyEnd: + 'Feel free to ignore this invite if you do not know the person sending it.' + } +} + +function buildTextPreamble( + invite: Pick< + ServerInviteRecord, + | 'id' + | 'target' + | 'inviterId' + | 'message' + | 'resourceTarget' + | 'resourceId' + | 'role' + | 'token' + | 'serverRole' + >, + inviter: UserRecord, + serverInfo: ServerInfo, + resourceName: string | null +) { + const { message } = invite + const forServer = isServerInvite(invite) + + const dynamicText = forServer + ? `join the ${serverInfo.name} Speckle Server` + : `become a collaborator on the "${resourceName}" project` + + const bodyStart = `Hello! + +${inviter.name} has just sent you this invitation to ${dynamicText}! + +${message ? inviter.name + ' said: "' + sanitizeMessage(message, true) + '"' : ''}` + + return { + bodyStart, + bodyEnd: 'Feel free to ignore this invite if you do not know the person sending it.' + } +} + +/** + * Sanitize message that potentially has HTML in it + */ +function sanitizeMessage(message: string, stripAll: boolean = false): string { + return sanitizeHtml(message, { + allowedTags: stripAll ? [] : ['b', 'i', 'em', 'strong'] + }) +} + +function resolveResourceName( + params: InviteOrInputParams, + resource?: { name: string } | null +) { + const { resourceTarget } = params + + if (resourceTarget === ResourceTargets.Streams) { + return resource?.name || null + } + + return null +} diff --git a/packages/server/modules/serverinvites/services/inviteCreationService.js b/packages/server/modules/serverinvites/services/inviteCreationService.js deleted file mode 100644 index bf681ed52..000000000 --- a/packages/server/modules/serverinvites/services/inviteCreationService.js +++ /dev/null @@ -1,486 +0,0 @@ -const crs = require('crypto-random-string') -const { getServerInfo } = require('@/modules/core/services/generic') -const { sendEmail } = require('@/modules/emails') -const { InviteCreateValidationError } = require('@/modules/serverinvites/errors') -const { authorizeResolver } = require('@/modules/shared') -const { - insertInviteAndDeleteOld, - getUserFromTarget, - getResource -} = require('@/modules/serverinvites/repositories') -const { getStreamCollaborator } = require('@/modules/core/repositories/streams') -const { Roles } = require('@/modules/core/helpers/mainConstants') -const sanitizeHtml = require('sanitize-html') -const { - getRegistrationRoute, - getStreamRoute -} = require('@/modules/core/helpers/routeHelper') -const { - isServerInvite, - resolveTarget, - buildUserTarget, - ResourceTargets -} = require('@/modules/serverinvites/helpers/inviteHelper') -const { getUsers, getUser } = require('@/modules/core/repositories/users') -const { - addStreamInviteSentOutActivity -} = require('@/modules/activitystream/services/streamActivity') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { getFrontendOrigin } = require('@/modules/shared/helpers/envHelper') - -/** - * @typedef {{ - * target: string; - * inviterId: string; - * message?: string | null; - * resourceTarget?: string; - * resourceId?: string; - * role?: string; - * serverRole?: string | null - * }} CreateInviteParams - */ - -/** - * @typedef {CreateInviteParams|import('@/modules/serverinvites/helpers/types').ServerInviteRecord} InviteOrInputParams - */ - -/** - * @param {InviteOrInputParams} params - * @param {Object | null} resource invite resource (e.g. stream) - */ -function resolveResourceName(params, resource) { - const { resourceTarget } = params - - if (resourceTarget === ResourceTargets.Streams) { - return resource.name - } - - return null -} - -/** - * Validate that the inviter has access to the resources he's trying to invite people to - * @param {CreateInviteParams} params - * @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter - * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits - */ -async function validateInviter(params, inviter, inviterResourceAccessLimits) { - const { resourceId, resourceTarget } = params - if (!inviter) throw new InviteCreateValidationError('Invalid inviter') - if (isServerInvite(params)) return - - try { - if (resourceTarget === ResourceTargets.Streams) { - await authorizeResolver( - inviter.id, - resourceId, - Roles.Stream.Owner, - inviterResourceAccessLimits - ) - } else { - throw new InviteCreateValidationError('Unexpected resource target type') - } - } catch (e) { - throw new InviteCreateValidationError( - "Inviter doesn't have proper access to the resource", - { cause: e } - ) - } -} - -/** - * Validate the target - * @param {CreateInviteParams} params - * @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser - */ -function validateTargetUser(params, targetUser) { - const { target } = params - const { userId } = resolveTarget(target) - - if (userId && !targetUser) { - throw new InviteCreateValidationError('Attempting to invite an invalid user') - } - - if (isServerInvite(params) && targetUser) { - throw new InviteCreateValidationError( - 'This email is already associated with an account on this server' - ) - } -} - -/** - * Validate the target resource - * @param {CreateInviteParams} params - * @param {Object | null} resource - * @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser Target user, if one exists in our DB - */ -async function validateResource(params, resource, targetUser) { - const { resourceId, resourceTarget, role } = params - - if (resourceId && !resource) { - throw new InviteCreateValidationError("Couldn't resolve invite resource") - } - - if (resourceTarget === ResourceTargets.Streams) { - if (targetUser) { - // Check if user isn't already associated with the stream - const isStreamCollaborator = !!(await getStreamCollaborator( - resourceId, - targetUser.id - )) - if (isStreamCollaborator) { - throw new InviteCreateValidationError( - 'The target user is already a collaborator of the specified project' - ) - } - } - - if (!Object.values(Roles.Stream).includes(role)) { - throw new InviteCreateValidationError('Unexpected stream invite role') - } - } -} - -/** - * Validate invite creation input data - * @param {CreateInviteParams} params - * @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter Inviter, resolved from DB - * @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser Target user, if one exists in our DB - * @param {Object | null} resource Invite resource (stream or null) - * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits - */ -async function validateInput( - params, - inviter, - targetUser, - resource, - inviterResourceAccessLimits -) { - const { message } = params - - // validate inviter & invitee - validateTargetUser(params, targetUser) - await validateInviter(params, inviter, inviterResourceAccessLimits) - - // validate resource - await validateResource(params, resource, targetUser) - - // check if message too long - if (message) { - if (message.length >= 1024) { - throw new InviteCreateValidationError('Personal message too long') - } - } -} - -/** - * Sanitize message that potentially has HTML in it - * @param {string} message - * @returns {string} - */ -function sanitizeMessage(message, stripAll = false) { - return sanitizeHtml(message, { - allowedTags: stripAll ? [] : [('b', 'i', 'em', 'strong')] - }) -} - -/** - * Build the email subject line - * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite - * @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter - * @param {string | null} resourceName - * @returns {string} - */ -function buildEmailSubject(invite, inviter, resourceName) { - const { resourceTarget } = invite - - if (isServerInvite(invite)) { - return 'Speckle Invitation from ' + inviter.name - } - - if (resourceTarget === 'streams') { - return `${inviter.name} wants to share the project "${resourceName}" on Speckle with you` - } else { - throw new InviteCreateValidationError('Unexpected resource target type') - } -} - -/** - * Build invite link URL - * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite - * @returns {string} - */ -function buildInviteLink(invite) { - const { resourceTarget, resourceId, token } = invite - - if (isServerInvite(invite)) { - return new URL( - `${getRegistrationRoute()}?token=${token}`, - getFrontendOrigin() - ).toString() - } - - if (resourceTarget === 'streams') { - return new URL( - `${getStreamRoute(resourceId)}?token=${token}&accept=true`, - getFrontendOrigin() - ).toString() - } else { - throw new InviteCreateValidationError('Unexpected resource target type') - } -} - -function buildMjmlPreamble(invite, inviter, serverInfo, resourceName) { - const { message } = invite - const forServer = isServerInvite(invite) - - const dynamicText = forServer - ? `join the ${serverInfo.name} Speckle Server` - : `become a collaborator on the ${resourceName} project` - - const bodyStart = ` - - Hello! -
-
- ${inviter.name} has just sent you this invitation to ${dynamicText}! - ${message ? inviter.name + ' said: "' + message + '"' : ''} -
- ` - - return { - bodyStart, - bodyEnd: - 'Feel free to ignore this invite if you do not know the person sending it.' - } -} - -function buildTextPreamble(invite, inviter, serverInfo, resourceName) { - const { message } = invite - const forServer = isServerInvite(invite) - - const dynamicText = forServer - ? `join the ${serverInfo.name} Speckle Server` - : `become a collaborator on the "${resourceName}" project` - - const bodyStart = `Hello! - -${inviter.name} has just sent you this invitation to ${dynamicText}! - -${message ? inviter.name + ' said: "' + sanitizeMessage(message, true) + '"' : ''}` - - return { - bodyStart, - bodyEnd: 'Feel free to ignore this invite if you do not know the person sending it.' - } -} - -/** - * @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 - * @returns {import('@/modules/emails/services/emailRendering').EmailTemplateParams} - */ -function buildEmailTemplateParams( - invite, - inviter, - serverInfo, - inviteLink, - resourceName -) { - return { - mjml: buildMjmlPreamble(invite, inviter, serverInfo, resourceName), - text: buildTextPreamble(invite, inviter, serverInfo, resourceName), - cta: { - title: 'Accept the invitation', - url: inviteLink - } - } -} - -/** - * Build invite email contents - * @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 - * @returns {Promise<{to: string, subject: string, text: string, html: string}>} - */ -async function buildEmailContents(invite, inviter, targetUser, resource) { - const email = targetUser ? targetUser.email : invite.target - const serverInfo = await getServerInfo() - const inviteLink = buildInviteLink(invite) - const resourceName = resolveResourceName(invite, resource) - - const templateParams = buildEmailTemplateParams( - invite, - inviter, - serverInfo, - inviteLink, - resourceName - ) - const subject = buildEmailSubject(invite, inviter, resourceName) - - const { text, html } = await renderEmail( - templateParams, - serverInfo, - targetUser || null - ) - return { - to: email, - subject, - text, - html - } -} - -/** - * Create and send out an invite - * @param {CreateInviteParams} params - * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits - * @returns {Promise} The ID of the created invite - */ -async function createAndSendInvite(params, inviterResourceAccessLimits) { - const { inviterId, resourceTarget, resourceId, role, serverRole } = params - let { message, target } = params - - const [inviter, targetUser, resource, serverInfo] = await Promise.all([ - getUser(inviterId, { withRole: true }), - getUserFromTarget(target), - getResource(params), - getServerInfo() - ]) - - // if target user found, always use the user ID - if (targetUser) target = buildUserTarget(targetUser.id) - const { userEmail, userId } = resolveTarget(target) - - // validate inputs - await validateInput( - params, - inviter, - targetUser, - resource, - inviterResourceAccessLimits - ) - - // Sanitize msg - // TODO: We should just use TipTap here - if (message) { - message = sanitizeMessage(message) - } - - // validate server role - if (serverRole && !Object.values(Roles.Server).includes(serverRole)) { - throw new InviteCreateValidationError('Invalid server role') - } - if (inviter.role !== Roles.Server.Admin && serverRole === Roles.Server.Admin) { - throw new InviteCreateValidationError( - 'Only server admins can assign the admin server role' - ) - } - if (serverRole === Roles.Server.Guest && !serverInfo.guestModeEnabled) { - throw new InviteCreateValidationError('Guest mode is not enabled on this server') - } - if (targetUser && targetUser.role === Roles.Server.Guest) { - if (role === Roles.Stream.Owner) { - throw new InviteCreateValidationError('Guest users cannot be owners of projects') - } - } - - // write to DB - const invite = { - id: crs({ length: 20 }), - target, - inviterId, - message, - resourceTarget, - resourceId, - role, - token: crs({ length: 50 }), - serverRole - } - await insertInviteAndDeleteOld( - invite, - targetUser ? [targetUser.email, buildUserTarget(targetUser.id)] : [] - ) - - // generate and send email - const emailParams = await buildEmailContents(invite, inviter, targetUser, resource) - - // send email and create activity stream item, if stream invite - await Promise.all([ - sendEmail(emailParams), - ...(resourceTarget === ResourceTargets.Streams - ? [ - addStreamInviteSentOutActivity({ - streamId: resourceId, - inviterId, - inviteTargetEmail: userEmail, - inviteTargetId: userId - }) - ] - : []) - ]) - - return { - inviteId: invite.id, - token: invite.token - } -} - -/** - * Re-send existing invite email - * @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite - */ -async function resendInviteEmail(invite) { - const { inviterId, target } = invite - - const [inviter, targetUser, resource] = await Promise.all([ - getUser(inviterId), - getUserFromTarget(target), - getResource(invite) - ]) - - const emailParams = await buildEmailContents(invite, inviter, targetUser, resource) - await sendEmail(emailParams) -} - -/** - * Invite users to be contributors for the specified stream - * @param {string} inviterId - * @param {string} streamId - * @param {string[]} userIds - * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits - * @returns {Promise} - */ -async function inviteUsersToStream( - inviterId, - streamId, - userIds, - inviterResourceAccessLimits -) { - const users = await getUsers(userIds) - if (!users.length) return false - - const inviteParamsArray = users.map((u) => ({ - target: buildUserTarget(u.id), - inviterId, - resourceTarget: ResourceTargets.Streams, - resourceId: streamId, - role: Roles.Stream.Contributor - })) - - await Promise.all( - inviteParamsArray.map((p) => createAndSendInvite(p, inviterResourceAccessLimits)) - ) - - return true -} - -module.exports = { - createAndSendInvite, - resendInviteEmail, - inviteUsersToStream -} diff --git a/packages/server/modules/serverinvites/services/inviteCreationService.ts b/packages/server/modules/serverinvites/services/inviteCreationService.ts new file mode 100644 index 000000000..79b18cbfa --- /dev/null +++ b/packages/server/modules/serverinvites/services/inviteCreationService.ts @@ -0,0 +1,197 @@ +import crs from 'crypto-random-string' +import { getServerInfo } from '@/modules/core/services/generic' +import emailsModule from '@/modules/emails' +import { InviteCreateValidationError } from '@/modules/serverinvites/errors' +import { Roles } from '@/modules/core/helpers/mainConstants' +import sanitizeHtml from 'sanitize-html' +import { + resolveTarget, + buildUserTarget, + ResourceTargets +} from '@/modules/serverinvites/helpers/inviteHelper' +import { getUser, getUsers } from '@/modules/core/repositories/users' +import { addStreamInviteSentOutActivity } from '@/modules/activitystream/services/streamActivity' +import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' +import { + FindResource, + FindUserByTarget, + InsertInviteAndDeleteOld +} from '@/modules/serverinvites/domain/operations' +import { validateInput } from '@/modules/serverinvites/services/validation' +import { buildEmailContents } from '@/modules/serverinvites/services/buildEmailContents' +import { + CreateAndSendInvite, + ResendInviteEmail +} from '@/modules/serverinvites/services/operations' + +/** + * Create and send out an invite + */ +export const createAndSendInviteFactory = + ({ + findUserByTarget, + findResource, + insertInviteAndDeleteOld + }: { + findUserByTarget: FindUserByTarget + findResource: FindResource + insertInviteAndDeleteOld: InsertInviteAndDeleteOld + }): CreateAndSendInvite => + async (params, inviterResourceAccessLimits?) => { + const { inviterId, resourceTarget, resourceId, role, serverRole } = params + let { message, target } = params + + const [inviter, targetUser, resource, serverInfo] = await Promise.all([ + getUser(inviterId, { withRole: true }), + findUserByTarget(target), + findResource(params), + getServerInfo() + ]) + + // if target user found, always use the user ID + if (targetUser) target = buildUserTarget(targetUser.id)! + const { userEmail, userId } = resolveTarget(target) + + // validate inputs + await validateInput( + params, + inviter, + resource, + targetUser, + inviterResourceAccessLimits + ) + + // Sanitize msg + // TODO: We should just use TipTap here + if (message) { + message = sanitizeMessage(message) + } + + // validate server role + if (serverRole && !Object.values(Roles.Server).includes(serverRole)) { + throw new InviteCreateValidationError('Invalid server role') + } + if (inviter?.role !== Roles.Server.Admin && serverRole === Roles.Server.Admin) { + throw new InviteCreateValidationError( + 'Only server admins can assign the admin server role' + ) + } + if (serverRole === Roles.Server.Guest && !serverInfo.guestModeEnabled) { + throw new InviteCreateValidationError('Guest mode is not enabled on this server') + } + if (targetUser && targetUser.role === Roles.Server.Guest) { + if (role === Roles.Stream.Owner) { + throw new InviteCreateValidationError( + 'Guest users cannot be owners of projects' + ) + } + } + + // write to DB + const invite = { + id: crs({ length: 20 }), + target, + inviterId, + message: message ?? null, + resourceTarget: resourceTarget ?? null, + resourceId: resourceId ?? null, + role: role ?? null, + token: crs({ length: 50 }), + serverRole: serverRole ?? null + } + await insertInviteAndDeleteOld( + invite, + targetUser ? [targetUser.email, buildUserTarget(targetUser.id)!] : [] + ) + + // generate and send email + const emailParams = await buildEmailContents(invite, inviter!, resource, targetUser) + + // send email and create activity stream item, if stream invite + await Promise.all([ + emailsModule.sendEmail(emailParams), + ...(resourceTarget === ResourceTargets.Streams + ? [ + addStreamInviteSentOutActivity({ + streamId: resourceId!, // TODO: check null + inviterId, + inviteTargetEmail: userEmail!, // TODO: this should be properly typed + inviteTargetId: userId! // TODO: this should be properly typed + }) + ] + : []) + ]) + + return { + inviteId: invite.id, + token: invite.token + } + } + +/** + * Sanitize message that potentially has HTML in it + */ +function sanitizeMessage(message: string, stripAll: boolean = false) { + return sanitizeHtml(message, { + allowedTags: stripAll ? [] : ['b', 'i', 'em', 'strong'] + }) +} + +/** + * Re-send existing invite email + */ +export const resendInviteEmailFactory = + ({ + findResource, + findUserByTarget + }: { + findResource: FindResource + findUserByTarget: FindUserByTarget + }): ResendInviteEmail => + async (invite) => { + const { inviterId, target } = invite + + const [inviter, targetUser, resource] = await Promise.all([ + getUser(inviterId), + findUserByTarget(target), + findResource( + invite as { + resourceId?: string | null + resourceTarget?: typeof ResourceTargets.Streams | null + } + ) + ]) + + // TODO: check nullable inviter + const emailParams = await buildEmailContents(invite, inviter!, resource, targetUser) + await emailsModule.sendEmail(emailParams) + } + +/** + * Invite users to be contributors for the specified stream + */ +export const inviteUsersToStreamFactory = + ({ createAndSendInvite }: { createAndSendInvite: CreateAndSendInvite }) => + async ( + inviterId: string, + streamId: string, + userIds: string[], + inviterResourceAccessLimits?: TokenResourceIdentifier[] | null + ): Promise => { + const users = await getUsers(userIds) + if (!users.length) return false + + const inviteParamsArray = users.map((u) => ({ + target: buildUserTarget(u.id)!, + inviterId, + resourceTarget: ResourceTargets.Streams, + resourceId: streamId, + role: Roles.Stream.Contributor + })) + + await Promise.all( + inviteParamsArray.map((p) => createAndSendInvite(p, inviterResourceAccessLimits)) + ) + + return true + } diff --git a/packages/server/modules/serverinvites/services/inviteProcessingService.js b/packages/server/modules/serverinvites/services/inviteProcessingService.js deleted file mode 100644 index 34906798a..000000000 --- a/packages/server/modules/serverinvites/services/inviteProcessingService.js +++ /dev/null @@ -1,196 +0,0 @@ -const { Roles } = require('@/modules/core/helpers/mainConstants') -const { getStreamRoute } = require('@/modules/core/helpers/routeHelper') -const { NoInviteFoundError } = require('@/modules/serverinvites/errors') -const { - isStreamInvite, - buildUserTarget, - ResourceTargets -} = require('@/modules/serverinvites/helpers/inviteHelper') -const { - getServerInvite, - deleteServerOnlyInvites, - updateAllInviteTargets, - getStreamInvite, - deleteStreamInvite, - getInvite, - deleteInvite: deleteInviteFromDb, - deleteInvitesByTarget -} = require('@/modules/serverinvites/repositories') -const { - resendInviteEmail -} = require('@/modules/serverinvites/services/inviteCreationService') -const { - addOrUpdateStreamCollaborator -} = require('@/modules/core/services/streams/streamAccessService') -const { - addStreamInviteDeclinedActivity -} = require('@/modules/activitystream/services/streamActivity') -const { getFrontendOrigin } = require('@/modules/shared/helpers/envHelper') - -/** - * 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/helpers/types').ServerInviteRecord | undefined} invite - * @returns {string} - */ -function resolveAuthRedirectPath(invite) { - if (invite) { - const { resourceId } = invite - - if (isStreamInvite(invite)) { - return `${getStreamRoute(resourceId)}` - } - } - - // Fall-back to base URL (for server invites) - return getFrontendOrigin() -} - -/** - * 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/helpers/types').ServerInviteRecord} - */ -async function validateServerInvite(email, token) { - const invite = await getServerInvite(email, token) - if (!invite) { - throw new NoInviteFoundError( - token - ? "Wrong e-mail address or invite token. Make sure you're using the same e-mail address that received the invite." - : "Wrong e-mail address. Make sure you're using the same e-mail address that received the invite.", - { - info: { - email, - token - } - } - ) - } - - return invite -} - -/** - * Finalize server registration by deleting unnecessary invites and updating - * the remaining ones - * @param {string} email - * @param {string} userId - */ -async function finalizeInvitedServerRegistration(email, userId) { - // Delete all server-only invites for this email - await deleteServerOnlyInvites(email) - - // Update all remaining invites to use a userId target, not the e-mail - // (in case the user changes his e-mail right after) - await updateAllInviteTargets(email, buildUserTarget(userId)) -} - -/** - * Accept or decline a stream invite - * @param {boolean} accept - * @param {string} streamId - * @param {string} token - * @param {string} userId User who's accepting the invite - */ -async function finalizeStreamInvite(accept, streamId, token, userId) { - const invite = await getStreamInvite(streamId, { - token, - target: buildUserTarget(userId) - }) - if (!invite) { - throw new NoInviteFoundError('Attempted to finalize nonexistant stream invite', { - info: { - streamId, - token, - userId - } - }) - } - - // Invite found - accept or decline - if (accept) { - // Add access for user - const { role = Roles.Stream.Contributor, inviterId } = invite - await addOrUpdateStreamCollaborator(streamId, userId, role, inviterId, null, { - fromInvite: true - }) - - // Delete all invites to this stream - await deleteInvitesByTarget( - buildUserTarget(userId), - ResourceTargets.Streams, - streamId - ) - } else { - await addStreamInviteDeclinedActivity({ - streamId, - inviteTargetId: userId, - inviterId: invite.inviterId - }) - } - - // Delete all invites to this stream - await deleteInvitesByTarget( - buildUserTarget(userId), - ResourceTargets.Streams, - streamId - ) -} - -/** - * Cancel/decline a stream invite - * @param {string} streamId - * @param {string} inviteId - */ -async function cancelStreamInvite(streamId, inviteId) { - const invite = await getStreamInvite(streamId, { inviteId }) - if (!invite) { - throw new NoInviteFoundError('Attempted to process nonexistant stream invite', { - info: { - streamId, - inviteId - } - }) - } - - // Delete invite - await deleteStreamInvite(invite.id) -} - -/** - * Re-send pending invite e-mail, without creating a new invite - * @param {string} inviteId - */ -async function resendInvite(inviteId) { - const invite = await getInvite(inviteId) - if (!invite) { - throw new NoInviteFoundError('Attempted to re-send a nonexistant invite') - } - - await resendInviteEmail(invite) -} - -/** - * Delete pending invite - * @param {string} inviteId - */ -async function deleteInvite(inviteId) { - const invite = await getInvite(inviteId) - if (!invite) { - throw new NoInviteFoundError('Attempted to delete a nonexistant invite') - } - - await deleteInviteFromDb(invite.id) -} - -module.exports = { - validateServerInvite, - resolveAuthRedirectPath, - finalizeInvitedServerRegistration, - finalizeStreamInvite, - cancelStreamInvite, - resendInvite, - deleteInvite -} diff --git a/packages/server/modules/serverinvites/services/inviteProcessingService.ts b/packages/server/modules/serverinvites/services/inviteProcessingService.ts new file mode 100644 index 000000000..77a4defa3 --- /dev/null +++ b/packages/server/modules/serverinvites/services/inviteProcessingService.ts @@ -0,0 +1,212 @@ +import { Roles } from '@/modules/core/helpers/mainConstants' +import { getStreamRoute } from '@/modules/core/helpers/routeHelper' +import { NoInviteFoundError } from '@/modules/serverinvites/errors' +import { + isStreamInvite, + buildUserTarget, + ResourceTargets +} from '@/modules/serverinvites/helpers/inviteHelper' +import { addOrUpdateStreamCollaborator } from '@/modules/core/services/streams/streamAccessService' +import { addStreamInviteDeclinedActivity } from '@/modules/activitystream/services/streamActivity' +import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' +import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' +import { + DeleteInvite, + DeleteInvitesByTarget, + DeleteServerOnlyInvites, + DeleteStreamInvite, + FindInvite, + FindServerInvite, + FindStreamInvite, + UpdateAllInviteTargets +} from '@/modules/serverinvites/domain/operations' +import { + FinalizeStreamInvite, + ResendInviteEmail +} from '@/modules/serverinvites/services/operations' + +/** + * 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 + */ +export const resolveAuthRedirectPath = () => (invite?: ServerInviteRecord) => { + if (invite) { + const { resourceId } = invite + + if (isStreamInvite(invite)) { + // TODO: check nullability + return `${getStreamRoute(resourceId!)}` + } + } + + // Fall-back to base URL (for server invites) + return getFrontendOrigin() +} + +/** + * Validate that the new user has a valid invite for registering to the server + */ +export const validateServerInvite = + ({ findServerInvite }: { findServerInvite: FindServerInvite }) => + async (email: string, token: string): Promise => { + const invite = await findServerInvite(email, token) + if (!invite) { + throw new NoInviteFoundError( + token + ? "Wrong e-mail address or invite token. Make sure you're using the same e-mail address that received the invite." + : "Wrong e-mail address. Make sure you're using the same e-mail address that received the invite.", + { + info: { + email, + token + } + } + ) + } + + return invite + } + +/** + * Finalize server registration by deleting unnecessary invites and updating + * the remaining ones + */ +export const finalizeInvitedServerRegistration = + ({ + deleteServerOnlyInvites, + updateAllInviteTargets + }: { + deleteServerOnlyInvites: DeleteServerOnlyInvites + updateAllInviteTargets: UpdateAllInviteTargets + }) => + async (email: string, userId: string) => { + // Delete all server-only invites for this email + await deleteServerOnlyInvites(email) + + // Update all remaining invites to use a userId target, not the e-mail + // (in case the user changes his e-mail right after) + await updateAllInviteTargets(email, buildUserTarget(userId)!) + } + +/** + * Accept or decline a stream invite + */ +export const finalizeStreamInvite = + ({ + findStreamInvite, + deleteInvitesByTarget + }: { + findStreamInvite: FindStreamInvite + deleteInvitesByTarget: DeleteInvitesByTarget + }): FinalizeStreamInvite => + async (accept, streamId, token, userId) => { + const invite = await findStreamInvite(streamId, { + token, + target: buildUserTarget(userId) + }) + if (!invite) { + throw new NoInviteFoundError('Attempted to finalize nonexistant stream invite', { + info: { + streamId, + token, + userId + } + }) + } + + // Invite found - accept or decline + if (accept) { + // Add access for user + const { role = Roles.Stream.Contributor, inviterId } = invite + // TODO: check role nullability + await addOrUpdateStreamCollaborator(streamId, userId, role!, inviterId, null, { + fromInvite: true + }) + + // Delete all invites to this stream + await deleteInvitesByTarget( + buildUserTarget(userId)!, + ResourceTargets.Streams, + streamId + ) + } else { + await addStreamInviteDeclinedActivity({ + streamId, + inviteTargetId: userId, + inviterId: invite.inviterId + }) + } + + // Delete all invites to this stream + await deleteInvitesByTarget( + buildUserTarget(userId)!, + ResourceTargets.Streams, + streamId + ) + } + +/** + * Cancel/decline a stream invite + */ +export const cancelStreamInvite = + ({ + findStreamInvite, + deleteStreamInvite + }: { + findStreamInvite: FindStreamInvite + deleteStreamInvite: DeleteStreamInvite + }) => + async (streamId: string, inviteId: string) => { + const invite = await findStreamInvite(streamId, { + inviteId + }) + if (!invite) { + throw new NoInviteFoundError('Attempted to process nonexistant stream invite', { + info: { + streamId, + inviteId + } + }) + } + await deleteStreamInvite(invite.id) + } + +/** + * Re-send pending invite e-mail, without creating a new invite + */ +export const resendInvite = + ({ + findInvite, + resendInviteEmail + }: { + resendInviteEmail: ResendInviteEmail + findInvite: FindInvite + }) => + async (inviteId: string) => { + const invite = await findInvite(inviteId) + if (!invite) { + throw new NoInviteFoundError('Attempted to re-send a nonexistant invite') + } + await resendInviteEmail(invite) + } + +/** + * Delete pending invite + */ +export const deleteInvite = + ({ + findInvite, + deleteInvite + }: { + findInvite: FindInvite + deleteInvite: DeleteInvite + }) => + async (inviteId: string) => { + const invite = await findInvite(inviteId) + if (!invite) { + throw new NoInviteFoundError('Attempted to delete a nonexistant invite') + } + + await deleteInvite(invite.id) + } diff --git a/packages/server/modules/serverinvites/services/inviteRetrievalService.ts b/packages/server/modules/serverinvites/services/inviteRetrievalService.ts index 3b1bf80d7..0a7c9a48b 100644 --- a/packages/server/modules/serverinvites/services/inviteRetrievalService.ts +++ b/packages/server/modules/serverinvites/services/inviteRetrievalService.ts @@ -12,15 +12,15 @@ import { import { ServerInviteRecord, StreamInviteRecord -} from '@/modules/serverinvites/helpers/types' -import { - getAllStreamInvites, - getStreamInvite, - getAllUserStreamInvites, - getServerInvite -} from '@/modules/serverinvites/repositories' +} from '@/modules/serverinvites/domain/types' import { MaybeNullOrUndefined, Nullable, Roles } from '@speckle/shared' import { keyBy, uniq } from 'lodash' +import { + FindServerInvite, + FindStreamInvite, + QueryAllStreamInvites, + QueryAllUserStreamInvites +} from '@/modules/serverinvites/domain/operations' /** * The token field is intentionally ommited from this and only managed through the .token resolver @@ -67,82 +67,89 @@ async function getInvitationTargetUsers(invites: ServerInviteRecord[]) { /** * Get pending stream collaborators (invited, but not accepted) */ -export async function getPendingStreamCollaborators( - streamId: string -): Promise { - // Get all pending invites - const invites = await getAllStreamInvites(streamId) +export const getPendingStreamCollaborators = + ({ queryAllStreamInvites }: { queryAllStreamInvites: QueryAllStreamInvites }) => + async (streamId: string): Promise => { + // Get all pending invites + const invites = await queryAllStreamInvites(streamId) - // Get all target users, if any - const usersById = await getInvitationTargetUsers(invites) + // Get all target users, if any + const usersById = await getInvitationTargetUsers(invites) - // Build results - const results = [] - for (const invite of invites) { - /** @type {import("@/modules/core/helpers/userHelper").LimitedUserRecord} */ - let user - const { userId } = resolveTarget(invite.target) - if (userId && usersById[userId]) { - user = removePrivateFields(usersById[userId]) + // Build results + const results = [] + for (const invite of invites) { + let user: LimitedUserRecord | null = null + const { userId } = resolveTarget(invite.target) + if (userId && usersById[userId]) { + user = removePrivateFields(usersById[userId]) + } + + results.push(buildPendingStreamCollaboratorModel(invite, user)) } - results.push(buildPendingStreamCollaboratorModel(invite, user || null)) + return results } - return results -} - /** * Find a pending invitation to the specified stream for the specified user * Either the user ID or invite ID must be set */ -export async function getUserPendingStreamInvite( - streamId: string, - userId: MaybeNullOrUndefined, - token: MaybeNullOrUndefined -): Promise> { - if (!userId && !token) return null +export const getUserPendingStreamInvite = + ({ findStreamInvite }: { findStreamInvite: FindStreamInvite }) => + async ( + streamId: string, + userId: MaybeNullOrUndefined, + token: MaybeNullOrUndefined + ): Promise> => { + if (!userId && !token) return null - const invite = await getStreamInvite(streamId, { - target: buildUserTarget(userId), - token - }) - if (!invite) return null + const invite = await findStreamInvite(streamId, { + target: buildUserTarget(userId), + token + }) + if (!invite) return null - const targetUser = userId ? await getUser(userId) : null + // TODO: user repo should be injected + const targetUser = userId ? await getUser(userId) : null - return buildPendingStreamCollaboratorModel(invite, targetUser) -} + return buildPendingStreamCollaboratorModel(invite, targetUser) + } /** * Get all pending invitations to streams that this user has */ -export async function getUserPendingStreamInvites( - userId: string -): Promise { - if (!userId) return [] +export const getUserPendingStreamInvites = + ({ + queryAllUserStreamInvites + }: { + queryAllUserStreamInvites: QueryAllUserStreamInvites + }) => + async (userId: string): Promise => { + if (!userId) return [] - const targetUser = await getUser(userId) - if (!targetUser) { - throw new NoInviteFoundError('Nonexistant user specified') + // TODO: user repository should be injected + const targetUser = await getUser(userId) + if (!targetUser) { + throw new NoInviteFoundError('Nonexistant user specified') + } + + const invites = await queryAllUserStreamInvites(userId) + return invites.map((i) => buildPendingStreamCollaboratorModel(i, targetUser)) } - const invites = await getAllUserStreamInvites(userId) - return invites.map((i) => buildPendingStreamCollaboratorModel(i, targetUser)) -} +export const getServerInviteForToken = + ({ findServerInvite }: { findServerInvite: FindServerInvite }) => + async (token: string): Promise> => { + const invite = await findServerInvite(undefined, token) + if (!invite) return null -export async function getServerInviteForToken( - token: string -): Promise> { - const invite = await getServerInvite(undefined, token) - if (!invite) return null + const target = resolveTarget(invite.target) + if (!target.userEmail) return null - const target = resolveTarget(invite.target) - if (!target.userEmail) return null - - return { - id: invite.id, - invitedById: invite.inviterId, - email: target.userEmail + return { + id: invite.id, + invitedById: invite.inviterId, + email: target.userEmail + } } -} diff --git a/packages/server/modules/serverinvites/services/management.ts b/packages/server/modules/serverinvites/services/management.ts index 6a4f0a3c1..19ee6c874 100644 --- a/packages/server/modules/serverinvites/services/management.ts +++ b/packages/server/modules/serverinvites/services/management.ts @@ -1,25 +1,26 @@ -import { MaybeNullOrUndefined, Roles } from '@speckle/shared' +import { MaybeNullOrUndefined, Roles, ServerRoles, StreamRoles } from '@speckle/shared' import { MutationStreamInviteUseArgs, ProjectInviteCreateInput, ProjectInviteUseInput, - StreamInviteCreateInput, - TokenResourceIdentifier, - TokenResourceIdentifierType + StreamInviteCreateInput } from '@/modules/core/graph/generated/graphql' import { InviteCreateValidationError } from '@/modules/serverinvites/errors' import { buildUserTarget, ResourceTargets } from '@/modules/serverinvites/helpers/inviteHelper' -import { createAndSendInvite } from '@/modules/serverinvites/services/inviteCreationService' import { has } from 'lodash' -import { finalizeStreamInvite } from '@/modules/serverinvites/services/inviteProcessingService' import { ContextResourceAccessRules, isResourceAllowed } from '@/modules/core/helpers/token' import { StreamInvalidAccessError } from '@/modules/core/errors/stream' +import { + CreateAndSendInvite, + FinalizeStreamInvite +} from '@/modules/serverinvites/services/operations' +import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' type FullProjectInviteCreateInput = ProjectInviteCreateInput & { projectId: string } @@ -27,68 +28,72 @@ const isStreamInviteCreateInput = ( i: StreamInviteCreateInput | FullProjectInviteCreateInput ): i is StreamInviteCreateInput => has(i, 'streamId') -export async function createStreamInviteAndNotify( - input: StreamInviteCreateInput | FullProjectInviteCreateInput, - inviterId: string, - inviterResourceAccessRules: MaybeNullOrUndefined -) { - const { email, userId, role } = input +export const createStreamInviteAndNotifyFactory = + ({ createAndSendInvite }: { createAndSendInvite: CreateAndSendInvite }) => + async ( + input: StreamInviteCreateInput | FullProjectInviteCreateInput, + inviterId: string, + inviterResourceAccessRules: MaybeNullOrUndefined + ) => { + const { email, userId, role } = input - if (!email && !userId) { - throw new InviteCreateValidationError('Either email or userId must be specified') + if (!email && !userId) { + throw new InviteCreateValidationError('Either email or userId must be specified') + } + + const target = (userId ? buildUserTarget(userId) : email)! + await createAndSendInvite( + { + target, + inviterId, + resourceTarget: ResourceTargets.Streams, + resourceId: isStreamInviteCreateInput(input) ? input.streamId : input.projectId, + role: (role as StreamRoles) || Roles.Stream.Contributor, + message: isStreamInviteCreateInput(input) + ? input.message || undefined + : undefined, + serverRole: (input.serverRole as ServerRoles) || undefined + }, + inviterResourceAccessRules + ) } - const target = (userId ? buildUserTarget(userId) : email)! - await createAndSendInvite( - { - target, - inviterId, - resourceTarget: ResourceTargets.Streams, - resourceId: isStreamInviteCreateInput(input) ? input.streamId : input.projectId, - role: role || Roles.Stream.Contributor, - message: isStreamInviteCreateInput(input) - ? input.message || undefined - : undefined, - serverRole: input.serverRole || undefined - }, - inviterResourceAccessRules - ) -} - const isStreamInviteUseArgs = ( i: MutationStreamInviteUseArgs | ProjectInviteUseInput ): i is MutationStreamInviteUseArgs => has(i, 'streamId') -export async function useStreamInviteAndNotify( - input: MutationStreamInviteUseArgs | ProjectInviteUseInput, - userId: string, - userResourceAccessRules: ContextResourceAccessRules -) { - const { accept, token } = input +export const useStreamInviteAndNotify = + ({ finalizeStreamInvite }: { finalizeStreamInvite: FinalizeStreamInvite }) => + async ( + input: MutationStreamInviteUseArgs | ProjectInviteUseInput, + userId: string, + userResourceAccessRules: ContextResourceAccessRules + ) => { + const { accept, token } = input - if ( - !isResourceAllowed({ - resourceId: isStreamInviteUseArgs(input) ? input.streamId : input.projectId, - resourceType: TokenResourceIdentifierType.Project, - resourceAccessRules: userResourceAccessRules - }) - ) { - throw new StreamInvalidAccessError( - 'You are not allowed to process an invite for this stream', - { - info: { - userId, - userResourceAccessRules, - input + if ( + !isResourceAllowed({ + resourceId: isStreamInviteUseArgs(input) ? input.streamId : input.projectId, + resourceType: 'project', + resourceAccessRules: userResourceAccessRules + }) + ) { + throw new StreamInvalidAccessError( + 'You are not allowed to process an invite for this stream', + { + info: { + userId, + userResourceAccessRules, + input + } } - } + ) + } + + await finalizeStreamInvite( + accept, + isStreamInviteUseArgs(input) ? input.streamId : input.projectId, + token, + userId ) } - - await finalizeStreamInvite( - accept, - isStreamInviteUseArgs(input) ? input.streamId : input.projectId, - token, - userId - ) -} diff --git a/packages/server/modules/serverinvites/services/operations.ts b/packages/server/modules/serverinvites/services/operations.ts new file mode 100644 index 000000000..e41738bbd --- /dev/null +++ b/packages/server/modules/serverinvites/services/operations.ts @@ -0,0 +1,21 @@ +import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' +import { CreateInviteParams } from '@/modules/serverinvites/domain/operations' +import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' + +export type InviteResult = { + inviteId: string + token: string +} +export type CreateAndSendInvite = ( + params: CreateInviteParams, + inviterResourceAccessLimits?: TokenResourceIdentifier[] | null +) => Promise + +export type FinalizeStreamInvite = ( + accept: boolean, + streamId: string, + token: string, + userId: string +) => Promise + +export type ResendInviteEmail = (invite: ServerInviteRecord) => Promise diff --git a/packages/server/modules/serverinvites/services/validation.ts b/packages/server/modules/serverinvites/services/validation.ts new file mode 100644 index 000000000..4c3df1d8c --- /dev/null +++ b/packages/server/modules/serverinvites/services/validation.ts @@ -0,0 +1,120 @@ +import { UserRecord } from '@/modules/core/helpers/types' +import { CreateInviteParams } from '@/modules/serverinvites/domain/operations' +import { InviteCreateValidationError } from '@/modules/serverinvites/errors' +import { ResourceTargets, isServerInvite, resolveTarget } from '../helpers/inviteHelper' +import { UserWithOptionalRole } from '@/modules/core/repositories/users' +import { authorizeResolver } from '@/modules/shared' +import { Roles } from '@speckle/shared' +import { getStreamCollaborator } from '@/modules/core/repositories/streams' +import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' + +/** + * Validate invite creation input data + */ +export async function validateInput( + params: CreateInviteParams, + inviter: UserRecord | null, + resource?: { name: string } | null, + targetUser?: UserWithOptionalRole | null, + inviterResourceAccessLimits?: TokenResourceIdentifier[] | null +) { + const { message } = params + + // validate inviter & invitee + validateTargetUser(params, targetUser) + await validateInviter(params, inviter, inviterResourceAccessLimits) + + // validate resource + await validateResource(params, resource, targetUser) + + // check if message too long + if (message) { + if (message.length >= 1024) { + throw new InviteCreateValidationError('Personal message too long') + } + } +} + +function validateTargetUser( + params: CreateInviteParams, + targetUser?: UserRecord | null +) { + const { target } = params + const { userId } = resolveTarget(target) + + if (userId && !targetUser) { + throw new InviteCreateValidationError('Attempting to invite an invalid user') + } + + if (isServerInvite(params) && targetUser) { + throw new InviteCreateValidationError( + 'This email is already associated with an account on this server' + ) + } +} + +/** + * Validate that the inviter has access to the resources he's trying to invite people to + */ +async function validateInviter( + params: CreateInviteParams, + inviter: UserRecord | null, + inviterResourceAccessLimits?: TokenResourceIdentifier[] | null +) { + const { resourceId, resourceTarget } = params + if (!inviter) throw new InviteCreateValidationError('Invalid inviter') + if (isServerInvite(params)) return + + try { + if (resourceTarget === ResourceTargets.Streams) { + await authorizeResolver( + inviter.id, + resourceId!, // TODO: check null + Roles.Stream.Owner, + inviterResourceAccessLimits + ) + } else { + throw new InviteCreateValidationError('Unexpected resource target type') + } + } catch (e) { + throw new InviteCreateValidationError( + "Inviter doesn't have proper access to the resource", + { cause: e as Error } + ) + } +} + +/** + * Validate the target resource + */ +async function validateResource( + params: CreateInviteParams, + resource?: { name: string } | null, + targetUser?: UserRecord | null +) { + const { resourceId, resourceTarget, role } = params + + if (resourceId && !resource) { + throw new InviteCreateValidationError("Couldn't resolve invite resource") + } + + if (resourceTarget === ResourceTargets.Streams) { + if (targetUser) { + // Check if user isn't already associated with the stream + const isStreamCollaborator = !!(await getStreamCollaborator( + resourceId!, // TODO: verify this null + targetUser.id + )) + if (isStreamCollaborator) { + throw new InviteCreateValidationError( + 'The target user is already a collaborator of the specified project' + ) + } + } + + // TODO: check null role + if (!Object.values(Roles.Stream).includes(role!)) { + throw new InviteCreateValidationError('Unexpected stream invite role') + } + } +} diff --git a/packages/server/modules/serverinvites/tests/invites.spec.js b/packages/server/modules/serverinvites/tests/invites.spec.js index f4edaedd8..1df0c2544 100644 --- a/packages/server/modules/serverinvites/tests/invites.spec.js +++ b/packages/server/modules/serverinvites/tests/invites.spec.js @@ -10,12 +10,12 @@ const { resendInvite, batchCreateServerInvites, batchCreateStreamInvites, - deleteInvite, getStreamInvite, useUpStreamInvite, cancelStreamInvite, getStreamPendingCollaborators, - getStreamInvites + getStreamInvites, + deleteInvite } = require('@/test/graphql/serverInvites') const { truncateTables } = require('@/test/hooks') const { expect } = require('chai') @@ -23,30 +23,35 @@ const { createStream, grantPermissionsStream } = require('@/modules/core/services/streams') -const { - getInviteByToken, - getInvite: getInviteFromDB -} = require('@/modules/serverinvites/repositories') const { getUserStreamRole } = require('@/test/speckle-helpers/streamHelper') -const { createInviteDirectly } = require('@/test/speckle-helpers/inviteHelper') +const { createInviteDirectlyFactory } = require('@/test/speckle-helpers/inviteHelper') const { buildAuthenticatedApolloServer } = require('@/test/serverHelper') const { EmailSendingServiceMock } = require('@/test/mocks/global') +const db = require('@/db/knex') +const { + findInviteByTokenFactory, + findInviteFactory +} = require('@/modules/serverinvites/repositories/serverInvites') async function cleanup() { await truncateTables([ServerInvites.name, Streams.name, Users.name]) } +const findInviteByToken = findInviteByTokenFactory({ db }) +const findInvite = findInviteFactory({ db }) + function getInviteTokenFromEmailParams(emailParams) { const { text } = emailParams const [, inviteId] = text.match(/\?token=(.*?)(\s|&)/i) return inviteId } +const createInviteDirectly = createInviteDirectlyFactory({ db }) async function validateInviteExistanceFromEmail(emailParams) { // Validate that invite exists const token = getInviteTokenFromEmailParams(emailParams) expect(token).to.be.ok - const invite = await getInviteByToken(token) + const invite = await findInviteByToken(token) expect(invite).to.be.ok return invite @@ -466,14 +471,16 @@ describe('[Stream & Server Invites]', () => { // Delete all invites for (const invite of deletableInvites) { - const result = await deleteInvite(apollo, { inviteId: invite.inviteId }) + const result = await deleteInvite(apollo, { + inviteId: invite.inviteId + }) expect(result.data?.inviteDelete).to.be.ok expect(result.errors).to.not.be.ok } // Validate that invites no longer exist const invitesInDb = await Promise.all( - deletableInvites.map((i) => getInviteFromDB(i.inviteId)) + deletableInvites.map((i) => findInvite(i.inviteId)) ) expect(invitesInDb.every((i) => !i)).to.be.true }) @@ -623,7 +630,7 @@ describe('[Stream & Server Invites]', () => { expect(data?.streamInviteUse).to.be.ok expect(errors).to.not.be.ok - expect(await getInviteFromDB(inviteId)).to.be.not.ok + expect(await findInvite(inviteId)).to.be.not.ok const userStreamRole = await getUserStreamRole(me.id, streamId) expect(userStreamRole).to.eq(accept ? Roles.Stream.Contributor : null) @@ -710,7 +717,7 @@ describe('[Stream & Server Invites]', () => { expect(data?.streamInviteCancel).to.be.ok expect(errors).to.be.not.ok - expect(await getInviteFromDB(inviteId)).to.be.not.ok + expect(await findInvite(inviteId)).to.be.not.ok }) it('own pending collaborators can be retrieved', async () => { diff --git a/packages/server/modules/shared/authz.ts b/packages/server/modules/shared/authz.ts index 1ee7594f6..85e2567fe 100644 --- a/packages/server/modules/shared/authz.ts +++ b/packages/server/modules/shared/authz.ts @@ -16,10 +16,7 @@ import { } from '@/modules/shared/errors' import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' import { MaybeNullOrUndefined, Nullable } from '@speckle/shared' -import { - TokenResourceIdentifier, - TokenResourceIdentifierType -} from '@/modules/core/graph/generated/graphql' +import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' import { isResourceAllowed } from '@/modules/core/helpers/token' import { getAutomationProject } from '@/modules/automate/repositories/automations' @@ -187,7 +184,7 @@ export const validateResourceAccess: AuthPipelineFunction = async ({ const hasAccess = isResourceAllowed({ resourceId: streamId, - resourceType: TokenResourceIdentifierType.Project, + resourceType: 'project', resourceAccessRules }) diff --git a/packages/server/modules/shared/index.js b/packages/server/modules/shared/index.js index 4cf2d2506..9b7fce7ae 100644 --- a/packages/server/modules/shared/index.js +++ b/packages/server/modules/shared/index.js @@ -40,7 +40,7 @@ async function validateScopes(scopes, scope) { * @param {string | null | undefined} userId * @param {string} resourceId * @param {string} requiredRole - * @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits] + * @param {import('@/modules/serverinvites/services/operations').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits] */ async function authorizeResolver( userId, diff --git a/packages/server/test/speckle-helpers/inviteHelper.js b/packages/server/test/speckle-helpers/inviteHelper.js deleted file mode 100644 index 03ffa7f27..000000000 --- a/packages/server/test/speckle-helpers/inviteHelper.js +++ /dev/null @@ -1,44 +0,0 @@ -const { Roles } = require('@/modules/core/helpers/mainConstants') -const { - buildUserTarget, - ResourceTargets -} = require('@/modules/serverinvites/helpers/inviteHelper') -const { - createAndSendInvite -} = require('@/modules/serverinvites/services/inviteCreationService') - -/** - * Create a new invite. User & userId are alternatives for each other, and so - * are stream & streamId - * @param {{ - * email?: string, - * user?: import("@/modules/core/helpers/userHelper").UserRecord, - * userId?: string, - * message?: string, - * stream?: Object, - * streamId?: string - * }} invite - * @param {string} creatorId - * - * @returns {Promise<{inviteId: string, token: string}>} - */ -function createInviteDirectly(invite, creatorId) { - const userId = invite.userId || invite.user?.id || null - const email = invite.email || null - if (!userId && !email) throw new Error('Either user/userId or email must be set') - - const streamId = invite.streamId || invite.stream?.id || null - - return createAndSendInvite({ - target: email || buildUserTarget(userId), - inviterId: creatorId, - message: invite.message, - resourceTarget: streamId ? ResourceTargets.Streams : null, - resourceId: streamId || null, - role: streamId ? Roles.Stream.Contributor : null - }) -} - -module.exports = { - createInviteDirectly -} diff --git a/packages/server/test/speckle-helpers/inviteHelper.ts b/packages/server/test/speckle-helpers/inviteHelper.ts new file mode 100644 index 000000000..e012012a1 --- /dev/null +++ b/packages/server/test/speckle-helpers/inviteHelper.ts @@ -0,0 +1,58 @@ +import { Roles } from '@speckle/shared' + +import { + buildUserTarget, + ResourceTargets +} from '@/modules/serverinvites/helpers/inviteHelper' +import { InviteResult } from '@/modules/serverinvites/services/operations' +import { StreamRecord, UserRecord } from '@/modules/core/helpers/types' +import { + findResourceFactory, + findUserByTargetFactory, + insertInviteAndDeleteOldFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { Knex } from 'knex' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/inviteCreationService' + +/** + * Create a new invite. User & userId are alternatives for each other, and so + * are stream & streamId + */ +export const createInviteDirectlyFactory = + // This is a test helper, so im ok, with leaking the internal abstractions here + + + ({ db }: { db: Knex }) => + async ( + invite: { + email?: string + user?: UserRecord + userId?: string + message?: string + stream?: StreamRecord + streamId?: string + }, + creatorId: string + ): Promise => { + const userId = invite.userId || invite.user?.id || null + const email = invite.email || null + if (!userId && !email) throw new Error('Either user/userId or email must be set') + + const streamId = invite.streamId || invite.stream?.id + + const target = email || buildUserTarget(userId) + if (!target) throw new Error('Cannot create invite without a target') + + return await createAndSendInviteFactory({ + findUserByTarget: findUserByTargetFactory(), + findResource: findResourceFactory(), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }) + })({ + target, + inviterId: creatorId, + message: invite.message, + resourceTarget: streamId ? ResourceTargets.Streams : undefined, + resourceId: streamId, + role: streamId ? Roles.Stream.Contributor : undefined + }) + } diff --git a/packages/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index c7ed82288..efb4a2ae8 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -4,15 +4,23 @@ import { z } from 'zod' function parseFeatureFlags() { //INFO // As a convention all feature flags should be prefixed with a FF_ - const featureFlagSchema = z.object({ + return parseEnv(process.env, { // Enables the automate module. - FF_AUTOMATE_MODULE_ENABLED: z.boolean().default(false), + FF_AUTOMATE_MODULE_ENABLED: { + schema: z.boolean(), + defaults: { production: false, _: true } + }, // Enables the gendo ai integration - FF_GENDOAI_MODULE_ENABLED: z.boolean().default(false), + FF_GENDOAI_MODULE_ENABLED: { + schema: z.boolean(), + defaults: { production: false, _: true } + }, // Disables writing to the closure table in the create objects batched services (re object upload routes) - FF_NO_CLOSURE_WRITES: z.boolean().default(false) + FF_NO_CLOSURE_WRITES: { + schema: z.boolean(), + defaults: { production: false, _: false } + } }) - return parseEnv(process.env, featureFlagSchema.shape) } let parsedFlags: ReturnType | undefined