Merge branch 'main' into andrew/fix-member-table-underline-reactivity

This commit is contained in:
andrewwallacespeckle
2025-07-10 14:30:39 +01:00
173 changed files with 4095 additions and 1650 deletions
-3
View File
@@ -84,9 +84,6 @@ jobs:
run: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn --immutable
- name: Build public packages
run: yarn build:public
- name: Lint viewer-sandbox
run: yarn lint:ci
working-directory: 'packages/viewer-sandbox'
- name: Build viewer-sandbox
run: yarn build
working-directory: 'packages/viewer-sandbox'
+3 -28
View File
@@ -69,6 +69,7 @@ jobs:
test-frontend-2:
name: Frontend
runs-on: blacksmith
if: false # disabled as there is nothing to run
steps:
- uses: actions/checkout@v4.2.2
- uses: useblacksmith/setup-node@v5
@@ -79,9 +80,6 @@ jobs:
run: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn --immutable
- name: Build public packages
run: yarn build:public
- name: Lint everything
run: yarn lint:ci
working-directory: 'packages/frontend-2'
test-viewer:
name: Viewer
@@ -96,15 +94,9 @@ jobs:
run: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn --immutable
- name: Build public packages
run: yarn build:public
- name: Lint viewer
run: yarn lint:ci
working-directory: 'packages/viewer'
- name: Run tests
run: yarn test
working-directory: 'packages/viewer'
- name: Lint viewer-sandbox
run: yarn lint:ci
working-directory: 'packages/viewer-sandbox'
- name: Build viewer-sandbox
run: yarn build
working-directory: 'packages/viewer-sandbox'
@@ -141,9 +133,6 @@ jobs:
cache: yarn
- name: Install dependencies
run: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn --immutable
- name: Lint
run: yarn lint:ci
working-directory: 'packages/shared'
- name: Run tests (all FFs)
run: ENABLE_ALL_FFS=1 yarn test:ci
working-directory: 'packages/shared'
@@ -179,7 +168,7 @@ jobs:
run: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn --immutable
- name: Build public packages
run: yarn build:public
- name: Lint everything
- name: Test object sender
run: yarn test:ci
working-directory: 'packages/objectsender'
- uses: codecov/codecov-action@v5
@@ -202,15 +191,6 @@ jobs:
run: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true yarn --immutable # we need PLAYWRIGHT
- name: Build public packages
run: yarn build:public
- name: Lint tailwind theme
run: yarn lint:ci
working-directory: 'packages/tailwind-theme'
- name: Lint ui components
run: yarn lint:ci
working-directory: 'packages/ui-components'
- name: Lint component nuxt package
run: yarn lint:ci
working-directory: 'packages/ui-components-nuxt'
- name: Test via Storybook
run: yarn storybook:test:ci
working-directory: 'packages/ui-components'
@@ -218,6 +198,7 @@ jobs:
test-preview-service:
name: Preview service
runs-on: blacksmith
if: false # disabled as there is nothing to run
steps:
- uses: actions/checkout@v4.2.2
- uses: useblacksmith/setup-node@v5
@@ -228,9 +209,6 @@ jobs:
run: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn --immutable
- name: Build public packages
run: yarn build:public
- name: Lint everything
run: yarn lint:ci
working-directory: 'packages/preview-service'
docker-build-postgres-container:
runs-on: blacksmith
@@ -319,9 +297,6 @@ jobs:
run: yarn build:public
- run: cp .env.test-example .env.test
working-directory: 'packages/server'
- name: 'Lint'
run: yarn lint:ci
working-directory: 'packages/server'
- name: 'Run test'
run: yarn test:report
working-directory: 'packages/server'
+5
View File
@@ -32,6 +32,7 @@ services:
NUXT_PUBLIC_BACKEND_API_ORIGIN: 'http://speckle-server:3000'
NUXT_PUBLIC_LOG_LEVEL: 'warn'
NUXT_REDIS_URL: 'redis://redis'
NUXT_PUBLIC_FF_LARGE_FILE_IMPORTS_ENABLED: 'true'
LOG_LEVEL: 'info'
LOG_PRETTY: 'true'
depends_on:
@@ -79,9 +80,11 @@ services:
REDIS_URL: 'redis://redis'
PREVIEW_SERVICE_USE_PRIVATE_OBJECTS_SERVER_URL: 'true'
PREVIEW_SERVICE_REDIS_URL: 'redis://redis'
FILEIMPORT_SERVICE_USE_PRIVATE_OBJECTS_SERVER_URL: 'true'
FILEIMPORT_SERVICE_REDIS_URL: 'redis://redis'
S3_ENDPOINT: 'http://minio:9000'
S3_PUBLIC_ENDPOINT: 'http://127.0.0.1:9000'
S3_ACCESS_KEY: 'minioadmin'
S3_SECRET_KEY: 'minioadmin'
S3_BUCKET: 'speckle-server'
@@ -92,6 +95,8 @@ services:
FRONTEND_ORIGIN: 'http://127.0.0.1'
ONBOARDING_STREAM_URL: 'https://latest.speckle.systems/projects/843d07eb10'
FF_LARGE_FILE_IMPORTS_ENABLED: 'true'
depends_on:
[]
# - minio
+2 -1
View File
@@ -4,7 +4,8 @@ POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE='1'
POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS='16000'
POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS='5000'
FF_WORKSPACES_MULTI_REGION_ENABLED=false
USE_LEGACY_IFC_IMPORTER=true
FF_LEGACY_IFC_IMPORTER_ENABLED=true
FF_EXPERIMENTAL_IFC_IMPORTER_ENABLED=false
# IFC_DOTNET_DLL_PATH='packages/fileimport-service/src/ifc-dotnet/ifc-converter.dll'
##########################################################
+5
View File
@@ -12,6 +12,7 @@ WORKDIR /speckle-server
# configure tini
ARG TINI_VERSION=v0.19.0
ARG SPECKLE_IFC_VERSION=0.2.0
# hadolint ignore=DL3008
RUN apt-get update -y \
@@ -20,6 +21,10 @@ RUN apt-get update -y \
ca-certificates=20240203 \
curl=8.5.0-2ubuntu10.6 \
gosu=1.17-1ubuntu0.24.04.3 \
&& curl -L -o speckleifc.tar.gz https://github.com/specklesystems/speckleifc/archive/refs/tags/v${SPECKLE_IFC_VERSION}.tar.gz \
&& mkdir speckleifc \
&& tar --strip-components=1 -C speckleifc -xzf speckleifc.tar.gz speckleifc-${SPECKLE_IFC_VERSION} \
&& rm speckleifc.tar.gz \
&& curl -fsSL https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini -o /usr/bin/tini \
&& chmod +x /usr/bin/tini \
&& curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh \
@@ -3,3 +3,4 @@ specklepy==3.0.1
structlog==23.3.0
numpy==1.26.4 # not directly required, pinned to avoid a vulnerability in <1.22.2
python-util==1.2.1 # not directly required, peer dependency of numpy-stl
ifcopenshell==0.8.2 # required for speckleifc
@@ -5,26 +5,24 @@ import {
metricOperationErrors
} from '@/controller/prometheusMetrics.js'
import { DbClient, getDbClients } from '@/clients/knex.js'
import { downloadFile } from '@/controller/filesApi.js'
import fs from 'fs'
import { ServerAPI } from '@/controller/api.js'
import { downloadDependencies } from '@/controller/objDependencies.js'
import { logger } from '@/observability/logging.js'
import { Nullable, Scopes, wait, TIME_MS } from '@speckle/shared'
import { Knex } from 'knex'
import {
getIfcDllPath,
isProdEnv,
useLegacyIfcImporter
} from '@/controller/helpers/env.js'
import { getIfcDllPath, isProdEnv } from '@/controller/helpers/env.js'
import { isErrorOutput, isSuccessOutput } from '@/common/output.js'
import { runProcessWithTimeout } from '@/common/processHandling.js'
import {
getConnectionSettings,
obfuscateConnectionString
} from '@speckle/shared/environment/db'
import { getFeatureFlags } from '@speckle/shared/environment'
const { FF_LEGACY_IFC_IMPORTER_ENABLED, FF_EXPERIMENTAL_IFC_IMPORTER_ENABLED } =
getFeatureFlags()
const HEALTHCHECK_FILE_PATH = '/tmp/last_successful_query'
@@ -183,7 +181,7 @@ async function doTask(
if (info.fileType.toLowerCase() === 'ifc') {
if (
info.fileName.toLowerCase().endsWith('.legacyimporter.ifc') ||
useLegacyIfcImporter()
FF_LEGACY_IFC_IMPORTER_ENABLED
) {
await runProcessWithTimeout(
taskLogger,
@@ -208,7 +206,10 @@ async function doTask(
TIME_LIMIT,
TMP_RESULTS_PATH
)
} else {
} else if (
info.fileName.toLowerCase().endsWith('.dotnetimporter.ifc') ||
!FF_EXPERIMENTAL_IFC_IMPORTER_ENABLED
) {
await runProcessWithTimeout(
taskLogger,
process.env['DOTNET_BINARY_PATH'] || 'dotnet',
@@ -228,6 +229,27 @@ async function doTask(
TIME_LIMIT,
TMP_RESULTS_PATH
)
} else {
await runProcessWithTimeout(
taskLogger,
process.env['PYTHON_BINARY_PATH'] || 'python3',
[
'-m',
'speckleifc',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.streamId,
`File upload: ${info.fileName}`,
existingBranch?.id || ''
],
{
USER_TOKEN: tempUserToken,
//speckleifc is not installed to sys (e.g. via pip), so we need to point it to the directory explicitly
PYTHONPATH: '/speckle-server/speckleifc/src/'
},
TIME_LIMIT,
TMP_RESULTS_PATH
)
}
} else if (info.fileType.toLowerCase() === 'stl') {
await runProcessWithTimeout(
@@ -16,10 +16,6 @@ export function isProdEnv() {
export const isDevOrTestEnv = () => isDevEnv() || isTestEnv()
export const useLegacyIfcImporter = () => {
return ['true', '1'].includes(process.env.USE_LEGACY_IFC_IMPORTER || 'false')
}
export const getPackageRootDirPath = () => {
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -11,6 +11,9 @@ import { DOTNET_BINARY_PATH, RHINO_IMPORTER_PATH } from './config.js'
import { getIfcDllPath } from '@/controller/helpers/env.js'
import { z } from 'zod'
import { TIME_MS } from '@speckle/shared'
import { getFeatureFlags } from '@speckle/shared/environment'
const { FF_EXPERIMENTAL_IFC_IMPORTER_ENABLED } = getFeatureFlags()
const jobSuccess = z.object({
success: z.literal(true),
@@ -120,26 +123,55 @@ export const jobProcessor = async ({
switch (fileType) {
case 'ifc':
parserUsed = 'ifc'
await runProcessWithTimeout(
taskLogger,
DOTNET_BINARY_PATH,
[
getIfcDllPath(),
sourceFilePath,
resultsPath,
job.projectId,
`File upload: ${job.fileName}`,
job.modelId,
'bogus',
'regionName'
],
{
SPECKLE_SERVER_URL: job.serverUrl,
USER_TOKEN: job.token
},
Math.min(timeout, job.timeOutSeconds * TIME_MS.second),
resultsPath
)
const useDotnetIfcImporter =
job.fileName.toLowerCase().endsWith('.dotnetimporter.ifc') ||
!FF_EXPERIMENTAL_IFC_IMPORTER_ENABLED
if (useDotnetIfcImporter) {
await runProcessWithTimeout(
taskLogger,
DOTNET_BINARY_PATH,
[
getIfcDllPath(),
sourceFilePath,
resultsPath,
job.projectId,
`File upload: ${job.fileName}`,
job.modelId,
'bogus',
'regionName'
],
{
SPECKLE_SERVER_URL: job.serverUrl,
USER_TOKEN: job.token
},
Math.min(timeout, job.timeOutSeconds * TIME_MS.second),
resultsPath
)
} else {
await runProcessWithTimeout(
taskLogger,
process.env['PYTHON_BINARY_PATH'] || 'python3',
[
'-m',
'speckleifc',
sourceFilePath,
resultsPath,
job.projectId,
`File upload: ${job.fileName}`,
job.modelId
],
{
USER_TOKEN: job.token,
SPECKLE_SERVER_URL: job.serverUrl,
//speckleifc is not installed to sys (e.g. via pip), so we need to point it to the directory explicitly
PYTHONPATH: '/speckle-server/speckleifc/src/'
},
Math.min(timeout, job.timeOutSeconds * TIME_MS.second),
resultsPath
)
}
break
case 'stl':
case 'obj':
@@ -1,8 +1,6 @@
<template>
<div>
<CommonCard
class="flex flex-1 flex-col gap-1 !p-4 !pt-2 !pb-3 h-full hover:bg-foundation"
>
<CommonCard class="flex flex-1 flex-col gap-1 !p-4 !pt-2 !pb-3 h-full">
<div class="flex gap-2 items-center">
<div v-if="connector.images" class="relative flex items-start mr-2">
<div
@@ -29,20 +27,32 @@
{{ connector.description }}
</p>
<div class="flex gap-1 mt-2">
<FormButton
color="outline"
size="sm"
:disabled="enableButton"
external
:to="latestAvailableVersion?.Url"
@click="
mixpanel.track('Connector Card Install Clicked', {
connector: props.connector.slug
})
<div
v-tippy="
canDownload
? undefined
: {
content: `Please <a href='${loginRoute}'>login</a> or <a href='${registerRoute}'>register</a> to download connectors`,
allowHTML: true,
interactive: true
}
"
>
{{ connector.isComingSoon ? 'Coming soon' : 'Install for Windows' }}
</FormButton>
<FormButton
color="outline"
size="sm"
:disabled="enableButton"
external
:to="canDownload ? latestAvailableVersion?.Url : undefined"
@click="
mixpanel.track('Connector Card Install Clicked', {
connector: props.connector.slug
})
"
>
{{ connector.isComingSoon ? 'Coming soon' : 'Install for Windows' }}
</FormButton>
</div>
<FormButton
v-if="connector.url"
color="subtle"
@@ -66,20 +76,22 @@
<script setup lang="ts">
import type { ConnectorItem, Version, Versions } from '~~/lib/dashboard/helpers/types'
import { useMixpanel } from '~/lib/core/composables/mp'
import { loginRoute, registerRoute } from '~~/lib/common/helpers/route'
const props = defineProps<{
connector: ConnectorItem
canDownload: boolean
}>()
const mixpanel = useMixpanel()
const { data: versionData, status } = useFetch(
`https://releases.speckle.dev/manager2/feeds/${props.connector.slug}-v3.json`,
{
immediate: !props.connector.isComingSoon
immediate: !props.connector.isComingSoon && props.canDownload
}
)
const enableButton = computed(() => status.value !== 'success')
const enableButton = computed(() => status.value !== 'success' || !props.canDownload)
const latestAvailableVersion = computed<Version | null>(() => {
if (versionData.value) {
@@ -60,6 +60,7 @@
v-for="connector in filteredConnectors"
:key="connector.title"
:connector="connector"
:can-download="isLoggedIn"
/>
</div>
<p
@@ -96,6 +97,7 @@ const {
const labelId = useId()
const buttonId = useId()
const { isLoggedIn } = useActiveUser()
const selectedCategory = ref<CategoryFilter>()
const connectors = shallowRef<ConnectorItem[]>(connectorItems)
@@ -1000,6 +1000,22 @@ export type EmailVerificationRequestInput = {
id: Scalars['ID']['input'];
};
export type EmbedToken = {
__typename?: 'EmbedToken';
createdAt: Scalars['DateTime']['output'];
id: Scalars['String']['output'];
lastUsed: Scalars['DateTime']['output'];
lifespan: Scalars['BigInt']['output'];
modelIds: Scalars['String']['output'];
name: Scalars['String']['output'];
};
export type EmbedTokenCreateInput = {
lifespan?: InputMaybe<Scalars['BigInt']['input']>;
modelIds: Scalars['String']['input'];
projectId: Scalars['String']['input'];
};
export type FileUpload = {
__typename?: 'FileUpload';
branchName: Scalars['String']['output'];
@@ -2095,6 +2111,7 @@ export type Project = {
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
embedTokens: Array<EmbedToken>;
hasAccessToFeature: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
invitableCollaborators: WorkspaceCollaboratorCollection;
@@ -2576,6 +2593,7 @@ export type ProjectMutations = {
batchDelete: Scalars['Boolean']['output'];
/** Create new project */
create: Project;
createEmbedToken: Scalars['String']['output'];
/**
* Create onboarding/tutorial project. If one is already created for the active user, that
* one will be returned instead.
@@ -2587,6 +2605,7 @@ export type ProjectMutations = {
invites: ProjectInviteMutations;
/** Leave a project. Only possible if you're not the last remaining owner. */
leave: Scalars['Boolean']['output'];
revokeEmbedToken: Scalars['Boolean']['output'];
/** Updates an existing project */
update: Project;
/** Update role for a collaborator */
@@ -2609,6 +2628,11 @@ export type ProjectMutationsCreateArgs = {
};
export type ProjectMutationsCreateEmbedTokenArgs = {
token: EmbedTokenCreateInput;
};
export type ProjectMutationsDeleteArgs = {
id: Scalars['String']['input'];
};
@@ -2619,6 +2643,11 @@ export type ProjectMutationsLeaveArgs = {
};
export type ProjectMutationsRevokeEmbedTokenArgs = {
token: Scalars['String']['input'];
};
export type ProjectMutationsUpdateArgs = {
update: ProjectUpdateInput;
};
@@ -7901,6 +7930,7 @@ export type AllObjectTypes = {
CommitCollection: CommitCollection,
CountOnlyCollection: CountOnlyCollection,
CurrencyBasedPrices: CurrencyBasedPrices,
EmbedToken: EmbedToken,
FileUpload: FileUpload,
FileUploadCollection: FileUploadCollection,
FileUploadMutations: FileUploadMutations,
@@ -8363,6 +8393,14 @@ export type CurrencyBasedPricesFieldArgs = {
gbp: {},
usd: {},
}
export type EmbedTokenFieldArgs = {
createdAt: {},
id: {},
lastUsed: {},
lifespan: {},
modelIds: {},
name: {},
}
export type FileUploadFieldArgs = {
branchName: {},
convertedCommitId: {},
@@ -8656,6 +8694,7 @@ export type ProjectFieldArgs = {
createdAt: {},
description: {},
embedOptions: {},
embedTokens: {},
hasAccessToFeature: ProjectHasAccessToFeatureArgs,
id: {},
invitableCollaborators: ProjectInvitableCollaboratorsArgs,
@@ -8762,10 +8801,12 @@ export type ProjectMutationsFieldArgs = {
automationMutations: ProjectMutationsAutomationMutationsArgs,
batchDelete: ProjectMutationsBatchDeleteArgs,
create: ProjectMutationsCreateArgs,
createEmbedToken: ProjectMutationsCreateEmbedTokenArgs,
createForOnboarding: {},
delete: ProjectMutationsDeleteArgs,
invites: {},
leave: ProjectMutationsLeaveArgs,
revokeEmbedToken: ProjectMutationsRevokeEmbedTokenArgs,
update: ProjectMutationsUpdateArgs,
updateRole: ProjectMutationsUpdateRoleArgs,
}
@@ -9529,6 +9570,7 @@ export type AllObjectFieldArgTypes = {
CommitCollection: CommitCollectionFieldArgs,
CountOnlyCollection: CountOnlyCollectionFieldArgs,
CurrencyBasedPrices: CurrencyBasedPricesFieldArgs,
EmbedToken: EmbedTokenFieldArgs,
FileUpload: FileUploadFieldArgs,
FileUploadCollection: FileUploadCollectionFieldArgs,
FileUploadMutations: FileUploadMutationsFieldArgs,
@@ -6,8 +6,4 @@
useHead({
title: 'Connectors'
})
definePageMeta({
middleware: ['auth']
})
</script>
@@ -24,6 +24,7 @@ export class ObjectLoader2 {
#gathered: AsyncGeneratorQueue<Item>
#root?: Item = undefined
#isRootStored = false
constructor(options: ObjectLoader2Options) {
this.#rootId = options.rootId
@@ -34,8 +35,8 @@ export class ObjectLoader2 {
maxCacheReadSize: 10_000,
maxCacheWriteSize: 10_000,
maxWriteQueueSize: 40_000,
maxCacheBatchWriteWait: 3_000,
maxCacheBatchReadWait: 3_000
maxCacheBatchWriteWait: 100, //100 ms, next to nothing!
maxCacheBatchReadWait: 100 //100 ms, next to nothing!
}
this.#gathered = new AsyncGeneratorQueue()
@@ -56,10 +57,10 @@ export class ObjectLoader2 {
await Promise.all([
this.#gathered.disposeAsync(),
this.#downloader.disposeAsync(),
this.#cacheReader.disposeAsync(),
this.#cacheWriter.disposeAsync()
])
this.#deferments.dispose()
this.#cacheReader.dispose()
}
async getRootObject(): Promise<Item | undefined> {
@@ -67,6 +68,8 @@ export class ObjectLoader2 {
this.#root = (await this.#database.getAll([this.#rootId]))[0]
if (!this.#root) {
this.#root = await this.#downloader.downloadSingle()
} else {
this.#isRootStored = true
}
}
return this.#root
@@ -114,6 +117,10 @@ export class ObjectLoader2 {
break
}
}
if (!this.#isRootStored) {
await this.#database.saveBatch({ batch: [rootItem] })
this.#isRootStored = true
}
}
static createFromObjects(objects: Base[]): ObjectLoader2 {
@@ -30,6 +30,6 @@ describe('CacheReader testing', () => {
const base = await objPromise
expect(base).toMatchSnapshot()
await cacheReader.disposeAsync()
cacheReader.dispose()
})
})
@@ -31,11 +31,12 @@ export class CacheReader {
this.#notFoundQueue = notFoundQueue
}
async getObject(params: { id: string }): Promise<Base> {
if (!this.#defermentManager.isDeferred(params.id)) {
getObject(params: { id: string }): Promise<Base> {
const [p, b] = this.#defermentManager.defer({ id: params.id })
if (!b) {
this.#requestItem(params.id)
}
return await this.#defermentManager.defer({ id: params.id })
return p
}
#createReadQueue(): void {
@@ -57,10 +58,15 @@ export class CacheReader {
requestAll(keys: string[]): void {
this.#createReadQueue()
for (const key of keys) {
this.#defermentManager.trackDefermentRequest(key)
}
this.#readQueue?.addAll(keys, keys)
}
#processBatch = async (batch: string[]): Promise<void> => {
const start = performance.now()
const items = await this.#database.getAll(batch)
for (let i = 0; i < items.length; i++) {
const item = items[i]
@@ -71,9 +77,10 @@ export class CacheReader {
this.#notFoundQueue?.add(batch[i])
}
}
this.#logger('readBatch: left, time', items.length, performance.now() - start)
}
async disposeAsync(): Promise<void> {
await this.#readQueue?.disposeAsync()
dispose(): void {
this.#readQueue?.dispose()
}
}
@@ -30,17 +30,25 @@ export class CacheWriter implements Queue<Item> {
this.#writeQueue = new BatchingQueue({
batchSize: this.#options.maxCacheWriteSize,
maxWaitTime: this.#options.maxCacheBatchWriteWait,
processFunction: (batch: Item[]): Promise<void> =>
this.#database.saveBatch({ batch })
processFunction: async (batch: Item[]): Promise<void> => {
await this.writeAll(batch)
}
})
}
this.#defermentManager.undefer(item)
this.#writeQueue.add(item.baseId, item)
}
async writeAll(items: Item[]): Promise<void> {
const start = performance.now()
await this.#database.saveBatch({ batch: items })
this.#logger('writeBatch: left, time', items.length, performance.now() - start)
}
async disposeAsync(): Promise<void> {
await this.#writeQueue?.disposeAsync()
this.#writeQueue?.dispose()
this.#disposed = true
return Promise.resolve()
}
get isDisposed(): boolean {
@@ -121,7 +121,8 @@ export default class IndexedDatabase implements Database {
async disposeAsync(): Promise<void> {
this.#cacheDB?.close()
this.#cacheDB = undefined
await this.#writeQueue?.disposeAsync()
this.#writeQueue?.dispose()
this.#writeQueue = undefined
return Promise.resolve()
}
}
@@ -14,6 +14,8 @@ export interface ServerDownloaderOptions {
fetch?: Fetcher
}
const MAX_SAFARI_DECODE_BYTES = 2 * 1024 * 1024 * 1024 - 1024 * 1024 // 2GB minus a margin
export default class ServerDownloader implements Downloader {
#requestUrlRootObj: string
#requestUrlChildren: string
@@ -24,7 +26,8 @@ export default class ServerDownloader implements Downloader {
#total?: number
#downloadQueue?: BatchedPool<string>
#decoder = new TextDecoder()
#decoder = new TextDecoder('utf-8', { fatal: true })
#decodedBytesCount = 0
constructor(options: ServerDownloaderOptions) {
this.#options = options
@@ -89,6 +92,24 @@ export default class ServerDownloader implements Downloader {
this.#getPool().add(id)
}
/*
This is the most frequently reported and confirmed reason for this error in Safari. There's a known bug in WebKit (Safari's rendering engine) where TextDecoder can fail or throw a RangeError after decoding around 2GB of data. Chrome and other browsers handle much larger amounts of data without this specific limitation.
Why it happens: It seems to be an internal memory or indexing limitation within Safari's TextDecoder implementation. After a certain threshold of data has been processed by a TextDecoder instance, it starts throwing this error.
Chrome's behavior: Chrome generally handles larger data sizes without this specific RangeError. It might become slow or run out of general memory, but not typically with this specific error.
*/
decodeChunk(chunkBuffer: Uint8Array): string {
if (this.#decodedBytesCount + chunkBuffer.byteLength > MAX_SAFARI_DECODE_BYTES) {
// Safari is approaching its limit, create a new decoder
this.#decoder = new TextDecoder('utf-8', { fatal: true })
this.#decodedBytesCount = 0 // Reset counter for the new decoder
}
const decodedText = this.#decoder.decode(chunkBuffer)
this.#decodedBytesCount += chunkBuffer.byteLength
return decodedText
}
async disposeAsync(): Promise<void> {
await this.#downloadQueue?.disposeAsync()
}
@@ -166,16 +187,16 @@ export default class ServerDownloader implements Downloader {
for (let i = 0; i < line.length; i++) {
if (line[i] === 0x09) {
//this is a tab
const baseId = this.#decoder.decode(line.subarray(0, i))
const baseId = this.decodeChunk(line.subarray(0, i))
const json = line.subarray(i + 1)
const base = this.#decoder.decode(json)
const base = this.decodeChunk(json)
const item = this.#processJson(baseId, base)
item.size = json.length
return item
}
}
throw new ObjectLoaderRuntimeError(
'Invalid line format: ' + this.#decoder.decode(line)
'Invalid line format in response: ' + this.decodeChunk(line)
)
}
@@ -10,14 +10,12 @@ describe('DefermentManager disposal', () => {
base: { id, speckle_type: 'test' }
})
it('should throw on get/defer/undefer after dispose', async () => {
it('should throw on get/defer/undefer after dispose', () => {
const manager = new DefermentManager(options)
manager.dispose()
expect(() => manager.get('a')).toThrow('DefermentManager is disposed')
expect(() => manager.undefer(makeItem('a'))).toThrow('DefermentManager is disposed')
await expect(manager.defer({ id: 'a' })).rejects.toThrow(
'DefermentManager is disposed'
)
expect(() => manager.defer({ id: 'a' })).toThrow('DefermentManager is disposed')
})
it('dispose is idempotent', () => {
@@ -6,7 +6,8 @@ import { DefermentManager } from './defermentManager.js'
describe('deferments', () => {
test('defer one', async () => {
const deferments = new DefermentManager({ maxSizeInMb: 1, ttlms: 1 })
const x = deferments.defer({ id: 'id' })
const [x, alreadyDeferred] = deferments.defer({ id: 'id' })
expect(alreadyDeferred).toBeFalsy()
expect(x).toBeInstanceOf(Promise)
deferments.undefer({ baseId: 'id', base: { id: 'id', speckle_type: 'type' } })
const b = await x
@@ -17,7 +18,8 @@ describe('deferments', () => {
const now = 1
const deferments = new DefermentManager({ maxSizeInMb: 1, ttlms: 1 })
deferments['now'] = (): number => now
const x = deferments.defer({ id: 'id' })
const [x, alreadyDeferred] = deferments.defer({ id: 'id' })
expect(alreadyDeferred).toBeFalsy()
expect(x).toBeInstanceOf(Promise)
const d = deferments.get('id')
expect(d).toBeDefined()
@@ -22,23 +22,19 @@ export class DefermentManager {
return Date.now()
}
isDeferred(id: string): boolean {
return this.deferments.has(id)
}
get(id: string): DeferredBase | undefined {
if (this.disposed) throw new Error('DefermentManager is disposed')
return this.deferments.get(id)
}
async defer(params: { id: string }): Promise<Base> {
defer(params: { id: string }): [Promise<Base>, boolean] {
if (this.disposed) throw new Error('DefermentManager is disposed')
this.trackDefermentRequest(params.id)
const now = this.now()
const deferredBase = this.deferments.get(params.id)
if (deferredBase) {
deferredBase.setAccess(now)
return deferredBase.getPromise()
return [deferredBase.getPromise(), true]
}
const notYetFound = new DeferredBase(
this.options.ttlms,
@@ -46,10 +42,10 @@ export class DefermentManager {
now + this.options.ttlms
)
this.deferments.set(params.id, notYetFound)
return notYetFound.getPromise()
return [notYetFound.getPromise(), false]
}
private trackDefermentRequest(id: string): void {
trackDefermentRequest(id: string): void {
const request = this.totalDefermentRequests.get(id)
if (request) {
this.totalDefermentRequests.set(id, request + 1)
@@ -131,7 +127,7 @@ export class DefermentManager {
//we do not clean it up to allow the requests to resolve
const requestCount = this.totalDefermentRequests.get(deferredBase.getId())
if (requestCount && requestCount > 1) {
return
break
}
this.currentSize -= deferredBase.getSize() || 0
this.deferments.delete(deferredBase.getId())
@@ -0,0 +1,146 @@
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'
import BatchingQueue from './batchingQueue.js'
describe('BatchingQueue', () => {
let queue: BatchingQueue<string>
beforeEach(() => {
queue = new BatchingQueue({
batchSize: 3,
maxWaitTime: 100,
processFunction: async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
}
})
})
afterEach(() => {
queue.dispose()
})
test('should add items and process them in batches', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
batchSize: 2,
maxWaitTime: 100,
processFunction: async (batch: string[]): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
processSpy(batch)
}
})
queue.add('key1', 'item1')
queue.add('key2', 'item2')
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
})
test('should process items after timeout if batch size is not reached', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
batchSize: 5,
maxWaitTime: 100,
processFunction: async (batch: string[]): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
processSpy(batch)
}
})
queue.add('key1', 'item1')
queue.add('key2', 'item2')
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
})
test('should not process items if disposed', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
batchSize: 2,
maxWaitTime: 10000,
processFunction: async (batch: string[]): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
processSpy(batch)
}
})
queue.add('key1', 'item1')
queue.dispose()
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).not.toHaveBeenCalled()
})
test('should handle multiple batches correctly', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
batchSize: 2,
maxWaitTime: 100,
processFunction: async (batch: string[]): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
processSpy(batch)
}
})
queue.add('key1', 'item1')
queue.add('key2', 'item2')
queue.add('key3', 'item3')
queue.add('key4', 'item4')
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(2)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
expect(processSpy).toHaveBeenCalledWith(['item3', 'item4'])
})
test('should retrieve items by key', () => {
queue.add('key1', 'item1')
queue.add('key2', 'item2')
expect(queue.get('key1')).toBe('item1')
expect(queue.get('key2')).toBe('item2')
expect(queue.get('key3')).toBeUndefined()
})
test('should return correct count of items', () => {
expect(queue.count()).toBe(0)
queue.add('key1', 'item1')
queue.add('key2', 'item2')
expect(queue.count()).toBe(2)
})
test('should not process items if already processing', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
batchSize: 2,
maxWaitTime: 100,
processFunction: async (batch: string[]): Promise<void> => {
processSpy(batch)
await new Promise((resolve) => setTimeout(resolve, 300))
}
})
queue.add('key1', 'item1')
queue.add('key2', 'item2')
queue.add('key3', 'item3')
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(2)
expect(processSpy).toHaveBeenCalledWith(['item3'])
})
})
@@ -1,41 +1,100 @@
import { CustomLogger } from '../types/functions.js'
import KeyedQueue from './keyedQueue.js'
export default class BatchingQueue<T> {
#queue: KeyedQueue<string, T> = new KeyedQueue<string, T>()
#batchSize: number
#processFunction: (batch: T[]) => Promise<void>
#timeoutId: ReturnType<typeof setTimeout> | null = null
#isProcessing = false
#logger: CustomLogger
#baseInterval: number
#minInterval: number
#maxInterval: number
#processingLoop: Promise<void>
#disposed = false
#batchTimeout: number
// Helper methods for cross-environment timeout handling
#getSetTimeoutFn(): typeof setTimeout {
// First check for window object (browser), then fallback to global (node), then just use setTimeout
return typeof window !== 'undefined'
? window.setTimeout.bind(window)
: typeof global !== 'undefined'
? global.setTimeout
: setTimeout
}
#getClearTimeoutFn(): typeof clearTimeout {
// First check for window object (browser), then fallback to global (node), then just use clearTimeout
return typeof window !== 'undefined'
? window.clearTimeout.bind(window)
: typeof global !== 'undefined'
? global.clearTimeout
: clearTimeout
}
constructor(params: {
batchSize: number
maxWaitTime?: number
maxWaitTime: number
processFunction: (batch: T[]) => Promise<void>
logger?: CustomLogger
}) {
this.#batchSize = params.batchSize
this.#baseInterval = Math.min(params.maxWaitTime ?? 200, 200) // Initial batch time (ms)
this.#minInterval = Math.min(params.maxWaitTime ?? 100, 100) // Minimum batch time
this.#maxInterval = Math.min(params.maxWaitTime ?? 3000, 3000) // Maximum batch time
this.#processFunction = params.processFunction
this.#processingLoop = this.#loop()
this.#batchTimeout = params.maxWaitTime
this.#logger = params.logger || ((): void => {})
}
async disposeAsync(): Promise<void> {
dispose(): void {
this.#disposed = true
await this.#processingLoop
if (this.#timeoutId) {
this.#getClearTimeoutFn()(this.#timeoutId)
}
}
add(key: string, item: T): void {
this.#queue.enqueue(key, item)
this.#addCheck()
}
addAll(keys: string[], items: T[]): void {
this.#queue.enqueueAll(keys, items)
this.#addCheck()
}
#addCheck(): void {
if (this.#queue.size >= this.#batchSize) {
// Fire and forget, no need to await
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.#flush()
} else {
if (this.#timeoutId) {
this.#getClearTimeoutFn()(this.#timeoutId)
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.#timeoutId = this.#getSetTimeoutFn()(() => this.#flush(), this.#batchTimeout)
}
}
async #flush(): Promise<void> {
if (this.#timeoutId) {
this.#getClearTimeoutFn()(this.#timeoutId)
this.#timeoutId = null
}
if (this.#isProcessing || this.#queue.size === 0) {
return
}
this.#isProcessing = true
const batchToProcess = this.#getBatch(this.#batchSize)
try {
await this.#processFunction(batchToProcess)
} catch (error) {
this.#logger('Batch processing failed:', error)
} finally {
this.#isProcessing = false
}
this.#addCheck()
}
get(id: string): T | undefined {
@@ -53,37 +112,4 @@ export default class BatchingQueue<T> {
#getBatch(batchSize: number): T[] {
return this.#queue.spliceValues(0, Math.min(batchSize, this.#queue.size))
}
async #loop(): Promise<void> {
let interval = this.#baseInterval
while (!this.#disposed || this.#queue.size > 0) {
const startTime = performance.now()
if (this.#queue.size > 0) {
const batch = this.#getBatch(this.#batchSize)
//console.log('running with queue size of ' + this.#queue.length)
await this.#processFunction(batch)
}
if (this.#queue.size < this.#batchSize / 2) {
//refigure interval
const endTime = performance.now()
const duration = endTime - startTime
if (duration > interval) {
interval = Math.min(interval * 1.5, this.#maxInterval) // Increase if slow or empty
} else {
interval = Math.max(interval * 0.8, this.#minInterval) // Decrease if fast
}
/*console.log(
'queue is waiting ' +
interval / 1000 +
' with queue size of ' +
this.#queue.length
)*/
await this.#delay(interval)
}
}
}
#delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
@@ -1,46 +0,0 @@
import { Database } from '../core/interfaces.js'
import { CacheOptions } from '../core/options.js'
import { DefermentManager } from '../deferment/defermentManager.js'
import { Item } from '../types/types.js'
import BatchingQueue from './batchingQueue.js'
import Queue from './queue.js'
export class CacheWriter implements Queue<Item> {
#writeQueue: BatchingQueue<Item> | undefined
#database: Database
#defermentManager: DefermentManager
#options: CacheOptions
#disposed = false
constructor(
database: Database,
defermentManager: DefermentManager,
options: CacheOptions
) {
this.#database = database
this.#defermentManager = defermentManager
this.#options = options
}
add(item: Item): void {
if (!this.#writeQueue) {
this.#writeQueue = new BatchingQueue({
batchSize: this.#options.maxCacheWriteSize,
maxWaitTime: this.#options.maxCacheBatchWriteWait,
processFunction: (batch: Item[]): Promise<void> =>
this.#database.saveBatch({ batch })
})
}
this.#defermentManager.undefer(item)
this.#writeQueue.add(item.baseId, item)
}
async disposeAsync(): Promise<void> {
await this.#writeQueue?.disposeAsync()
this.#disposed = true
}
get isDisposed(): boolean {
return this.#disposed
}
}
@@ -55,6 +55,33 @@ input AppTokenCreateInput {
limitResources: [TokenResourceIdentifierInput!]
}
"""
A token used to enable an embedded viewer for a private project
"""
type EmbedToken {
tokenId: String!
projectId: String!
user: LimitedUser
resourceIdString: String!
createdAt: DateTime!
lifespan: BigInt!
lastUsed: DateTime!
}
input EmbedTokenCreateInput {
projectId: String!
"""
The model(s) and version(s) string used in the embed url
"""
resourceIdString: String!
lifespan: BigInt
}
type CreateEmbedTokenReturn {
token: String!
tokenMetadata: EmbedToken!
}
extend type Mutation {
"""
Creates an personal api token.
@@ -77,3 +104,21 @@ extend type Mutation {
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "tokens:write")
}
extend type ProjectMutations {
createEmbedToken(token: EmbedTokenCreateInput!): CreateEmbedTokenReturn!
@hasScope(scope: "tokens:write")
revokeEmbedToken(token: String!, projectId: String!): Boolean!
@hasScope(scope: "tokens:write")
revokeEmbedTokens(projectId: String!): Boolean! @hasScope(scope: "tokens:write")
}
type EmbedTokenCollection {
items: [EmbedToken!]!
totalCount: Int!
cursor: String
}
extend type Project {
embedTokens(cursor: String, limit: Int): EmbedTokenCollection!
}
@@ -16,6 +16,9 @@ type ProjectPermissionChecks {
canPublish: PermissionCheckResult!
canLoad: PermissionCheckResult!
canInvite: PermissionCheckResult!
canCreateEmbedTokens: PermissionCheckResult!
canRevokeEmbedTokens: PermissionCheckResult!
canReadEmbedTokens: PermissionCheckResult!
}
type RootPermissionChecks {
+1
View File
@@ -24,6 +24,7 @@ generates:
ProjectAccessRequestMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
LimitedUser: '@/modules/core/helpers/graphTypes#LimitedUserGraphQLReturn'
User: '@/modules/core/helpers/graphTypes#UserGraphQLReturn'
EmbedToken: '@/modules/core/helpers/graphTypes#EmbedTokenGraphQLReturn'
ActiveUserMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
UserMetaMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
UserEmailMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
@@ -21,6 +21,7 @@ import { mapStreamRoleToValue } from '@/modules/core/helpers/graphTypes'
import { Roles } from '@/modules/core/helpers/mainConstants'
import {
getStreamFactory,
getStreamRolesFactory,
grantStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import { getUserFactory } from '@/modules/core/repositories/users'
@@ -69,6 +70,7 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
@@ -12,7 +12,9 @@ import {
requestProjectAccessFactory
} from '@/modules/accessrequests/services/stream'
import { StreamActionTypes } from '@/modules/activitystream/helpers/types'
import { getActivitiesFactory } from '@/modules/activitystream/repositories/index'
import {
Activity,
ServerAccessRequests,
StreamActivity,
Streams,
@@ -24,6 +26,7 @@ import { Roles } from '@/modules/core/helpers/mainConstants'
import {
getStreamCollaboratorsFactory,
getStreamFactory,
getStreamRolesFactory,
grantStreamPermissionsFactory,
revokeStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
@@ -79,15 +82,17 @@ const removeStreamCollaborator = removeStreamCollaboratorFactory({
validateStreamAccess,
isStreamCollaborator,
revokeStreamPermissions: revokeStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const getActivities = getActivitiesFactory({ db })
const isNotCollaboratorError = (e: unknown) =>
e instanceof StreamAccessUpdateError &&
@@ -406,7 +411,11 @@ describe('Project access requests', () => {
let validReqId: string
beforeEach(async () => {
await truncateTables([ServerAccessRequests.name, StreamActivity.name])
await truncateTables([
ServerAccessRequests.name,
StreamActivity.name,
Activity.name
])
await removeStreamCollaborator(
myPrivateStream.id,
otherGuy.id,
@@ -464,8 +473,8 @@ describe('Project access requests', () => {
// activity stream item should be inserted
if (accept) {
const streamActivity = await getStreamActivities(myPrivateStream.id, {
actionType: StreamActionTypes.Stream.PermissionsAdd,
const streamActivity = await getActivities({
projectId: myPrivateStream.id,
userId: me.id
})
expect(streamActivity).to.have.lengthOf(1)
@@ -13,7 +13,9 @@ import {
requestStreamAccessFactory
} from '@/modules/accessrequests/services/stream'
import { StreamActionTypes } from '@/modules/activitystream/helpers/types'
import { getActivitiesFactory } from '@/modules/activitystream/repositories/index'
import {
Activity,
ServerAccessRequests,
StreamActivity,
Streams,
@@ -25,6 +27,7 @@ import { Roles } from '@/modules/core/helpers/mainConstants'
import {
getStreamCollaboratorsFactory,
getStreamFactory,
getStreamRolesFactory,
grantStreamPermissionsFactory,
revokeStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
@@ -82,6 +85,7 @@ const removeStreamCollaborator = removeStreamCollaboratorFactory({
validateStreamAccess,
isStreamCollaborator,
revokeStreamPermissions: revokeStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
@@ -89,8 +93,10 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const getActivities = getActivitiesFactory({ db })
const isNotCollaboratorError = (e: unknown) =>
e instanceof StreamAccessUpdateError &&
@@ -375,7 +381,11 @@ describe('Stream access requests', () => {
let validReqId: string
beforeEach(async () => {
await truncateTables([ServerAccessRequests.name, StreamActivity.name])
await truncateTables([
ServerAccessRequests.name,
StreamActivity.name,
Activity.name
])
await removeStreamCollaborator(
myPrivateStream.id,
otherGuy.id,
@@ -424,9 +434,10 @@ describe('Stream access requests', () => {
// activity stream item should be inserted
if (accept) {
const streamActivity = await getStreamActivities(myPrivateStream.id, {
actionType: StreamActionTypes.Stream.PermissionsAdd,
userId: me.id
const streamActivity = await getActivities({
projectId: myPrivateStream.id,
userId: me.id,
eventType: 'project_role_updated'
})
expect(streamActivity).to.have.lengthOf(1)
@@ -1,4 +1,5 @@
import {
Activity,
ActivitySummary,
CommentCreatedActivityInput,
ReplyCreatedActivityInput,
@@ -7,7 +8,6 @@ import {
StreamActionType
} from '@/modules/activitystream/domain/types'
import {
Activity,
StreamActivityRecord,
StreamScopeActivity
} from '@/modules/activitystream/helpers/types'
@@ -32,7 +32,7 @@ import {
} from '@/modules/core/helpers/types'
import { Nullable } from '@speckle/shared'
export type GetActivity = (
export type GetUserStreamActivity = (
streamId: string,
start: Date,
end: Date,
@@ -281,3 +281,12 @@ export type SaveActivity = <
>(
args: Omit<Activity<T, R>, 'createdAt' | 'id'>
) => Promise<Activity<T, R>>
type GetActivitiesArgs = Partial<{
workspaceId: string
projectId: string
eventType: string
userId: string
}>
export type GetActivities = (filters: GetActivitiesArgs) => Promise<Activity[]>
@@ -23,6 +23,23 @@ export type ResourceEventsToPayloadMap = {
workspace_seat_updated: z.infer<typeof WorkspaceSeatUpdatedActivity>
workspace_seat_deleted: z.infer<typeof WorkspaceSeatDeletedActivity>
}
project: {
project_role_updated: z.infer<typeof ProjectRoleUpdatedActivity>
project_role_deleted: z.infer<typeof ProjectRoleDeletedActivity>
}
}
export interface Activity<
T extends keyof ResourceEventsToPayloadMap = keyof ResourceEventsToPayloadMap,
R extends keyof ResourceEventsToPayloadMap[T] = keyof ResourceEventsToPayloadMap[T]
> {
id: string
contextResourceId: string
contextResourceType: T
eventType: R
userId: string | null
payload: ResourceEventsToPayloadMap[T][R]
createdAt: Date
}
const workspacePlan = z.object({
@@ -56,6 +73,12 @@ const workspaceSubscription = z.object({
totalEditorSeats: z.number()
})
const projectRole = z.union([
z.literal('stream:owner'),
z.literal('stream:contributor'),
z.literal('stream:reviewer')
])
export const WorkspacePlanCreatedActivity = z.object({
version: z.literal('1'),
new: workspacePlan
@@ -84,6 +107,19 @@ export const WorkspaceSeatDeletedActivity = z.object({
old: workspaceSeat
})
export const ProjectRoleUpdatedActivity = z.object({
version: z.literal('1'),
userId: z.string(),
new: projectRole,
old: z.nullable(projectRole)
})
export const ProjectRoleDeletedActivity = z.object({
version: z.literal('1'),
userId: z.string(),
old: projectRole
})
// Stream Activity
export type StreamActionType =
@@ -19,13 +19,13 @@ import {
*/
const addStreamAccessRequestedActivityFactory =
({
saveActivity
saveStreamActivity
}: {
saveActivity: SaveStreamActivity
saveStreamActivity: SaveStreamActivity
}): AddStreamAccessRequestedActivity =>
async (params: { streamId: string; requesterId: string }) => {
const { streamId, requesterId } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Stream,
resourceId: streamId,
@@ -41,13 +41,13 @@ const addStreamAccessRequestedActivityFactory =
*/
const addStreamAccessRequestDeclinedActivityFactory =
({
saveActivity
saveStreamActivity
}: {
saveActivity: SaveStreamActivity
saveStreamActivity: SaveStreamActivity
}): AddStreamAccessRequestDeclinedActivity =>
async (params: { streamId: string; requesterId: string; declinerId: string }) => {
const { streamId, requesterId, declinerId } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Stream,
resourceId: streamId,
@@ -104,7 +104,8 @@ const onServerAccessRequestFinalizedFactory =
}
export const reportAccessRequestActivityFactory =
(deps: { eventListen: EventBusListen; saveActivity: SaveStreamActivity }) => () => {
(deps: { eventListen: EventBusListen; saveStreamActivity: SaveStreamActivity }) =>
() => {
const addStreamAccessRequestedActivity =
addStreamAccessRequestedActivityFactory(deps)
const addStreamAccessRequestDeclinedActivity =
@@ -16,11 +16,15 @@ import { EventBusListen } from '@/modules/shared/services/eventBus'
* Save "branch created" activity
*/
const addBranchCreatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddBranchCreatedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddBranchCreatedActivity =>
async (params) => {
const { branch } = params
await saveActivity({
await saveStreamActivity({
streamId: branch.streamId,
resourceType: StreamResourceTypes.Branch,
resourceId: branch.id,
@@ -32,12 +36,16 @@ const addBranchCreatedActivityFactory =
}
const addBranchUpdatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddBranchUpdatedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddBranchUpdatedActivity =>
async (params) => {
const { update, userId, oldBranch } = params
const streamId = isBranchUpdateInput(update) ? update.streamId : update.projectId
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Branch,
resourceId: update.id,
@@ -49,13 +57,17 @@ const addBranchUpdatedActivityFactory =
}
const addBranchDeletedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddBranchDeletedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddBranchDeletedActivity =>
async (params) => {
const { input, userId, branchName } = params
const streamId = isBranchDeleteInput(input) ? input.streamId : input.projectId
await Promise.all([
saveActivity({
saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Branch,
resourceId: input.id,
@@ -68,7 +80,8 @@ const addBranchDeletedActivityFactory =
}
export const reportBranchActivityFactory =
(deps: { eventListen: EventBusListen; saveActivity: SaveStreamActivity }) => () => {
(deps: { eventListen: EventBusListen; saveStreamActivity: SaveStreamActivity }) =>
() => {
const addBranchCreatedActivity = addBranchCreatedActivityFactory(deps)
const addBranchUpdatedActivity = addBranchUpdatedActivityFactory(deps)
const addBranchDeletedActivity = addBranchDeletedActivityFactory(deps)
@@ -19,11 +19,15 @@ import { has } from 'lodash'
import { OverrideProperties } from 'type-fest'
const addThreadCreatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddThreadCreatedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddThreadCreatedActivity =>
async (params) => {
const { input, comment } = params
await saveActivity({
await saveStreamActivity({
resourceId: comment.id,
streamId: comment.streamId,
resourceType: StreamResourceTypes.Comment,
@@ -39,14 +43,18 @@ const isLegacyReplyCreateInput = (
): i is ReplyCreateInput => has(i, 'streamId')
const addReplyAddedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddReplyAddedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddReplyAddedActivity =>
async (params) => {
const { input, reply } = params
const parentCommentId = isLegacyReplyCreateInput(input)
? input.parentComment
: input.threadId
await saveActivity({
await saveStreamActivity({
streamId: reply.streamId,
resourceType: StreamResourceTypes.Comment,
resourceId: parentCommentId,
@@ -59,14 +67,14 @@ const addReplyAddedActivityFactory =
const addCommentArchivedActivityFactory =
({
saveActivity
saveStreamActivity
}: {
saveActivity: SaveStreamActivity
saveStreamActivity: SaveStreamActivity
}): AddCommentArchivedActivity =>
async (params) => {
const { userId, input, comment } = params
await saveActivity({
await saveStreamActivity({
streamId: comment.streamId,
resourceType: StreamResourceTypes.Comment,
resourceId: comment.id,
@@ -100,7 +108,8 @@ const isThreadCreatedPayload = (
}
export const reportCommentActivityFactory =
(deps: { eventListen: EventBusListen; saveActivity: SaveStreamActivity }) => () => {
(deps: { eventListen: EventBusListen; saveStreamActivity: SaveStreamActivity }) =>
() => {
const addThreadCreatedActivity = addThreadCreatedActivityFactory(deps)
const addReplyAddedActivity = addReplyAddedActivityFactory(deps)
const addCommentArchivedActivity = addCommentArchivedActivityFactory(deps)
@@ -18,7 +18,11 @@ import { MaybeNullOrUndefined } from '@speckle/shared'
* Save "new commit created" activity item
*/
const addCommitCreatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddCommitCreatedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddCommitCreatedActivity =>
async (params: {
commitId: string
streamId: string
@@ -29,7 +33,7 @@ const addCommitCreatedActivityFactory =
commit: CommitRecord
}) => {
const { commitId, input, streamId, userId, branchName, commit, modelId } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Commit,
resourceId: commitId,
@@ -49,11 +53,15 @@ const addCommitCreatedActivityFactory =
}
const addCommitUpdatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddCommitUpdatedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddCommitUpdatedActivity =>
async (params) => {
const { commitId, streamId, userId, originalCommit, update } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Commit,
resourceId: commitId,
@@ -65,7 +73,7 @@ const addCommitUpdatedActivityFactory =
}
const addCommitMovedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }) =>
({ saveStreamActivity }: { saveStreamActivity: SaveStreamActivity }) =>
async (params: {
commitId: string
streamId: string
@@ -75,7 +83,7 @@ const addCommitMovedActivityFactory =
commit: CommitRecord
}) => {
const { commitId, streamId, userId, originalBranchId, newBranchId } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Commit,
resourceId: commitId,
@@ -87,7 +95,11 @@ const addCommitMovedActivityFactory =
}
const addCommitDeletedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddCommitDeletedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddCommitDeletedActivity =>
async (params: {
commitId: string
streamId: string
@@ -96,7 +108,7 @@ const addCommitDeletedActivityFactory =
branchId: string
}) => {
const { commitId, streamId, userId, commit } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Commit,
resourceId: commitId,
@@ -108,7 +120,7 @@ const addCommitDeletedActivityFactory =
}
const addCommitReceivedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }) =>
({ saveStreamActivity }: { saveStreamActivity: SaveStreamActivity }) =>
async (params: {
streamId: string
commitId: string
@@ -117,7 +129,7 @@ const addCommitReceivedActivityFactory =
message: MaybeNullOrUndefined<string>
}) => {
const { streamId, commitId, userId, sourceApplication, message } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Commit,
resourceId: commitId,
@@ -132,7 +144,8 @@ const addCommitReceivedActivityFactory =
}
export const reportCommitActivityFactory =
(deps: { eventListen: EventBusListen; saveActivity: SaveStreamActivity }) => () => {
(deps: { eventListen: EventBusListen; saveStreamActivity: SaveStreamActivity }) =>
() => {
const addCommitCreatedActivity = addCommitCreatedActivityFactory(deps)
const addCommitUpdatedActivity = addCommitUpdatedActivityFactory(deps)
const addCommitMovedActivity = addCommitMovedActivityFactory(deps)
@@ -18,7 +18,7 @@ import { Roles } from '@speckle/shared'
*/
const addStreamInviteSentOutActivityFactory =
(deps: {
saveActivity: SaveStreamActivity
saveStreamActivity: SaveStreamActivity
getProjectInviteProject: GetProjectInviteProject
}) =>
async (payload: EventPayload<typeof ServerInvitesEvents.Created>) => {
@@ -29,7 +29,7 @@ const addStreamInviteSentOutActivityFactory =
const userTarget = resolveTarget(invite.target)
const targetDisplay = userTarget.userId || userTarget.userEmail
await deps.saveActivity({
await deps.saveStreamActivity({
streamId: project.id,
resourceType: StreamResourceTypes.Stream,
resourceId: project.id,
@@ -48,7 +48,7 @@ const addStreamInviteSentOutActivityFactory =
*/
const addStreamInviteAcceptedActivityFactory =
(deps: {
saveActivity: SaveStreamActivity
saveStreamActivity: SaveStreamActivity
getProjectInviteProject: GetProjectInviteProject
}) =>
async (payload: EventPayload<typeof ServerInvitesEvents.Finalized>) => {
@@ -63,7 +63,7 @@ const addStreamInviteAcceptedActivityFactory =
const differentFinalizer = trueFinalizerUserId !== userTarget.userId
await deps.saveActivity({
await deps.saveStreamActivity({
streamId: project.id,
resourceType: StreamResourceTypes.Stream,
resourceId: project.id,
@@ -81,7 +81,7 @@ const addStreamInviteAcceptedActivityFactory =
*/
const addStreamInviteDeclinedActivityFactory =
(deps: {
saveActivity: SaveStreamActivity
saveStreamActivity: SaveStreamActivity
getProjectInviteProject: GetProjectInviteProject
}) =>
async (payload: EventPayload<typeof ServerInvitesEvents.Finalized>) => {
@@ -90,7 +90,7 @@ const addStreamInviteDeclinedActivityFactory =
if (!project) return
const userTarget = resolveTarget(invite.target)
await deps.saveActivity({
await deps.saveStreamActivity({
streamId: project.id,
resourceType: StreamResourceTypes.Stream,
resourceId: project.id,
@@ -104,7 +104,7 @@ const addStreamInviteDeclinedActivityFactory =
export const reportStreamInviteActivityFactory =
(deps: {
eventListen: EventBusListen
saveActivity: SaveStreamActivity
saveStreamActivity: SaveStreamActivity
getProjectInviteProject: GetProjectInviteProject
}) =>
() => {
@@ -1,6 +1,7 @@
import {
AddStreamDeletedActivity,
AddStreamUpdatedActivity,
SaveActivity,
SaveStreamActivity
} from '@/modules/activitystream/domain/operations'
import {
@@ -13,14 +14,54 @@ import {
StreamCreateInput
} from '@/modules/core/graph/generated/graphql'
import { StreamRecord } from '@/modules/core/helpers/types'
import { EventBusListen } from '@/modules/shared/services/eventBus'
import { StreamRoles } from '@speckle/shared'
import { EventBusListen, EventPayload } from '@/modules/shared/services/eventBus'
// Activity
const addProjectPermissionsAddedActivityFactory =
({ saveActivity }: { saveActivity: SaveActivity }) =>
async ({
payload: { activityUserId, project, role, targetUserId, previousRole }
}: EventPayload<typeof ProjectEvents.PermissionsAdded>) => {
await saveActivity({
contextResourceId: project.id,
contextResourceType: 'project',
eventType: 'project_role_updated',
userId: activityUserId,
payload: {
version: '1',
userId: targetUserId,
new: role,
old: previousRole
}
})
}
const addProjectPermissionsRevokedActivityFactory =
({ saveActivity }: { saveActivity: SaveActivity }) =>
async ({
payload: { activityUserId, project, removedUserId, role }
}: EventPayload<typeof ProjectEvents.PermissionsRevoked>) => {
await saveActivity({
contextResourceId: project.id,
contextResourceType: 'project',
eventType: 'project_role_deleted',
userId: activityUserId,
payload: {
version: '1',
userId: removedUserId,
old: role
}
})
}
// Stream activity
/**
* Save "user created stream" activity item
*/
const addStreamCreatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }) =>
({ saveStreamActivity }: { saveStreamActivity: SaveStreamActivity }) =>
async (params: {
streamId: string
creatorId: string
@@ -29,7 +70,7 @@ const addStreamCreatedActivityFactory =
}) => {
const { streamId, creatorId, input } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Stream,
resourceId: streamId,
@@ -44,11 +85,15 @@ const addStreamCreatedActivityFactory =
* Save "stream updated" activity
*/
const addStreamUpdatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddStreamUpdatedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddStreamUpdatedActivity =>
async (params) => {
const { streamId, updaterId, oldStream, update } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Stream,
resourceId: streamId,
@@ -63,11 +108,15 @@ const addStreamUpdatedActivityFactory =
* Save "stream deleted" activity
*/
const addStreamDeletedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }): AddStreamDeletedActivity =>
({
saveStreamActivity
}: {
saveStreamActivity: SaveStreamActivity
}): AddStreamDeletedActivity =>
async (params) => {
const { streamId, deleterId } = params
await saveActivity({
await saveStreamActivity({
streamId,
resourceType: StreamResourceTypes.Stream,
resourceId: streamId,
@@ -82,7 +131,7 @@ const addStreamDeletedActivityFactory =
* Save "user cloned stream X" activity item
*/
const addStreamClonedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }) =>
({ saveStreamActivity }: { saveStreamActivity: SaveStreamActivity }) =>
async (params: {
sourceStreamId: string
newStream: StreamRecord
@@ -91,7 +140,7 @@ const addStreamClonedActivityFactory =
const { sourceStreamId, newStream, clonerId } = params
const newStreamId = newStream.id
await saveActivity({
await saveStreamActivity({
streamId: newStreamId,
resourceType: StreamResourceTypes.Stream,
resourceId: newStreamId,
@@ -102,68 +151,31 @@ const addStreamClonedActivityFactory =
})
}
/**
* Save "stream permissions granted to user" activity item
*/
const addStreamPermissionsAddedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }) =>
async (params: {
streamId: string
activityUserId: string
targetUserId: string
role: StreamRoles
}) => {
const { streamId, activityUserId, targetUserId, role } = params
await saveActivity({
streamId,
resourceType: StreamResourceTypes.Stream,
resourceId: streamId,
actionType: StreamActionTypes.Stream.PermissionsAdd,
userId: activityUserId,
info: { targetUser: targetUserId, role },
message: `Permission granted to user ${targetUserId} (${role})`
})
}
/**
* Save "stream permissions revoked for user" activity item
*/
const addStreamPermissionsRevokedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }) =>
async (params: {
streamId: string
activityUserId: string
removedUserId: string
stream: StreamRecord
}) => {
const { streamId, activityUserId, removedUserId } = params
const isVoluntaryLeave = activityUserId === removedUserId
await saveActivity({
streamId,
resourceType: StreamResourceTypes.Stream,
resourceId: streamId,
actionType: StreamActionTypes.Stream.PermissionsRemove,
userId: activityUserId,
info: { targetUser: removedUserId },
message: isVoluntaryLeave
? `User ${removedUserId} left the stream`
: `Permission revoked for user ${removedUserId}`
})
}
export const reportStreamActivityFactory =
(deps: { eventListen: EventBusListen; saveActivity: SaveStreamActivity }) => () => {
(deps: {
eventListen: EventBusListen
saveActivity: SaveActivity
saveStreamActivity: SaveStreamActivity
}) =>
() => {
const addProjectPermissionsAddedActivity =
addProjectPermissionsAddedActivityFactory(deps)
const addProjectPermissionsRevokedActivity =
addProjectPermissionsRevokedActivityFactory(deps)
const addStreamCreatedActivity = addStreamCreatedActivityFactory(deps)
const addStreamUpdatedActivity = addStreamUpdatedActivityFactory(deps)
const addStreamDeletedActivity = addStreamDeletedActivityFactory(deps)
const addStreamClonedActivity = addStreamClonedActivityFactory(deps)
const addStreamPermissionsAddedActivity =
addStreamPermissionsAddedActivityFactory(deps)
const addStreamPermissionsRevokedActivity =
addStreamPermissionsRevokedActivityFactory(deps)
const quitters = [
deps.eventListen(
ProjectEvents.PermissionsAdded,
addProjectPermissionsAddedActivity
),
deps.eventListen(
ProjectEvents.PermissionsRevoked,
addProjectPermissionsRevokedActivity
),
deps.eventListen(ProjectEvents.Created, async ({ payload }) => {
await addStreamCreatedActivity({
stream: payload.project,
@@ -194,22 +206,6 @@ export const reportStreamActivityFactory =
newStream: payload.newProject,
clonerId: payload.clonerId
})
}),
deps.eventListen(ProjectEvents.PermissionsAdded, async ({ payload }) => {
await addStreamPermissionsAddedActivity({
streamId: payload.project.id,
activityUserId: payload.activityUserId,
targetUserId: payload.targetUserId,
role: payload.role
})
}),
deps.eventListen(ProjectEvents.PermissionsRevoked, async ({ payload }) => {
await addStreamPermissionsRevokedActivity({
streamId: payload.project.id,
activityUserId: payload.activityUserId,
removedUserId: payload.removedUserId,
stream: payload.project
})
})
]
@@ -9,11 +9,11 @@ import { EventBusListen, EventPayload } from '@/modules/shared/services/eventBus
import { UserEvents } from '@/modules/core/domain/users/events'
const addUserCreatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }) =>
({ saveStreamActivity }: { saveStreamActivity: SaveStreamActivity }) =>
async (payload: EventPayload<typeof UserEvents.Created>) => {
const { user } = payload.payload
await saveActivity({
await saveStreamActivity({
streamId: null,
resourceType: StreamResourceTypes.User,
resourceId: user.id,
@@ -25,7 +25,7 @@ const addUserCreatedActivityFactory =
}
const addUserUpdatedActivityFactory =
({ saveActivity }: { saveActivity: SaveStreamActivity }) =>
({ saveStreamActivity }: { saveStreamActivity: SaveStreamActivity }) =>
async (params: {
oldUser: UserRecord
update: UserUpdateInput
@@ -33,7 +33,7 @@ const addUserUpdatedActivityFactory =
}) => {
const { oldUser, update, updaterId } = params
await saveActivity({
await saveStreamActivity({
streamId: null,
resourceType: StreamResourceTypes.User,
resourceId: oldUser.id,
@@ -45,11 +45,11 @@ const addUserUpdatedActivityFactory =
}
const addUserDeletedActivityFactory =
(deps: { saveActivity: SaveStreamActivity }) =>
(deps: { saveStreamActivity: SaveStreamActivity }) =>
async (params: { targetUserId: string; invokerUserId: string }) => {
const { targetUserId, invokerUserId } = params
await deps.saveActivity({
await deps.saveStreamActivity({
streamId: null,
resourceType: 'user',
resourceId: targetUserId,
@@ -61,7 +61,8 @@ const addUserDeletedActivityFactory =
}
export const reportUserActivityFactory =
(deps: { eventListen: EventBusListen; saveActivity: SaveStreamActivity }) => () => {
(deps: { eventListen: EventBusListen; saveStreamActivity: SaveStreamActivity }) =>
() => {
const addUserDeletedActivity = addUserDeletedActivityFactory(deps)
const addUserUpdatedActivity = addUserUpdatedActivityFactory(deps)
const addUserCreatedActivity = addUserCreatedActivityFactory(deps)
@@ -1,18 +1,4 @@
import { Nullable } from '@/modules/shared/helpers/typeHelper'
import { ResourceEventsToPayloadMap } from '@/modules/activitystream/domain/types'
export interface Activity<
T extends keyof ResourceEventsToPayloadMap,
R extends keyof ResourceEventsToPayloadMap[T]
> {
id: string
contextResourceId: string
contextResourceType: T
eventType: R
userId: string | null
payload: ResourceEventsToPayloadMap[T][R]
createdAt: Date
}
export type StreamActivityRecord = {
streamId: Nullable<string>
@@ -44,45 +44,47 @@ const initializeEventListeners = ({
eventBus: EventBus
db: Knex
}) => {
const saveActivity = saveActivityFactory({ db })
const saveStreamActivity = saveStreamActivityFactory({ db })
const reportUserActivity = reportUserActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveStreamActivity
saveStreamActivity
})
const reportAccessRequestActivity = reportAccessRequestActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveStreamActivity
saveStreamActivity
})
const reportBranchActivity = reportBranchActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveStreamActivity
saveStreamActivity
})
const reportCommitActivity = reportCommitActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveStreamActivity
saveStreamActivity
})
const reportCommentActivity = reportCommentActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveStreamActivity
saveStreamActivity
})
const reportStreamInviteActivity = reportStreamInviteActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveStreamActivity,
saveStreamActivity,
getProjectInviteProject: getProjectInviteProjectFactory({
getStream: getStreamFactory({ db })
})
})
const reportStreamActivity = reportStreamActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveStreamActivity
saveActivity,
saveStreamActivity
})
const reportGatekeeperActivity = reportGatekeeperActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveActivityFactory({ db })
saveActivity
})
const reportWorkspaceActivity = reportWorkspaceActivityFactory({
eventListen: eventBus.listen,
saveActivity: saveActivityFactory({ db })
saveActivity
})
const quitCbs = [
@@ -1,5 +1,6 @@
import {
GetActiveUserStreams,
GetActivities,
GetActivityCountByResourceId,
GetActivityCountByStreamId,
GetActivityCountByUserId,
@@ -15,7 +16,11 @@ import {
StreamActivityRecord,
StreamScopeActivity
} from '@/modules/activitystream/helpers/types'
import { Activity, StreamAcl, StreamActivity } from '@/modules/core/dbSchema'
import {
Activity as ActivityModel,
StreamAcl,
StreamActivity
} from '@/modules/core/dbSchema'
import { Roles } from '@/modules/core/helpers/mainConstants'
import { StreamAclRecord } from '@/modules/core/helpers/types'
import {
@@ -29,6 +34,7 @@ import { getUserFactory } from '@/modules/core/repositories/users'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import cryptoRandomString from 'crypto-random-string'
import { Activity } from '@/modules/activitystream/domain/types'
const tables = {
streamActivity: <T extends object = StreamActivityRecord>(db: Knex) =>
@@ -36,7 +42,7 @@ const tables = {
streamAcl: (db: Knex) => db<StreamAclRecord>(StreamAcl.name)
}
export const getActivityFactory =
export const geUserStreamActivityFactory =
({ db }: { db: Knex }) =>
async (
streamId: string,
@@ -280,8 +286,40 @@ export const saveActivityFactory =
const createdAt = new Date()
const [result] = await db<typeof activity & { id: string; createdAt: Date }>(
Activity.name
ActivityModel.name
).insert({ ...activity, id, createdAt }, '*')
return result
}
export const getActivitiesFactory =
({ db }: { db: Knex }): GetActivities =>
async (filters = {}): Promise<Activity[]> => {
const { workspaceId, projectId, eventType, userId } = filters
const q = db<Activity>(ActivityModel.name).select('*')
if (projectId) {
q.where(ActivityModel.col.contextResourceId, projectId).andWhere(
ActivityModel.col.contextResourceType,
'project'
)
}
if (workspaceId) {
q.where(ActivityModel.col.contextResourceId, workspaceId).andWhere(
ActivityModel.col.contextResourceType,
'workspace'
)
}
if (eventType) {
q.andWhere(ActivityModel.col.eventType, eventType)
}
if (userId) {
q.andWhere(ActivityModel.col.userId, userId)
}
return await q
}
@@ -5,7 +5,7 @@ import {
import {
CreateActivitySummary,
GetActiveUserStreams,
GetActivity
GetUserStreamActivity
} from '@/modules/activitystream/domain/operations'
import { GetStream } from '@/modules/core/domain/streams/operations'
import { GetUser } from '@/modules/core/domain/users/operations'
@@ -17,7 +17,7 @@ export const createActivitySummaryFactory =
getUser
}: {
getStream: GetStream
getActivity: GetActivity
getActivity: GetUserStreamActivity
getUser: GetUser
}): CreateActivitySummary =>
async ({
@@ -12,7 +12,10 @@ import {
addOrUpdateStreamCollaboratorFactory
} from '@/modules/core/services/streams/access'
import { authorizeResolver } from '@/modules/shared'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import {
getStreamRolesFactory,
grantStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import {
getUserFactory,
storeUserFactory,
@@ -49,6 +52,7 @@ import { getEventBus } from '@/modules/shared/services/eventBus'
import type http from 'node:http'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { BasicTestBranch, createTestBranch } from '@/test/speckle-helpers/branchHelper'
import { getActivitiesFactory } from '@/modules/activitystream/repositories/index'
const getUser = getUserFactory({ db })
const getUserActivity = getUserActivityFactory({ db })
@@ -57,6 +61,7 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
@@ -100,6 +105,8 @@ const createObject = createObjectFactory({
storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db })
})
const getActivities = getActivitiesFactory({ db })
let server: http.Server
let sendRequest: Awaited<ReturnType<typeof initializeTestServer>>['sendRequest']
@@ -265,11 +272,37 @@ describe('Activity @activity', () => {
expect(activityC.length).to.equal(3)
expect(activityC[0].actionType).to.equal('commit_create')
const { items: activityI } = await getUserActivity({ userId: userIz.id })
const activityI = await getUserActivity({ userId: userIz.id })
// iz1 to iz4 + user created + user added as collaborator
expect(activityI.length).to.equal(6)
expect(activityI[0].actionType).to.equal('stream_permissions_add')
expect(activityI.items.length).to.equal(4)
expect(activityI).to.nested.include({
'items[0].actionType': 'commit_create',
'items[1].actionType': 'branch_create',
'items[2].actionType': 'stream_create',
'items[3].actionType': 'user_create'
})
const activity = { items: await getActivities({ userId: userIz.id }) }
expect(activity.items.length).to.equal(3)
expect(activity).to.nested.include({
'items[0].eventType': 'project_role_updated',
'items[0].payload.new': 'stream:owner',
'items[0].payload.old': null,
'items[0].userId': userIz.id, // created branch
'items[1].eventType': 'project_role_updated',
'items[1].payload.new': 'stream:reviewer',
'items[1].payload.old': null,
'items[1].payload.userId': userCr.id,
'items[1].userId': userIz.id, // added user
'items[2].eventType': 'project_role_updated',
'items[2].payload.new': 'stream:contributor',
'items[2].payload.old': 'stream:reviewer',
'items[2].payload.userId': userCr.id,
'items[2].userId': userIz.id // made him a contibutor
})
})
after(async () => {
@@ -283,9 +316,9 @@ describe('Activity @activity', () => {
expect(noErrors(res))
const activity = res.body.data.activeUser.activity
expect(activity.items.length).to.equal(6)
expect(activity.totalCount).to.equal(6)
expect(activity.items[0].actionType).to.equal('stream_permissions_add')
expect(activity.items.length).to.equal(4)
expect(activity.totalCount).to.equal(4)
expect(activity.items[0].actionType).to.equal('commit_create')
expect(activity.items[activity.totalCount - 1].actionType).to.equal('user_create')
})
@@ -303,8 +336,8 @@ describe('Activity @activity', () => {
query: `query {otherUser(id:"${userCr.id}") { name timeline { totalCount items {id streamId resourceType resourceId actionType userId message time}}} }`
})
expect(noErrors(res))
expect(res.body.data.otherUser.timeline.items.length).to.equal(7) // sum of all actions in before hook
expect(res.body.data.otherUser.timeline.totalCount).to.equal(7)
expect(res.body.data.otherUser.timeline.items.length).to.equal(5) // sum of all actions in before hook
expect(res.body.data.otherUser.timeline.totalCount).to.equal(5)
})
it("Should get a stream's activity", async () => {
@@ -313,8 +346,8 @@ describe('Activity @activity', () => {
})
expect(noErrors(res))
const activity = res.body.data.stream.activity
expect(activity.items.length).to.equal(5)
expect(activity.totalCount).to.equal(5)
expect(activity.items.length).to.equal(3)
expect(activity.totalCount).to.equal(3)
expect(activity.items[activity.totalCount - 1].actionType).to.equal('stream_create')
})
@@ -16,7 +16,7 @@ import {
NotificationTypeMessageMap
} from '@/modules/notifications/helpers/types'
import {
getActivityFactory,
geUserStreamActivityFactory,
saveStreamActivityFactory
} from '@/modules/activitystream/repositories'
import { db } from '@/db/knex'
@@ -24,6 +24,7 @@ import {
createStreamFactory,
deleteStreamFactory,
getStreamFactory,
getStreamRolesFactory,
grantStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import {
@@ -81,7 +82,7 @@ const getStream = getStreamFactory({ db })
const saveActivity = saveStreamActivityFactory({ db })
const createActivitySummary = createActivitySummaryFactory({
getStream,
getActivity: getActivityFactory({ db }),
getActivity: geUserStreamActivityFactory({ db }),
getUser
})
@@ -97,6 +98,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -74,6 +74,13 @@ export type PersonalApiTokenRecord = {
tokenId: string
}
export type EmbedApiTokenRecord = {
projectId: string
tokenId: string
userId: string
resourceIdString: string
}
export type TokenScopeRecord = {
tokenId: string
scopeName: ServerScope
@@ -22,7 +22,8 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se
import {
getStreamFactory,
createStreamFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents'
import { getEventBus } from '@/modules/shared/services/eventBus'
@@ -95,6 +96,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -48,7 +48,10 @@ import {
validateStreamAccessFactory
} from '@/modules/core/services/streams/access'
import { authorizeResolver } from '@/modules/shared'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import {
getStreamRolesFactory,
grantStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import { getUserFactory } from '@/modules/core/repositories/users'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { AutomationEvents } from '@/modules/automate/domain/events'
@@ -67,6 +70,7 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
@@ -2,6 +2,7 @@ import {
getS3AccessKey,
getS3BucketName,
getS3Endpoint,
getS3PublicEndpoint,
getS3Region,
getS3SecretKey
} from '@/modules/shared/helpers/envHelper'
@@ -51,9 +52,17 @@ export const getObjectStorage = (params: GetObjectStorageParams): ObjectStorage
}
let mainObjectStorage: Optional<ObjectStorage> = undefined
let publicMainObjectStorage: Optional<ObjectStorage> = undefined
/**
* Get main object storage client
*
* This is used for connecting the server to the S3 host. Where the S3 host is
* on the same private network as the server (e.g. in a Docker network),
* the S3_ENDPOINT can use the private IP or DNS name of the S3 host.
*
* S3_PUBLIC_ENDPOINT can be used to connect to the S3 host via the
* public internet (or localhost network if running locally or testing).
*/
export const getMainObjectStorage = (): ObjectStorage => {
if (mainObjectStorage) return mainObjectStorage
@@ -72,6 +81,37 @@ export const getMainObjectStorage = (): ObjectStorage => {
return mainObjectStorage
}
/**
* (Optional) Used to connect to the S3 host via the public endpoint.
* This is useful for clients that need to access the S3 bucket directly, e.g
* during testing or when the S3 host is not on the same private network as the server.
*
* If `S3_PUBLIC_ENDPOINT` is not set, it will return the same object storage
* as `getMainObjectStorage`.
*/
export const getPublicMainObjectStorage = (): ObjectStorage => {
if (publicMainObjectStorage) return publicMainObjectStorage
const endpoint = getS3PublicEndpoint()
if (!endpoint) {
// If no public endpoint is set, return the main object storage
return getMainObjectStorage()
}
const mainParams: GetObjectStorageParams = {
credentials: {
accessKeyId: getS3AccessKey(),
secretAccessKey: getS3SecretKey()
},
endpoint,
region: getS3Region(),
bucket: getS3BucketName()
}
publicMainObjectStorage = getObjectStorage(mainParams)
return publicMainObjectStorage
}
export const getSignedUrlFactory = (deps: {
objectStorage: ObjectStorage
}): GetSignedUrl => {
@@ -124,7 +124,9 @@ export const blobStorageRouterFactory = (): Router => {
const getBlobMetadata = getBlobMetadataFactory({ db: projectDb })
const getFileStream = getFileStreamFactory({ getBlobMetadata })
const getObjectStream = getObjectStreamFactory({ storage: projectStorage })
const getObjectStream = getObjectStreamFactory({
storage: projectStorage.private
})
const { fileName } = await getBlobMetadata({
streamId: req.params.streamId,
@@ -160,7 +162,7 @@ export const blobStorageRouterFactory = (): Router => {
])
const getBlobMetadata = getBlobMetadataFactory({ db: projectDb })
const deleteObject = deleteObjectFactory({ storage: projectStorage })
const deleteObject = deleteObjectFactory({ storage: projectStorage.private })
const deleteBlob = fullyDeleteBlobFactory({
getBlobMetadata,
deleteBlob: deleteBlobFactory({ db: projectDb }),
@@ -45,7 +45,7 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => {
getProjectObjectStorage({ projectId: streamId })
])
const storeFileStream = storeFileStreamFactory({ storage: projectStorage })
const storeFileStream = storeFileStreamFactory({ storage: projectStorage.private })
const updateBlob = updateBlobFactory({ db: projectDb })
const getBlobMetadata = getBlobMetadataFactory({ db: projectDb })
@@ -66,9 +66,9 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => {
})
const getObjectAttributes = getObjectAttributesFactory({
storage: projectStorage
storage: projectStorage.private
})
const deleteObject = deleteObjectFactory({ storage: projectStorage })
const deleteObject = deleteObjectFactory({ storage: projectStorage.private })
busboy.on(
'file',
@@ -12,7 +12,8 @@ import {
import {
getStreamFactory,
createStreamFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import { db } from '@/db/knex'
import {
@@ -83,6 +84,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -23,7 +23,10 @@ import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/stream
import cryptoRandomString from 'crypto-random-string'
import { BasicTestUser, createTestUser } from '@/test/authHelper'
import { storeFileStreamFactory } from '@/modules/blobstorage/repositories/blobs'
import { getMainObjectStorage } from '@/modules/blobstorage/clients/objectStorage'
import {
getMainObjectStorage,
getPublicMainObjectStorage
} from '@/modules/blobstorage/clients/objectStorage'
import { expect } from 'chai'
import { UploadFileStream } from '@/modules/blobstorage/domain/operations'
import { BlobStorageItem } from '@/modules/blobstorage/domain/types'
@@ -43,8 +46,8 @@ const buildUploadFileStream = async (params: { streamId: string | null }) => {
const storage = streamId
? await getProjectObjectStorage({ projectId: streamId })
: getMainObjectStorage()
const storeFileStream = storeFileStreamFactory({ storage })
: { private: getMainObjectStorage(), public: getPublicMainObjectStorage() }
const storeFileStream = storeFileStreamFactory({ storage: storage.public })
const uploadFileStream = uploadFileStreamFactory({
upsertBlob,
updateBlob,
@@ -52,7 +52,7 @@ const { FF_LARGE_FILE_IMPORTS_ENABLED } = getFeatureFlags()
}
let projectDb: Knex
let projectStorage: ObjectStorage
let projectStorage: { private: ObjectStorage; public: ObjectStorage }
let getBlobMetadata: GetBlobMetadata
before(async () => {
@@ -77,7 +77,7 @@ const { FF_LARGE_FILE_IMPORTS_ENABLED } = getFeatureFlags()
before(() => {
SUT = generatePresignedUrlFactory({
getSignedUrl: getSignedUrlFactory({
objectStorage: projectStorage
objectStorage: projectStorage.public
}),
upsertBlob: upsertBlobFactory({
db: projectDb
@@ -117,7 +117,7 @@ const { FF_LARGE_FILE_IMPORTS_ENABLED } = getFeatureFlags()
before(() => {
generatePresignedUrl = generatePresignedUrlFactory({
getSignedUrl: getSignedUrlFactory({
objectStorage: projectStorage
objectStorage: projectStorage.public
}),
upsertBlob: upsertBlobFactory({
db: projectDb
@@ -126,7 +126,7 @@ const { FF_LARGE_FILE_IMPORTS_ENABLED } = getFeatureFlags()
SUT = registerCompletedUploadFactory({
getBlob: getBlobFactory({ db: projectDb }),
getBlobMetadata: getBlobMetadataFromStorage({
objectStorage: projectStorage
objectStorage: projectStorage.public
}),
updateBlob: updateBlobFactory({
db: projectDb
@@ -56,7 +56,8 @@ import {
getStreamFactory,
createStreamFactory,
markCommitStreamUpdatedFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import {
createCommitByBranchIdFactory,
@@ -250,6 +251,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
+7
View File
@@ -406,6 +406,13 @@ export const PersonalApiTokens = buildTableHelper('personal_api_tokens', [
'userId'
])
export const EmbedApiTokens = buildTableHelper('embed_api_tokens', [
'tokenId',
'projectId',
'userId',
'resourceIdString'
])
export const UserServerAppTokens = buildTableHelper('user_server_app_tokens', [
'appId',
'userId',
@@ -6,7 +6,7 @@ import {
StreamUpdateInput
} from '@/modules/core/graph/generated/graphql'
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
import { StreamRoles } from '@speckle/shared'
import { Nullable, StreamRoles } from '@speckle/shared'
export const projectEventsNamespace = 'projects' as const
@@ -54,10 +54,12 @@ export type ProjectEventsPayloads = {
targetUserId: string
role: StreamRoles
project: Project
previousRole: Nullable<StreamRoles>
}
[ProjectEvents.PermissionsRevoked]: {
activityUserId: string
removedUserId: string
project: Project
role: StreamRoles
}
}
@@ -1,4 +1,4 @@
import { Project } from '@/modules/core/domain/streams/types'
import { Project, StreamWithOptionalRole } from '@/modules/core/domain/streams/types'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { MaybeNullOrUndefined, StreamRoles } from '@speckle/shared'
@@ -38,7 +38,7 @@ export type DeleteProjectRole = (args: {
export type DeleteProject = (args: { projectId: string }) => Promise<void>
export type GetRolesByUserId = ({
export type GetUserProjectRoles = ({
userId,
workspaceId
}: {
@@ -73,3 +73,15 @@ export type WaitForRegionProject = (params: {
regionKey: string
maxAttempts?: number
}) => Promise<void>
export type QueryAllProjects = (
args:
| {
userId: string
workspaceId?: string
}
| {
userId?: string
workspaceId: string
}
) => AsyncGenerator<StreamWithOptionalRole[], void, unknown>
@@ -258,7 +258,7 @@ export type GetStreamRoles = (
userId: string,
streamIds: string[]
) => Promise<{
[streamId: string]: Nullable<string>
[streamId: string]: Nullable<StreamRoles>
}>
export type GetUserStreamCounts = (params: {
@@ -1,5 +1,6 @@
import {
ApiToken,
EmbedApiToken,
PersonalApiToken,
TokenResourceAccessDefinition,
TokenResourceIdentifierType,
@@ -31,6 +32,8 @@ export type StorePersonalApiToken = (
token: PersonalApiToken
) => Promise<PersonalApiToken>
export type StoreEmbedApiToken = (token: EmbedApiToken) => Promise<EmbedApiToken>
export type GetUserPersonalAccessTokens = (userId: string) => Promise<
{
id: string
@@ -43,10 +46,33 @@ export type GetUserPersonalAccessTokens = (userId: string) => Promise<
}[]
>
export type ListProjectEmbedTokens = (args: {
projectId: string
filter?: {
limit?: number
createdBefore?: string | null
}
}) => Promise<
(EmbedApiToken & {
createdAt: Date
lastUsed: Date
lifespan: number | bigint
})[]
>
export type CountProjectEmbedTokens = (args: { projectId: string }) => Promise<number>
export type RevokeTokenById = (tokenId: string) => Promise<boolean>
export type RevokeUserTokenById = (tokenId: string, userId: string) => Promise<boolean>
export type RevokeEmbedTokenById = (args: {
tokenId: string
projectId: string
}) => Promise<boolean>
export type RevokeProjectEmbedTokens = (args: { projectId: string }) => Promise<void>
export type GetApiTokenById = (tokenId: string) => Promise<Optional<ApiToken>>
export type GetTokenScopesById = (tokenId: string) => Promise<TokenScope[]>
@@ -86,4 +112,30 @@ export type CreateAndStorePersonalAccessToken = (
lifespan?: number | bigint
) => Promise<string>
export type CreateAndStoreEmbedToken = (args: {
projectId: string
userId: string
/**
* The models (and optional versions) included in the embed.
* @example 'foo123,bar456@baz789'
*/
resourceIdString: string
lifespan?: number | bigint
}) => Promise<{
token: string
tokenMetadata: EmbedApiToken
}>
export type GetPaginatedProjectEmbedTokens = (args: {
projectId: string
filter?: {
limit?: number
cursor?: string
}
}) => Promise<{
items: EmbedApiToken[]
totalCount: number
cursor: string | null
}>
export type ValidateToken = (tokenString: string) => Promise<TokenValidationResult>
@@ -1,4 +1,5 @@
import {
EmbedApiTokenRecord,
PersonalApiTokenRecord,
TokenScopeRecord,
UserServerAppTokenRecord
@@ -26,3 +27,5 @@ export type TokenResourceAccessDefinition = TokenResourceAccessRecord
export type UserServerAppToken = UserServerAppTokenRecord
export type PersonalApiToken = PersonalApiTokenRecord
export type EmbedApiToken = EmbedApiTokenRecord
@@ -11,3 +11,9 @@ export class ProjectNotFoundError extends BaseError {
static code = 'PROJECT_NOT_FOUND'
static statusCode = 404
}
export class ProjectQueryError extends BaseError {
static defaultMessage = 'Unexpected error during query operation'
static code = 'PROJECT_QUERY_ERROR'
static statusCode = 500
}
@@ -1,5 +1,5 @@
import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, UserMetaGraphQLReturn, ProjectPermissionChecksGraphQLReturn, ModelPermissionChecksGraphQLReturn, VersionPermissionChecksGraphQLReturn, RootPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes';
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, EmbedTokenGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, UserMetaGraphQLReturn, ProjectPermissionChecksGraphQLReturn, ModelPermissionChecksGraphQLReturn, VersionPermissionChecksGraphQLReturn, RootPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes';
import { StreamAccessRequestGraphQLReturn, ProjectAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpers/graphTypes';
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn, CommentPermissionChecksGraphQLReturn } from '@/modules/comments/helpers/graphTypes';
import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes';
@@ -945,6 +945,12 @@ export type CreateCommentReplyInput = {
threadId: Scalars['String']['input'];
};
export type CreateEmbedTokenReturn = {
__typename?: 'CreateEmbedTokenReturn';
token: Scalars['String']['output'];
tokenMetadata: EmbedToken;
};
export type CreateModelInput = {
description?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
@@ -1023,6 +1029,32 @@ export type EmailVerificationRequestInput = {
id: Scalars['ID']['input'];
};
/** A token used to enable an embedded viewer for a private project */
export type EmbedToken = {
__typename?: 'EmbedToken';
createdAt: Scalars['DateTime']['output'];
lastUsed: Scalars['DateTime']['output'];
lifespan: Scalars['BigInt']['output'];
projectId: Scalars['String']['output'];
resourceIdString: Scalars['String']['output'];
tokenId: Scalars['String']['output'];
user?: Maybe<LimitedUser>;
};
export type EmbedTokenCollection = {
__typename?: 'EmbedTokenCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<EmbedToken>;
totalCount: Scalars['Int']['output'];
};
export type EmbedTokenCreateInput = {
lifespan?: InputMaybe<Scalars['BigInt']['input']>;
projectId: Scalars['String']['input'];
/** The model(s) and version(s) string used in the embed url */
resourceIdString: Scalars['String']['input'];
};
export type FileUpload = {
__typename?: 'FileUpload';
branchName: Scalars['String']['output'];
@@ -2118,6 +2150,7 @@ export type Project = {
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
embedTokens: EmbedTokenCollection;
hasAccessToFeature: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
invitableCollaborators: WorkspaceCollaboratorCollection;
@@ -2200,6 +2233,12 @@ export type ProjectCommentThreadsArgs = {
};
export type ProjectEmbedTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type ProjectHasAccessToFeatureArgs = {
featureName: WorkspaceFeatureName;
};
@@ -2599,6 +2638,7 @@ export type ProjectMutations = {
batchDelete: Scalars['Boolean']['output'];
/** Create new project */
create: Project;
createEmbedToken: CreateEmbedTokenReturn;
/**
* Create onboarding/tutorial project. If one is already created for the active user, that
* one will be returned instead.
@@ -2610,6 +2650,8 @@ export type ProjectMutations = {
invites: ProjectInviteMutations;
/** Leave a project. Only possible if you're not the last remaining owner. */
leave: Scalars['Boolean']['output'];
revokeEmbedToken: Scalars['Boolean']['output'];
revokeEmbedTokens: Scalars['Boolean']['output'];
/** Updates an existing project */
update: Project;
/** Update role for a collaborator */
@@ -2632,6 +2674,11 @@ export type ProjectMutationsCreateArgs = {
};
export type ProjectMutationsCreateEmbedTokenArgs = {
token: EmbedTokenCreateInput;
};
export type ProjectMutationsDeleteArgs = {
id: Scalars['String']['input'];
};
@@ -2642,6 +2689,17 @@ export type ProjectMutationsLeaveArgs = {
};
export type ProjectMutationsRevokeEmbedTokenArgs = {
projectId: Scalars['String']['input'];
token: Scalars['String']['input'];
};
export type ProjectMutationsRevokeEmbedTokensArgs = {
projectId: Scalars['String']['input'];
};
export type ProjectMutationsUpdateArgs = {
update: ProjectUpdateInput;
};
@@ -2684,6 +2742,7 @@ export type ProjectPermissionChecks = {
canBroadcastActivity: PermissionCheckResult;
canCreateAutomation: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateEmbedTokens: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
canInvite: PermissionCheckResult;
@@ -2692,9 +2751,11 @@ export type ProjectPermissionChecks = {
canMoveToWorkspace: PermissionCheckResult;
canPublish: PermissionCheckResult;
canRead: PermissionCheckResult;
canReadEmbedTokens: PermissionCheckResult;
canReadSettings: PermissionCheckResult;
canReadWebhooks: PermissionCheckResult;
canRequestRender: PermissionCheckResult;
canRevokeEmbedTokens: PermissionCheckResult;
canUpdate: PermissionCheckResult;
canUpdateAllowPublicComments: PermissionCheckResult;
};
@@ -5429,6 +5490,7 @@ export type ResolversTypes = {
CreateAutomateFunctionWithoutVersionInput: CreateAutomateFunctionWithoutVersionInput;
CreateCommentInput: CreateCommentInput;
CreateCommentReplyInput: CreateCommentReplyInput;
CreateEmbedTokenReturn: ResolverTypeWrapper<Omit<CreateEmbedTokenReturn, 'tokenMetadata'> & { tokenMetadata: ResolversTypes['EmbedToken'] }>;
CreateModelInput: CreateModelInput;
CreateServerRegionInput: CreateServerRegionInput;
CreateUserEmailInput: CreateUserEmailInput;
@@ -5444,6 +5506,9 @@ export type ResolversTypes = {
DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput;
EditCommentInput: EditCommentInput;
EmailVerificationRequestInput: EmailVerificationRequestInput;
EmbedToken: ResolverTypeWrapper<EmbedTokenGraphQLReturn>;
EmbedTokenCollection: ResolverTypeWrapper<Omit<EmbedTokenCollection, 'items'> & { items: Array<ResolversTypes['EmbedToken']> }>;
EmbedTokenCreateInput: EmbedTokenCreateInput;
FileUpload: ResolverTypeWrapper<FileUploadGraphQLReturn>;
FileUploadCollection: ResolverTypeWrapper<Omit<FileUploadCollection, 'items'> & { items: Array<ResolversTypes['FileUpload']> }>;
FileUploadMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
@@ -5773,6 +5838,7 @@ export type ResolversParentTypes = {
CreateAutomateFunctionWithoutVersionInput: CreateAutomateFunctionWithoutVersionInput;
CreateCommentInput: CreateCommentInput;
CreateCommentReplyInput: CreateCommentReplyInput;
CreateEmbedTokenReturn: Omit<CreateEmbedTokenReturn, 'tokenMetadata'> & { tokenMetadata: ResolversParentTypes['EmbedToken'] };
CreateModelInput: CreateModelInput;
CreateServerRegionInput: CreateServerRegionInput;
CreateUserEmailInput: CreateUserEmailInput;
@@ -5786,6 +5852,9 @@ export type ResolversParentTypes = {
DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput;
EditCommentInput: EditCommentInput;
EmailVerificationRequestInput: EmailVerificationRequestInput;
EmbedToken: EmbedTokenGraphQLReturn;
EmbedTokenCollection: Omit<EmbedTokenCollection, 'items'> & { items: Array<ResolversParentTypes['EmbedToken']> };
EmbedTokenCreateInput: EmbedTokenCreateInput;
FileUpload: FileUploadGraphQLReturn;
FileUploadCollection: Omit<FileUploadCollection, 'items'> & { items: Array<ResolversParentTypes['FileUpload']> };
FileUploadMutations: MutationsObjectGraphQLReturn;
@@ -6458,6 +6527,12 @@ export type CountOnlyCollectionResolvers<ContextType = GraphQLContext, ParentTyp
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CreateEmbedTokenReturnResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CreateEmbedTokenReturn'] = ResolversParentTypes['CreateEmbedTokenReturn']> = {
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
tokenMetadata?: Resolver<ResolversTypes['EmbedToken'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CurrencyBasedPricesResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CurrencyBasedPrices'] = ResolversParentTypes['CurrencyBasedPrices']> = {
gbp?: Resolver<ResolversTypes['WorkspacePaidPlanPrices'], ParentType, ContextType>;
usd?: Resolver<ResolversTypes['WorkspacePaidPlanPrices'], ParentType, ContextType>;
@@ -6468,6 +6543,24 @@ export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig<ResolversT
name: 'DateTime';
}
export type EmbedTokenResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['EmbedToken'] = ResolversParentTypes['EmbedToken']> = {
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
lastUsed?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
lifespan?: Resolver<ResolversTypes['BigInt'], ParentType, ContextType>;
projectId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
resourceIdString?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
tokenId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
user?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EmbedTokenCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['EmbedTokenCollection'] = ResolversParentTypes['EmbedTokenCollection']> = {
cursor?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
items?: Resolver<Array<ResolversTypes['EmbedToken']>, ParentType, ContextType>;
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FileUploadResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['FileUpload'] = ResolversParentTypes['FileUpload']> = {
branchName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
convertedCommitId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@@ -6820,6 +6913,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
embedOptions?: Resolver<ResolversTypes['ProjectEmbedOptions'], ParentType, ContextType>;
embedTokens?: Resolver<ResolversTypes['EmbedTokenCollection'], ParentType, ContextType, Partial<ProjectEmbedTokensArgs>>;
hasAccessToFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectHasAccessToFeatureArgs, 'featureName'>>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
invitableCollaborators?: Resolver<ResolversTypes['WorkspaceCollaboratorCollection'], ParentType, ContextType, RequireFields<ProjectInvitableCollaboratorsArgs, 'limit'>>;
@@ -6954,10 +7048,13 @@ export type ProjectMutationsResolvers<ContextType = GraphQLContext, ParentType e
automationMutations?: Resolver<ResolversTypes['ProjectAutomationMutations'], ParentType, ContextType, RequireFields<ProjectMutationsAutomationMutationsArgs, 'projectId'>>;
batchDelete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsBatchDeleteArgs, 'ids'>>;
create?: Resolver<ResolversTypes['Project'], ParentType, ContextType, Partial<ProjectMutationsCreateArgs>>;
createEmbedToken?: Resolver<ResolversTypes['CreateEmbedTokenReturn'], ParentType, ContextType, RequireFields<ProjectMutationsCreateEmbedTokenArgs, 'token'>>;
createForOnboarding?: Resolver<ResolversTypes['Project'], ParentType, ContextType>;
delete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsDeleteArgs, 'id'>>;
invites?: Resolver<ResolversTypes['ProjectInviteMutations'], ParentType, ContextType>;
leave?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsLeaveArgs, 'id'>>;
revokeEmbedToken?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsRevokeEmbedTokenArgs, 'projectId' | 'token'>>;
revokeEmbedTokens?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectMutationsRevokeEmbedTokensArgs, 'projectId'>>;
update?: Resolver<ResolversTypes['Project'], ParentType, ContextType, RequireFields<ProjectMutationsUpdateArgs, 'update'>>;
updateRole?: Resolver<ResolversTypes['Project'], ParentType, ContextType, RequireFields<ProjectMutationsUpdateRoleArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@@ -6981,6 +7078,7 @@ export type ProjectPermissionChecksResolvers<ContextType = GraphQLContext, Paren
canBroadcastActivity?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateAutomation?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateComment?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateEmbedTokens?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateModel?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canDelete?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canInvite?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
@@ -6989,9 +7087,11 @@ export type ProjectPermissionChecksResolvers<ContextType = GraphQLContext, Paren
canMoveToWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType, Partial<ProjectPermissionChecksCanMoveToWorkspaceArgs>>;
canPublish?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canRead?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canReadEmbedTokens?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canReadSettings?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canReadWebhooks?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canRequestRender?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canRevokeEmbedTokens?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canUpdate?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canUpdateAllowPublicComments?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@@ -7911,8 +8011,11 @@ export type Resolvers<ContextType = GraphQLContext> = {
Commit?: CommitResolvers<ContextType>;
CommitCollection?: CommitCollectionResolvers<ContextType>;
CountOnlyCollection?: CountOnlyCollectionResolvers<ContextType>;
CreateEmbedTokenReturn?: CreateEmbedTokenReturnResolvers<ContextType>;
CurrencyBasedPrices?: CurrencyBasedPricesResolvers<ContextType>;
DateTime?: GraphQLScalarType;
EmbedToken?: EmbedTokenResolvers<ContextType>;
EmbedTokenCollection?: EmbedTokenCollectionResolvers<ContextType>;
FileUpload?: FileUploadResolvers<ContextType>;
FileUploadCollection?: FileUploadCollectionResolvers<ContextType>;
FileUploadMutations?: FileUploadMutationsResolvers<ContextType>;
@@ -0,0 +1,113 @@
import { db } from '@/db/knex'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import {
storeApiTokenFactory,
storeTokenResourceAccessDefinitionsFactory,
storeTokenScopesFactory
} from '@/modules/core/repositories/tokens'
import {
countProjectEmbedTokensFactory,
listProjectEmbedTokensFactory,
revokeEmbedTokenByIdFactory,
revokeProjectEmbedTokensFactory,
storeEmbedApiTokenFactory
} from '@/modules/core/repositories/embedTokens'
import {
createEmbedTokenFactory,
createTokenFactory,
getPaginatedProjectEmbedTokensFactory
} from '@/modules/core/services/tokens'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { removeNullOrUndefinedKeys } from '@speckle/shared'
import { getUserFactory } from '@/modules/core/repositories/users'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
const resolvers: Resolvers = {
EmbedToken: {
user: async (parent) => {
return await getUserFactory({ db })(parent.userId)
}
},
Project: {
embedTokens: async (parent, args, context) => {
const canReadEmbedTokens = await context.authPolicies.project.canReadEmbedTokens({
userId: context.userId,
projectId: parent.id
})
throwIfAuthNotOk(canReadEmbedTokens)
return await getPaginatedProjectEmbedTokensFactory({
listEmbedTokens: listProjectEmbedTokensFactory({ db }),
countEmbedTokens: countProjectEmbedTokensFactory({ db })
})({
projectId: parent.id,
filter: removeNullOrUndefinedKeys(args)
})
}
},
ProjectMutations: {
createEmbedToken: async (_parent, args, context) => {
const canCreateEmbedToken =
await context.authPolicies.project.canUpdateEmbedTokens({
userId: context.userId,
projectId: args.token.projectId
})
throwIfAuthNotOk(canCreateEmbedToken)
throwIfResourceAccessNotAllowed({
resourceId: args.token.projectId,
resourceType: 'project',
resourceAccessRules: context.resourceAccessRules
})
return await createEmbedTokenFactory({
createToken: createTokenFactory({
storeApiToken: storeApiTokenFactory({ db }),
storeTokenScopes: storeTokenScopesFactory({ db }),
storeTokenResourceAccessDefinitions:
storeTokenResourceAccessDefinitionsFactory({ db })
}),
storeEmbedToken: storeEmbedApiTokenFactory({ db })
})({
...removeNullOrUndefinedKeys(args.token),
userId: context.userId!
})
},
revokeEmbedToken: async (_parent, args, context) => {
const canRevokeEmbedToken =
await context.authPolicies.project.canUpdateEmbedTokens({
userId: context.userId,
projectId: args.projectId
})
throwIfAuthNotOk(canRevokeEmbedToken)
throwIfResourceAccessNotAllowed({
resourceId: args.projectId,
resourceType: 'project',
resourceAccessRules: context.resourceAccessRules
})
return await revokeEmbedTokenByIdFactory({ db })({
tokenId: args.token,
projectId: args.projectId
})
},
revokeEmbedTokens: async (_parent, args, context) => {
const canRevokeEmbedTokens =
await context.authPolicies.project.canUpdateEmbedTokens({
userId: context.userId,
projectId: args.projectId
})
throwIfAuthNotOk(canRevokeEmbedTokens)
throwIfResourceAccessNotAllowed({
resourceId: args.projectId,
resourceType: 'project',
resourceAccessRules: context.resourceAccessRules
})
await revokeProjectEmbedTokensFactory({ db })({ projectId: args.projectId })
return true
}
}
}
export default resolvers
@@ -113,6 +113,27 @@ export default {
userId: ctx.userId
})
return Authz.toGraphqlResult(canInvite)
},
canReadEmbedTokens: async (parent, _args, ctx) => {
const canReadEmbedTokens = await ctx.authPolicies.project.canReadEmbedTokens({
projectId: parent.projectId,
userId: ctx.userId
})
return Authz.toGraphqlResult(canReadEmbedTokens)
},
canCreateEmbedTokens: async (parent, _args, ctx) => {
const canCreateEmbedTokens = await ctx.authPolicies.project.canUpdateEmbedTokens({
projectId: parent.projectId,
userId: ctx.userId
})
return Authz.toGraphqlResult(canCreateEmbedTokens)
},
canRevokeEmbedTokens: async (parent, _args, ctx) => {
const canUpdateEmbedTokens = await ctx.authPolicies.project.canUpdateEmbedTokens({
projectId: parent.projectId,
userId: ctx.userId
})
return Authz.toGraphqlResult(canUpdateEmbedTokens)
}
},
ModelPermissionChecks: {
@@ -45,7 +45,8 @@ import {
grantStreamPermissionsFactory,
getOnboardingBaseStreamFactory,
getUserStreamsPageFactory,
getUserStreamsCountFactory
getUserStreamsCountFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users'
import {
@@ -136,6 +137,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -203,6 +205,7 @@ const removeStreamCollaborator = removeStreamCollaboratorFactory({
validateStreamAccess,
isStreamCollaborator,
revokeStreamPermissions: revokeStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const updateStreamRoleAndNotify = updateStreamRoleAndNotifyFactory({
@@ -211,6 +214,7 @@ const updateStreamRoleAndNotify = updateStreamRoleAndNotifyFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
}),
removeStreamCollaborator
@@ -25,7 +25,8 @@ import {
canUserFavoriteStreamFactory,
setStreamFavoritedFactory,
getUserStreamsPageFactory,
getUserStreamsCountFactory
getUserStreamsCountFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import {
createStreamReturnRecordFactory,
@@ -125,6 +126,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -203,6 +205,7 @@ const removeStreamCollaborator = removeStreamCollaboratorFactory({
validateStreamAccess,
isStreamCollaborator,
revokeStreamPermissions: revokeStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const updateStreamRoleAndNotify = updateStreamRoleAndNotifyFactory({
@@ -211,6 +214,7 @@ const updateStreamRoleAndNotify = updateStreamRoleAndNotifyFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
}),
removeStreamCollaborator
@@ -33,7 +33,8 @@ import {
} from '@/modules/core/services/users/management'
import {
deleteStreamFactory,
getUserDeletableStreamsFactory
getUserDeletableStreamsFactory,
legacyGetStreamsFactory
} from '@/modules/core/repositories/streams'
import { dbLogger } from '@/observability/logging'
import { getAdminUsersListCollectionFactory } from '@/modules/core/services/users/legacyAdminUsersList'
@@ -51,6 +52,8 @@ import { asOperation } from '@/modules/shared/command'
import { setUserOnboardingChoicesFactory } from '@/modules/core/services/users/tracking'
import { getMixpanelClient } from '@/modules/shared/utils/mixpanel'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { getUserWorkspaceSeatsFactory } from '@/modules/workspacesCore/repositories/workspaces'
import { queryAllProjectsFactory } from '@/modules/core/services/projects'
const getUser = legacyGetUserFactory({ db })
const getUserByEmail = legacyGetUserByEmailFactory({ db })
@@ -67,6 +70,10 @@ const deleteUser = deleteUserFactory({
logger: dbLogger,
isLastAdminUser: isLastAdminUserFactory({ db }),
getUserDeletableStreams: getUserDeletableStreamsFactory({ db }),
queryAllProjects: queryAllProjectsFactory({
getStreams: legacyGetStreamsFactory({ db })
}),
getUserWorkspaceSeats: getUserWorkspaceSeatsFactory({ db }),
deleteAllUserInvites: deleteAllUserInvitesFactory({ db }),
deleteUserRecord: deleteUserRecordFactory({ db }),
emitEvent: getEventBus().emit
@@ -3,6 +3,7 @@ import {
LegacyStreamCommit,
LegacyUserCommit
} from '@/modules/core/domain/commits/types'
import { EmbedApiToken } from '@/modules/core/domain/tokens/types'
import {
LimitedUser,
StreamRole,
@@ -154,3 +155,5 @@ export type VersionPermissionChecksGraphQLReturn = {
versionId: string
projectId: string
}
export type EmbedTokenGraphQLReturn = EmbedApiToken
@@ -0,0 +1,30 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('embed_api_tokens', (table) => {
table
.string('tokenId')
.notNullable()
.references('id')
.inTable('api_tokens')
.onDelete('cascade')
table
.string('projectId')
.notNullable()
.references('id')
.inTable('streams')
.onDelete('cascade')
table
.string('userId')
.notNullable()
.references('id')
.inTable('users')
.onDelete('cascade')
table.string('resourceIdString').notNullable()
table.primary(['projectId', 'tokenId'])
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('embed_api_tokens')
}
@@ -351,12 +351,14 @@ export const getPaginatedBranchCommitsItemsFactory =
if (cursor) {
q.andWhere(Commits.col.createdAt, '<', cursor)
}
if (deps.limitsDate) {
q.andWhere(Commits.col.createdAt, '>', deps.limitsDate)
let rows = await q
const limitsDate = deps.limitsDate
if (limitsDate && rows.length > 0) {
// the length check above makes the ! ok
rows = [rows.shift()!, ...rows.filter((r) => r.createdAt > limitsDate)]
}
const rows = await q
return {
commits: rows,
cursor: rows.length > 0 ? rows[rows.length - 1].createdAt.toISOString() : null
@@ -701,11 +703,17 @@ export const legacyGetPaginatedStreamCommitsPageFactory =
if (ignoreGlobalsBranch) query.andWhere('branches.name', '!=', 'globals')
if (cursor) query.andWhere('commits.createdAt', '<', cursor)
if (deps.limitsDate) query.andWhere('commits.createdAt', '>', deps.limitsDate)
// if (deps.limitsDate) query.andWhere('commits.createdAt', '>', deps.limitsDate)
query.orderBy('commits.createdAt', 'desc').limit(limit)
const rows = (await query) as LegacyStreamCommit[]
let rows = (await query) as LegacyStreamCommit[]
const limitsDate = deps.limitsDate
if (limitsDate && rows.length > 0) {
// the length check above makes the ! ok
rows = [rows.shift()!, ...rows.filter((r) => r.createdAt > limitsDate)]
}
return {
commits: rows,
cursor: rows.length > 0 ? rows[rows.length - 1].createdAt.toISOString() : null
@@ -0,0 +1,92 @@
import { EmbedApiTokenRecord } from '@/modules/auth/helpers/types'
import { ApiTokenRecord } from '@/modules/auth/repositories'
import { ApiTokens, EmbedApiTokens } from '@/modules/core/dbSchema'
import {
CountProjectEmbedTokens,
ListProjectEmbedTokens,
RevokeEmbedTokenById,
RevokeProjectEmbedTokens,
StoreEmbedApiToken
} from '@/modules/core/domain/tokens/operations'
import { UserInputError } from '@/modules/core/errors/userinput'
import { Knex } from 'knex'
import { clamp } from 'lodash'
const tables = {
apiTokens: (db: Knex) => db<ApiTokenRecord>(ApiTokens.name),
embedApiTokens: (db: Knex) => db<EmbedApiTokenRecord>(EmbedApiTokens.name)
}
export const storeEmbedApiTokenFactory =
(deps: { db: Knex }): StoreEmbedApiToken =>
async (token) => {
const [newToken] = await tables.embedApiTokens(deps.db).insert(token).returning('*')
return newToken
}
export const countProjectEmbedTokensFactory =
(deps: { db: Knex }): CountProjectEmbedTokens =>
async ({ projectId }) => {
const [{ count }] = await tables
.embedApiTokens(deps.db)
.where(EmbedApiTokens.col.projectId, projectId)
.count()
return Number.parseInt(count as string)
}
export const listProjectEmbedTokensFactory =
(deps: { db: Knex }): ListProjectEmbedTokens =>
async ({ projectId, filter = {} }) => {
const { limit = 10, createdBefore } = filter
if (limit === 0) return []
const q = tables
.embedApiTokens(deps.db)
.select<
(EmbedApiTokenRecord &
Pick<ApiTokenRecord, 'createdAt' | 'lastUsed' | 'lifespan'>)[]
>(
...EmbedApiTokens.cols,
ApiTokens.col.createdAt,
ApiTokens.col.lastUsed,
ApiTokens.col.lifespan
)
.orderBy(ApiTokens.col.createdAt, 'desc')
.leftJoin(ApiTokens.name, ApiTokens.col.id, EmbedApiTokens.col.tokenId)
.where(EmbedApiTokens.col.projectId, projectId)
.limit(clamp(limit, 0, 50))
if (createdBefore) {
q.andWhere(ApiTokens.col.createdAt, '<', createdBefore)
}
return await q
}
export const revokeEmbedTokenByIdFactory =
(deps: { db: Knex }): RevokeEmbedTokenById =>
async ({ tokenId: token, projectId }) => {
const tokenId = token.slice(0, 10)
const delCount = await tables
.embedApiTokens(deps.db)
.where({ tokenId, projectId })
.delete()
if (delCount === 0) throw new UserInputError('Embed token not found')
await tables.apiTokens(deps.db).where(ApiTokens.col.id, tokenId).delete()
return true
}
export const revokeProjectEmbedTokensFactory =
(deps: { db: Knex }): RevokeProjectEmbedTokens =>
async ({ projectId }) => {
await tables
.apiTokens(deps.db)
.whereIn(ApiTokens.col.id, (builder) => {
return builder
.select('tokenId')
.from<EmbedApiTokenRecord>(EmbedApiTokens.name)
.where('projectId', projectId)
})
.delete()
}
@@ -2,6 +2,7 @@ import { StreamAcl, Streams } from '@/modules/core/dbSchema'
import {
DeleteProject,
GetProject,
GetUserProjectRoles,
StoreProject,
StoreProjectRole,
StoreProjectRoles
@@ -51,3 +52,17 @@ export const storeProjectRolesFactory =
}))
)
}
export const getUserProjectRolesFactory =
({ db }: { db: Knex }): GetUserProjectRoles =>
async ({ userId, workspaceId }) => {
const query = db<StreamAclRecord>(StreamAcl.name).where({ userId })
if (workspaceId) {
query
.join(Streams.name, Streams.col.id, StreamAcl.col.resourceId)
.where({ workspaceId })
}
return await query
}
@@ -63,7 +63,6 @@ import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import {
DeleteProjectRole,
UpdateProject,
GetRolesByUserId,
UpsertProjectRole
} from '@/modules/core/domain/projects/operations'
import {
@@ -455,7 +454,7 @@ export const getStreamRolesFactory =
async (userId: string, streamIds: string[]) => {
const q = tables
.streams(deps.db)
.select<{ id: string; role: Nullable<string> }[]>([
.select<{ id: string; role: Nullable<StreamRoles> }[]>([
Streams.col.id,
StreamAcl.col.role
])
@@ -1330,20 +1329,6 @@ export const getOnboardingBaseStreamFactory =
return await q
}
export const getRolesByUserIdFactory =
({ db }: { db: Knex }): GetRolesByUserId =>
async ({ userId, workspaceId }) => {
const query = db<Pick<StreamAclRecord, 'role' | 'resourceId' | 'userId'>>(
StreamAcl.name
).where({ userId })
if (workspaceId) {
query
.join(Streams.name, Streams.col.id, StreamAcl.col.resourceId)
.where({ workspaceId })
}
return await query
}
/**
* @deprecated Use getStreams() from the repository directly
*/
@@ -1,4 +1,5 @@
import {
EmbedApiTokenRecord,
PersonalApiTokenRecord,
TokenScopeRecord,
UserServerAppTokenRecord
@@ -6,6 +7,7 @@ import {
import { ApiTokenRecord } from '@/modules/auth/repositories'
import {
ApiTokens,
EmbedApiTokens,
PersonalApiTokens,
TokenResourceAccess,
TokenScopes,
@@ -38,7 +40,8 @@ const tables = {
db<TokenResourceAccessRecord>(TokenResourceAccess.name),
userServerAppTokens: (db: Knex) =>
db<UserServerAppTokenRecord>(UserServerAppTokens.name),
personalApiTokens: (db: Knex) => db<PersonalApiTokenRecord>(PersonalApiTokens.name)
personalApiTokens: (db: Knex) => db<PersonalApiTokenRecord>(PersonalApiTokens.name),
embedApiTokens: (db: Knex) => db<EmbedApiTokenRecord>(EmbedApiTokens.name)
}
export const storeApiTokenFactory =
@@ -4,13 +4,17 @@ import {
CreateProject,
DeleteProject,
GetProject,
QueryAllProjects,
StoreModel,
StoreProject,
StoreProjectRole,
WaitForRegionProject
} from '@/modules/core/domain/projects/operations'
import { Project } from '@/modules/core/domain/streams/types'
import { RegionalProjectCreationError } from '@/modules/core/errors/projects'
import { Project, StreamWithOptionalRole } from '@/modules/core/domain/streams/types'
import {
ProjectQueryError,
RegionalProjectCreationError
} from '@/modules/core/errors/projects'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { ProjectVisibility } from '@/modules/core/graph/generated/graphql'
import { mapGqlToDbProjectVisibility } from '@/modules/core/helpers/project'
@@ -19,6 +23,7 @@ import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { retry } from '@lifeomic/attempt'
import { Roles, TIME_MS } from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
import { LegacyGetStreams } from '@/modules/core/domain/streams/operations'
export const createNewProjectFactory =
({
@@ -68,6 +73,7 @@ export const createNewProjectFactory =
projectId,
authorId: ownerId
})
await emitEvent({
eventName: ProjectEvents.Created,
payload: {
@@ -80,6 +86,17 @@ export const createNewProjectFactory =
}
}
})
await emitEvent({
eventName: ProjectEvents.PermissionsAdded,
payload: {
project,
activityUserId: ownerId,
targetUserId: ownerId,
role: Roles.Stream.Owner,
previousRole: null
}
})
return project
}
@@ -109,3 +126,38 @@ export const waitForRegionProjectFactory =
throw err
}
}
export const queryAllProjectsFactory = ({
getStreams
}: {
getStreams: LegacyGetStreams
}): QueryAllProjects =>
async function* queryAllWorkspaceProjects({
userId,
workspaceId
}): AsyncGenerator<StreamWithOptionalRole[], void, unknown> {
let cursor: Date | null = null
let iterationCount = 0
if (!userId && !workspaceId) throw new ProjectQueryError()
do {
if (iterationCount > 500) throw new ProjectQueryError()
const { streams, cursorDate } = await getStreams({
cursor,
orderBy: null,
limit: 100,
visibility: null,
searchQuery: null,
streamIdWhitelist: null,
workspaceIdWhitelist: workspaceId ? [workspaceId] : null,
userId
})
yield streams
cursor = cursorDate
iterationCount++
} while (!!cursor)
}
@@ -2,6 +2,7 @@ import { ProjectEvents } from '@/modules/core/domain/projects/events'
import {
AddOrUpdateStreamCollaborator,
GetStream,
GetStreamRoles,
GrantStreamPermissions,
IsStreamCollaborator,
RemoveStreamCollaborator,
@@ -92,6 +93,7 @@ export const removeStreamCollaboratorFactory =
validateStreamAccess: ValidateStreamAccess
isStreamCollaborator: IsStreamCollaborator
revokeStreamPermissions: RevokeStreamPermissions
getStreamRoles: GetStreamRoles
emitEvent: EventBusEmit
}): RemoveStreamCollaborator =>
async (streamId, userId, removedById, removerResourceAccessRules, options) => {
@@ -113,19 +115,23 @@ export const removeStreamCollaboratorFactory =
}
}
const { [streamId]: role } = await deps.getStreamRoles(userId, [streamId])
const stream = await deps.revokeStreamPermissions({ streamId, userId }, options)
if (!stream) {
throw new LogicError('Stream not found')
}
await deps.emitEvent({
eventName: ProjectEvents.PermissionsRevoked,
payload: {
project: stream,
activityUserId: removedById,
removedUserId: userId
}
})
if (role) {
await deps.emitEvent({
eventName: ProjectEvents.PermissionsRevoked,
payload: {
project: stream,
activityUserId: removedById,
removedUserId: userId,
role
}
})
}
return stream
}
@@ -145,6 +151,7 @@ export const addOrUpdateStreamCollaboratorFactory =
validateStreamAccess: ValidateStreamAccess
getUser: GetUser
grantStreamPermissions: GrantStreamPermissions
getStreamRoles: GetStreamRoles
emitEvent: EventBusEmit
}): AddOrUpdateStreamCollaborator =>
async (
@@ -194,6 +201,7 @@ export const addOrUpdateStreamCollaboratorFactory =
}
})
const { [streamId]: previousRole } = await deps.getStreamRoles(userId, [streamId])
const stream = (await deps.grantStreamPermissions(
{
streamId,
@@ -203,17 +211,16 @@ export const addOrUpdateStreamCollaboratorFactory =
{ trackProjectUpdate }
)) as StreamRecord // validateStreamAccess already checked that it exists
if (!fromInvite) {
await deps.emitEvent({
eventName: ProjectEvents.PermissionsAdded,
payload: {
project: stream,
activityUserId: addedById,
targetUserId: userId,
role: role as StreamRoles
}
})
}
await deps.emitEvent({
eventName: ProjectEvents.PermissionsAdded,
payload: {
project: stream,
activityUserId: addedById,
targetUserId: userId,
role: role as StreamRoles,
previousRole: previousRole || null
}
})
return stream
}
@@ -92,6 +92,17 @@ export const createStreamReturnRecordFactory =
}
})
await deps.emitEvent({
eventName: ProjectEvents.PermissionsAdded,
payload: {
project: stream,
activityUserId: ownerId,
targetUserId: ownerId,
role: Roles.Stream.Owner,
previousRole: null
}
})
return stream
}
@@ -4,16 +4,21 @@ import {
TokenResourceAccessRecord,
TokenValidationResult
} from '@/modules/core/helpers/types'
import { Optional, ServerScope } from '@speckle/shared'
import { Optional, Scopes, ServerScope } from '@speckle/shared'
import {
CountProjectEmbedTokens,
CreateAndStoreAppToken,
CreateAndStoreEmbedToken,
CreateAndStorePersonalAccessToken,
CreateAndStoreUserToken,
GetApiTokenById,
GetPaginatedProjectEmbedTokens,
GetTokenResourceAccessDefinitionsById,
GetTokenScopesById,
ListProjectEmbedTokens,
RevokeUserTokenById,
StoreApiToken,
StoreEmbedApiToken,
StorePersonalApiToken,
StoreTokenResourceAccessDefinitions,
StoreTokenScopes,
@@ -24,6 +29,19 @@ import {
import { GetTokenAppInfo } from '@/modules/auth/domain/operations'
import { GetUserRole } from '@/modules/core/domain/users/operations'
import { TokenCreateError } from '@/modules/core/errors/user'
import cryptoRandomString from 'crypto-random-string'
import {
EmbedApiToken,
TokenResourceIdentifierType
} from '@/modules/core/domain/tokens/types'
import {
createGetParamFromResources,
parseUrlParameters
} from '@speckle/shared/viewer/route'
import {
decodeIsoDateCursor,
encodeIsoDateCursor
} from '@/modules/shared/helpers/dbHelper'
/*
Tokens
@@ -124,6 +142,69 @@ export const createPersonalAccessTokenFactory =
return token
}
export const createEmbedTokenFactory =
(deps: {
createToken: CreateAndStoreUserToken
storeEmbedToken: StoreEmbedApiToken
}): CreateAndStoreEmbedToken =>
async ({ projectId, userId, resourceIdString, lifespan }) => {
const validatedResourceIdString = createGetParamFromResources(
parseUrlParameters(resourceIdString)
)
const { id, token } = await deps.createToken({
userId,
name: cryptoRandomString({ length: 10 }),
scopes: [Scopes.Streams.Read],
limitResources: [
{
id: projectId,
type: TokenResourceIdentifierType.Project
}
],
lifespan
})
const tokenMetadata: EmbedApiToken = {
projectId,
tokenId: id,
userId,
resourceIdString: validatedResourceIdString
}
await deps.storeEmbedToken(tokenMetadata)
return { token, tokenMetadata }
}
export const getPaginatedProjectEmbedTokensFactory =
(deps: {
listEmbedTokens: ListProjectEmbedTokens
countEmbedTokens: CountProjectEmbedTokens
}): GetPaginatedProjectEmbedTokens =>
async ({ projectId, filter = {} }) => {
const cursor = filter.cursor ? decodeIsoDateCursor(filter.cursor) : null
const [items, totalCount] = await Promise.all([
deps.listEmbedTokens({
projectId,
filter: {
createdBefore: cursor,
limit: filter.limit
}
}),
deps.countEmbedTokens({ projectId })
])
const lastItem = items.at(-1)
return {
items,
totalCount,
cursor: lastItem ? encodeIsoDateCursor(lastItem.createdAt) : null
}
}
export const validateTokenFactory =
(deps: {
revokeUserTokenById: RevokeUserTokenById
@@ -54,6 +54,11 @@ import { GetServerInfo } from '@/modules/core/domain/server/operations'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { UserEvents } from '@/modules/core/domain/users/events'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { GetUserWorkspaceSeatsFactory } from '@/modules/workspacesCore/domain/operations'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { ProjectEvents } from '@/modules/core/domain/projects/events'
import { QueryAllProjects } from '@/modules/core/domain/projects/operations'
import { StreamWithOptionalRole } from '@/modules/core/repositories/streams'
const { FF_NO_PERSONAL_EMAILS_ENABLED } = getFeatureFlags()
@@ -80,6 +85,11 @@ export const updateUserAndNotifyFactory =
const key = entry[0] as keyof typeof update
let val = entry[1]
if (key === 'avatar' && val === '') {
filteredUpdate[key] = null // avatar removal
continue
}
if (key === 'avatar') {
val = sanitizeImageUrl(val)
}
@@ -288,7 +298,9 @@ export const deleteUserFactory =
isLastAdminUser: IsLastAdminUser
getUserDeletableStreams: GetUserDeletableStreams
deleteAllUserInvites: DeleteAllUserInvites
getUserWorkspaceSeats: GetUserWorkspaceSeatsFactory
deleteUserRecord: DeleteUserRecord
queryAllProjects: QueryAllProjects
emitEvent: EventBusEmit
}): DeleteUser =>
async (id, invokerId) => {
@@ -307,6 +319,39 @@ export const deleteUserFactory =
// THIS REALLY SHOULD BE A REACTION TO THE USER DELETED EVENT EMITTED HER
await deps.deleteAllUserInvites(id)
const workspaceSeats = await deps.getUserWorkspaceSeats({ userId: id })
for (const seat of workspaceSeats) {
await deps.emitEvent({
eventName: WorkspaceEvents.SeatDeleted,
payload: {
updatedByUserId: id,
previousSeat: seat
}
})
}
const emitRevokeEventIfUserHasRole = async (project: StreamWithOptionalRole) => {
if (!project.role) return
await deps.emitEvent({
eventName: ProjectEvents.PermissionsRevoked,
payload: {
activityUserId: id,
removedUserId: id,
role: project.role,
project
}
})
}
for await (const projectsPage of deps.queryAllProjects({
userId: id
})) {
for (const project of projectsPage) {
await emitRevokeEventIfUserHasRole(project)
}
}
const deleted = await deps.deleteUserRecord(id)
if (deleted) {
await deps.emitEvent({
@@ -4,7 +4,10 @@ import { Commits, Streams, Users } from '@/modules/core/dbSchema'
import { Roles } from '@/modules/core/helpers/mainConstants'
import { createBranchFactory } from '@/modules/core/repositories/branches'
import { getCommitsFactory } from '@/modules/core/repositories/commits'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import {
getStreamRolesFactory,
grantStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import { getUserFactory } from '@/modules/core/repositories/users'
import {
addOrUpdateStreamCollaboratorFactory,
@@ -39,6 +42,7 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
@@ -28,7 +28,8 @@ import {
createStreamFactory,
markBranchStreamUpdatedFactory,
markCommitStreamUpdatedFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import {
createCommitByBranchIdFactory,
@@ -152,6 +153,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -38,7 +38,8 @@ import {
getCommitStreamFactory,
createStreamFactory,
markCommitStreamUpdatedFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import {
getObjectFactory,
@@ -166,6 +167,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -2,7 +2,10 @@ import { buildApolloServer } from '@/app'
import { db } from '@/db/knex'
import { Commits, Streams, Users } from '@/modules/core/dbSchema'
import { Roles } from '@/modules/core/helpers/mainConstants'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import {
getStreamRolesFactory,
grantStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import { getUserFactory } from '@/modules/core/repositories/users'
import {
addOrUpdateStreamCollaboratorFactory,
@@ -25,6 +28,7 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
@@ -0,0 +1,120 @@
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { AllScopes } from '@/modules/core/helpers/mainConstants'
import {
createRandomEmail,
createRandomPassword
} from '@/modules/core/helpers/testHelpers'
import {
BasicTestWorkspace,
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import { BasicTestUser, createTestUser } from '@/test/authHelper'
import {
CreateEmbedTokenDocument,
GetActiveUserDocument,
GetProjectDocument,
GetWorkspaceDocument
} from '@/test/graphql/generated/graphql'
import {
createTestContext,
testApolloServer,
TestApolloServer
} from '@/test/graphqlHelper'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { Roles, Scopes } from '@speckle/shared'
import { expect } from 'chai'
describe('Embed tokens', () => {
const adminUser: BasicTestUser = {
id: '',
name: 'John Speckle',
email: createRandomEmail(),
password: createRandomPassword()
}
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
name: 'My Workspace',
slug: ''
}
const projectA: BasicTestStream = {
id: '',
ownerId: '',
name: 'My Project'
}
const projectB: BasicTestStream = {
id: '',
ownerId: '',
name: 'My Project 2'
}
let apollo: TestApolloServer
before(async () => {
await createTestUser(adminUser)
await createTestWorkspace(workspace, adminUser)
projectA.workspaceId = workspace.id
projectB.workspaceId = workspace.id
await createTestStream(projectA, adminUser)
await createTestStream(projectB, adminUser)
const adminApollo = await testApolloServer({
context: await createTestContext({
auth: true,
userId: adminUser.id,
role: Roles.Server.Admin,
scopes: AllScopes,
token: 'abc'
})
})
const res = await adminApollo.execute(CreateEmbedTokenDocument, {
token: {
projectId: projectA.id,
resourceIdString: 'foo123'
}
})
const token = res.data!.projectMutations.createEmbedToken.token
apollo = await testApolloServer({
context: await createTestContext({
auth: true,
userId: adminUser.id,
role: Roles.Server.Admin,
scopes: [Scopes.Streams.Read],
resourceAccessRules: [
{ id: projectA.id, type: TokenResourceIdentifierType.Project }
],
token
})
})
})
it('can read associated project data', async () => {
const res = await apollo.execute(GetProjectDocument, { id: projectA.id })
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.project.name).to.equal(projectA.name)
})
it('cannot read other project data, even if the source user has access', async () => {
const res = await apollo.execute(GetProjectDocument, { id: projectB.id })
expect(res).to.haveGraphQLErrors()
})
it('cannot access source user profile', async () => {
const res = await apollo.execute(GetActiveUserDocument, {})
expect(res).to.haveGraphQLErrors()
})
it('cannot access workspace data', async () => {
const res = await apollo.execute(GetWorkspaceDocument, {
workspaceId: workspace.id
})
expect(res).to.haveGraphQLErrors()
})
})
@@ -15,7 +15,8 @@ import {
import {
getStreamFactory,
createStreamFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import { db } from '@/db/knex'
import {
@@ -86,6 +87,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -11,7 +11,8 @@ import { ForbiddenError } from '@/modules/shared/errors'
import {
getStreamFactory,
createStreamFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import { db } from '@/db/knex'
import {
@@ -78,6 +79,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -18,7 +18,8 @@ import { authorizeResolver } from '@/modules/shared'
import {
getStreamFactory,
revokeStreamPermissionsFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import {
getUserFactory,
@@ -76,6 +77,7 @@ const removeStreamCollaborator = removeStreamCollaboratorFactory({
validateStreamAccess,
isStreamCollaborator,
revokeStreamPermissions: revokeStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
@@ -83,6 +85,7 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const getUsers = legacyGetPaginatedUsersFactory({ db })
@@ -7,7 +7,7 @@ import {
storeProjectFactory,
storeProjectRoleFactory
} from '@/modules/core/repositories/projects'
import { getRolesByUserIdFactory } from '@/modules/core/repositories/streams'
import { getUserProjectRolesFactory } from '@/modules/core/repositories/projects'
import { expectToThrow } from '@/test/assertionHelper'
import { createTestUser } from '@/test/authHelper'
import { Roles } from '@speckle/shared'
@@ -105,7 +105,9 @@ describe('project repositories @core', () => {
userId: testUser.id
}
await storeProjectRole(role)
const storedRoles = await getRolesByUserIdFactory({ db })({ userId: testUser.id })
const storedRoles = await getUserProjectRolesFactory({ db })({
userId: testUser.id
})
expect(storedRoles).deep.equalInAnyOrder([
{ resourceId: project.id, role: role.role, userId: role.userId }
])
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,8 @@ import { getAnIdForThisOnePlease } from '@/test/helpers'
import {
getStreamFactory,
createStreamFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import { db } from '@/db/knex'
import {
@@ -119,6 +120,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -12,7 +12,8 @@ import { Scopes } from '@speckle/shared'
import {
getStreamFactory,
createStreamFactory,
grantStreamPermissionsFactory
grantStreamPermissionsFactory,
getStreamRolesFactory
} from '@/modules/core/repositories/streams'
import { db } from '@/db/knex'
import {
@@ -92,6 +93,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -18,6 +18,7 @@ import {
createStreamFactory,
deleteStreamFactory,
getStreamFactory,
getStreamRolesFactory,
getStreamsCollaboratorsFactory,
grantStreamPermissionsFactory,
markBranchStreamUpdatedFactory,
@@ -163,6 +164,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -245,6 +247,7 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const isStreamCollaborator = isStreamCollaboratorFactory({
@@ -37,7 +37,9 @@ import {
grantStreamPermissionsFactory,
markCommitStreamUpdatedFactory,
deleteStreamFactory,
getUserDeletableStreamsFactory
getUserDeletableStreamsFactory,
getStreamRolesFactory,
legacyGetStreamsFactory
} from '@/modules/core/repositories/streams'
import {
getObjectFactory,
@@ -128,6 +130,8 @@ import {
validateStreamAccessFactory
} from '@/modules/core/services/streams/access'
import { authorizeResolver } from '@/modules/shared'
import { getUserWorkspaceSeatsFactory } from '@/modules/workspacesCore/repositories/workspaces'
import { queryAllProjectsFactory } from '@/modules/core/services/projects'
const getServerInfo = getServerInfoFactory({ db })
const getUser = legacyGetUserFactory({ db })
@@ -167,6 +171,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -279,6 +284,10 @@ const deleteUser = deleteUserFactory({
logger: dbLogger,
isLastAdminUser: isLastAdminUserFactory({ db }),
getUserDeletableStreams: getUserDeletableStreamsFactory({ db }),
queryAllProjects: queryAllProjectsFactory({
getStreams: legacyGetStreamsFactory({ db })
}),
getUserWorkspaceSeats: getUserWorkspaceSeatsFactory({ db }),
deleteAllUserInvites: deleteAllUserInvitesFactory({ db }),
deleteUserRecord: deleteUserRecordFactory({ db }),
emitEvent: getEventBus().emit
@@ -38,12 +38,15 @@ import {
} from '@/modules/serverinvites/repositories/serverInvites'
import {
deleteStreamFactory,
getUserDeletableStreamsFactory
getUserDeletableStreamsFactory,
legacyGetStreamsFactory
} from '@/modules/core/repositories/streams'
import { dbLogger } from '@/observability/logging'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { expect } from 'chai'
import { getUserWorkspaceSeatsFactory } from '@/modules/workspacesCore/repositories/workspaces'
import { queryAllProjectsFactory } from '@/modules/core/services/projects'
const getUsers = legacyGetPaginatedUsersFactory({ db })
const countUsers = legacyGetPaginatedUsersCountFactory({ db })
@@ -81,6 +84,10 @@ const deleteUser = deleteUserFactory({
logger: dbLogger,
isLastAdminUser: isLastAdminUserFactory({ db }),
getUserDeletableStreams: getUserDeletableStreamsFactory({ db }),
queryAllProjects: queryAllProjectsFactory({
getStreams: legacyGetStreamsFactory({ db })
}),
getUserWorkspaceSeats: getUserWorkspaceSeatsFactory({ db }),
deleteAllUserInvites: deleteAllUserInvitesFactory({ db }),
deleteUserRecord: deleteUserRecordFactory({ db }),
emitEvent: getEventBus().emit
@@ -12,6 +12,7 @@ import { createAuthedTestContext, ServerAndContext } from '@/test/graphqlHelper'
import {
createStreamFactory,
getStreamFactory,
getStreamRolesFactory,
grantStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import { db } from '@/db/knex'
@@ -86,6 +87,7 @@ const buildFinalizeProjectInvite = () =>
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
}),
@@ -925,6 +925,12 @@ export type CreateCommentReplyInput = {
threadId: Scalars['String']['input'];
};
export type CreateEmbedTokenReturn = {
__typename?: 'CreateEmbedTokenReturn';
token: Scalars['String']['output'];
tokenMetadata: EmbedToken;
};
export type CreateModelInput = {
description?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
@@ -1003,6 +1009,32 @@ export type EmailVerificationRequestInput = {
id: Scalars['ID']['input'];
};
/** A token used to enable an embedded viewer for a private project */
export type EmbedToken = {
__typename?: 'EmbedToken';
createdAt: Scalars['DateTime']['output'];
lastUsed: Scalars['DateTime']['output'];
lifespan: Scalars['BigInt']['output'];
projectId: Scalars['String']['output'];
resourceIdString: Scalars['String']['output'];
tokenId: Scalars['String']['output'];
user?: Maybe<LimitedUser>;
};
export type EmbedTokenCollection = {
__typename?: 'EmbedTokenCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<EmbedToken>;
totalCount: Scalars['Int']['output'];
};
export type EmbedTokenCreateInput = {
lifespan?: InputMaybe<Scalars['BigInt']['input']>;
projectId: Scalars['String']['input'];
/** The model(s) and version(s) string used in the embed url */
resourceIdString: Scalars['String']['input'];
};
export type FileUpload = {
__typename?: 'FileUpload';
branchName: Scalars['String']['output'];
@@ -2098,6 +2130,7 @@ export type Project = {
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
embedTokens: EmbedTokenCollection;
hasAccessToFeature: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
invitableCollaborators: WorkspaceCollaboratorCollection;
@@ -2180,6 +2213,12 @@ export type ProjectCommentThreadsArgs = {
};
export type ProjectEmbedTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type ProjectHasAccessToFeatureArgs = {
featureName: WorkspaceFeatureName;
};
@@ -2579,6 +2618,7 @@ export type ProjectMutations = {
batchDelete: Scalars['Boolean']['output'];
/** Create new project */
create: Project;
createEmbedToken: CreateEmbedTokenReturn;
/**
* Create onboarding/tutorial project. If one is already created for the active user, that
* one will be returned instead.
@@ -2590,6 +2630,8 @@ export type ProjectMutations = {
invites: ProjectInviteMutations;
/** Leave a project. Only possible if you're not the last remaining owner. */
leave: Scalars['Boolean']['output'];
revokeEmbedToken: Scalars['Boolean']['output'];
revokeEmbedTokens: Scalars['Boolean']['output'];
/** Updates an existing project */
update: Project;
/** Update role for a collaborator */
@@ -2612,6 +2654,11 @@ export type ProjectMutationsCreateArgs = {
};
export type ProjectMutationsCreateEmbedTokenArgs = {
token: EmbedTokenCreateInput;
};
export type ProjectMutationsDeleteArgs = {
id: Scalars['String']['input'];
};
@@ -2622,6 +2669,17 @@ export type ProjectMutationsLeaveArgs = {
};
export type ProjectMutationsRevokeEmbedTokenArgs = {
projectId: Scalars['String']['input'];
token: Scalars['String']['input'];
};
export type ProjectMutationsRevokeEmbedTokensArgs = {
projectId: Scalars['String']['input'];
};
export type ProjectMutationsUpdateArgs = {
update: ProjectUpdateInput;
};
@@ -2664,6 +2722,7 @@ export type ProjectPermissionChecks = {
canBroadcastActivity: PermissionCheckResult;
canCreateAutomation: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateEmbedTokens: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
canInvite: PermissionCheckResult;
@@ -2672,9 +2731,11 @@ export type ProjectPermissionChecks = {
canMoveToWorkspace: PermissionCheckResult;
canPublish: PermissionCheckResult;
canRead: PermissionCheckResult;
canReadEmbedTokens: PermissionCheckResult;
canReadSettings: PermissionCheckResult;
canReadWebhooks: PermissionCheckResult;
canRequestRender: PermissionCheckResult;
canRevokeEmbedTokens: PermissionCheckResult;
canUpdate: PermissionCheckResult;
canUpdateAllowPublicComments: PermissionCheckResult;
};
@@ -69,11 +69,16 @@ export type ProcessFileImportResult = (params: {
export type UpdateFileStatus = (params: {
fileId: string
projectId: string
status: FileUploadConvertedStatus
convertedMessage: string
convertedCommitId: string | null
}) => Promise<FileUploadRecord>
export type UpdateFileStatusForProjectFactory = (params: {
projectId: string
}) => Promise<UpdateFileStatus>
export type UploadedFile = UploadResult & { userId: string }
export type FileImportMessage = Pick<
@@ -25,7 +25,9 @@ import {
} from '@/modules/shared/errors'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import {
fileImportServiceShouldUsePrivateObjectsServerUrl,
getFileUploadUrlExpiryMinutes,
getPrivateObjectsServerOrigin,
getServerOrigin,
isFileUploadsEnabled
} from '@/modules/shared/helpers/envHelper'
@@ -68,6 +70,11 @@ import cryptoRandomString from 'crypto-random-string'
import { getFeatureFlags } from '@speckle/shared/environment'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { getModelUploadsFactory } from '@/modules/fileuploads/services/management'
import {
FileUploadRecord,
FileUploadRecordV2
} from '@/modules/fileuploads/helpers/types'
const { FF_LARGE_FILE_IMPORTS_ENABLED, FF_NEXT_GEN_FILE_IMPORTER_ENABLED } =
getFeatureFlags()
@@ -134,7 +141,7 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
const generatePresignedUrl = generatePresignedUrlFactory({
getSignedUrl: getSignedUrlFactory({
objectStorage: projectStorage
objectStorage: projectStorage.public
}),
upsertBlob: upsertBlobFactory({
db: projectDb
@@ -189,7 +196,9 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
])
const pushJobToFileImporter = pushJobToFileImporterFactory({
getServerOrigin,
getServerOrigin: fileImportServiceShouldUsePrivateObjectsServerUrl()
? getPrivateObjectsServerOrigin
: getServerOrigin,
createAppToken: createAppTokenFactory({
storeApiToken: storeApiTokenFactory({ db }),
storeTokenScopes: storeTokenScopesFactory({ db }),
@@ -221,7 +230,7 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
db: projectDb
}),
getBlobMetadata: getBlobMetadataFromStorage({
objectStorage: projectStorage
objectStorage: projectStorage.private
})
}),
insertNewUploadAndNotify: FF_NEXT_GEN_FILE_IMPORTER_ENABLED
@@ -249,11 +258,6 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
}
}
}
import { getModelUploadsFactory } from '@/modules/fileuploads/services/management'
import {
FileUploadRecord,
FileUploadRecordV2
} from '@/modules/fileuploads/helpers/types'
export = {
Stream: {

Some files were not shown because too many files have changed in this diff Show More