Merged with main
This commit is contained in:
@@ -17,7 +17,7 @@ class Version:
|
||||
return 1
|
||||
if self.pre_release_tag == 'beta':
|
||||
return 2
|
||||
return 0
|
||||
return 10
|
||||
|
||||
|
||||
@staticmethod
|
||||
@@ -61,6 +61,7 @@ class Version:
|
||||
if self.pre_release_priority > other.pre_release_priority:
|
||||
return True
|
||||
if self.pre_release_priority < other.pre_release_priority:
|
||||
print('foo')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
+72
-19
@@ -1,8 +1,5 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
node: circleci/node@5.0.1
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
|
||||
@@ -20,6 +17,7 @@ workflows:
|
||||
branches:
|
||||
only:
|
||||
- main
|
||||
|
||||
- docker-build-and-publish-server:
|
||||
filters: *filters-build
|
||||
context: &docker-hub-context
|
||||
@@ -62,6 +60,12 @@ workflows:
|
||||
- get-version
|
||||
- test-server
|
||||
|
||||
- docker-build-and-publish-monitor-container:
|
||||
context: *docker-hub-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
|
||||
- publish-helm-chart:
|
||||
filters: *filters-build
|
||||
requires:
|
||||
@@ -72,6 +76,7 @@ workflows:
|
||||
- docker-build-and-publish-webhooks
|
||||
- docker-build-and-publish-file-imports
|
||||
- docker-build-and-publish-previews
|
||||
- docker-build-and-publish-monitor-container
|
||||
- docker-build-and-publish-test-container
|
||||
|
||||
- publish-npm:
|
||||
@@ -108,7 +113,7 @@ jobs:
|
||||
|
||||
test-server:
|
||||
docker:
|
||||
- image: cimg/node:lts
|
||||
- image: cimg/node:16.15
|
||||
- image: cimg/redis:6.2.6
|
||||
- image: 'cimg/postgres:12.8'
|
||||
environment:
|
||||
@@ -125,8 +130,22 @@ jobs:
|
||||
CANONICAL_URL: 'http://localhost:3000'
|
||||
steps:
|
||||
- checkout
|
||||
- node/install-packages:
|
||||
app-dir: ~/project/packages/server
|
||||
- restore_cache:
|
||||
name: Restore Yarn Package Cache
|
||||
keys:
|
||||
- yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: yarn workspaces focus
|
||||
working_directory: 'packages/server'
|
||||
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
paths:
|
||||
- .yarn/cache
|
||||
- .yarn/unplugged
|
||||
|
||||
- run: 'dockerize -wait tcp://localhost:5432 -timeout 1m'
|
||||
|
||||
- run:
|
||||
@@ -134,7 +153,7 @@ jobs:
|
||||
working_directory: 'packages/server'
|
||||
|
||||
- run:
|
||||
command: npm run test:report
|
||||
command: yarn test:report
|
||||
working_directory: 'packages/server'
|
||||
|
||||
- run:
|
||||
@@ -150,7 +169,7 @@ jobs:
|
||||
|
||||
docker-build-and-publish: &docker-job
|
||||
docker: &docker-image
|
||||
- image: cimg/python:3.9-node
|
||||
- image: cimg/node:16.15
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
@@ -158,6 +177,9 @@ jobs:
|
||||
at: /tmp/ci/workspace
|
||||
- run: cat workspace/env-vars >> $BASH_ENV
|
||||
- setup_remote_docker:
|
||||
# a weird issue with yarn installing packages throwing EPERM errors
|
||||
# this fixes it
|
||||
version: 20.10.12
|
||||
docker_layer_caching: true
|
||||
- run:
|
||||
name: Build and Publish
|
||||
@@ -194,6 +216,12 @@ jobs:
|
||||
FOLDER: utils
|
||||
SPECKLE_SERVER_PACKAGE: test-deployment
|
||||
|
||||
docker-build-and-publish-monitor-container:
|
||||
<<: *docker-job
|
||||
environment:
|
||||
FOLDER: utils
|
||||
SPECKLE_SERVER_PACKAGE: monitor-deployment
|
||||
|
||||
publish-npm:
|
||||
docker: *docker-image
|
||||
working_directory: *work-dir
|
||||
@@ -202,25 +230,50 @@ jobs:
|
||||
- attach_workspace:
|
||||
at: /tmp/ci/workspace
|
||||
- run: cat workspace/env-vars >> $BASH_ENV
|
||||
|
||||
- restore_cache:
|
||||
name: Restore Yarn Package Cache
|
||||
keys:
|
||||
- yarn-packages-{{ checksum "yarn.lock" }}
|
||||
- run:
|
||||
name: restore packages
|
||||
command: |
|
||||
npm i
|
||||
npx lerna bootstrap
|
||||
# this has to be after lerna bootstrap
|
||||
# otherwise the auth workflow of lerna is borked...
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-{{ checksum "yarn.lock" }}
|
||||
paths:
|
||||
- .yarn/cache
|
||||
- .yarn/unplugged
|
||||
|
||||
- run:
|
||||
name: auth to npm as Speckle
|
||||
command: |
|
||||
echo "@speckle:registry=https://registry.npmjs.org" >> .npmrc
|
||||
echo "registry=http://registry.npmjs.org/" >> .npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
|
||||
echo "npmRegistryServer: https://registry.npmjs.org/" >> .yarnrc.yml
|
||||
echo "npmAuthToken: ${NPM_TOKEN}" >> .yarnrc.yml
|
||||
- run:
|
||||
name: try login to npm
|
||||
command: npm whoami
|
||||
command: yarn npm whoami
|
||||
|
||||
- run:
|
||||
name: build public packages
|
||||
command: yarn workspaces foreach -ptv --no-private run build
|
||||
- run:
|
||||
name: bump all versions
|
||||
# bump all versions in dependency tree order but not in parallel
|
||||
command: yarn workspaces foreach -tv version $IMAGE_VERSION_TAG
|
||||
|
||||
- run:
|
||||
name: publish to npm
|
||||
command: npx lerna publish $IMAGE_VERSION_TAG -y --no-git-tag-version --no-push
|
||||
command: 'yarn workspaces foreach -pv --no-private npm publish --access public --tag next'
|
||||
|
||||
# - run:
|
||||
# name: commit changes
|
||||
# command: |
|
||||
# yarn prettier:fix
|
||||
# git add .
|
||||
# git commit -m '[ci skip] bump version to $IMAGE_VERSION_TAG'
|
||||
# git push
|
||||
|
||||
publish-helm-chart:
|
||||
docker: *docker-image
|
||||
|
||||
@@ -27,8 +27,8 @@ fi
|
||||
rm -rf ~/helm/charts/speckle-server
|
||||
cp -r utils/helm/speckle-server ~/helm/charts/speckle-server
|
||||
|
||||
echo 'version: '$RELEASE_VERSION >> ~/helm/charts/speckle-server/Chart.yaml
|
||||
echo 'appVersion: "'$RELEASE_VERSION'"' >> ~/helm/charts/speckle-server/Chart.yaml
|
||||
sed -i 's/version: [^\s]*/version: '$RELEASE_VERSION'/g' ~/helm/charts/speckle-server/Chart.yaml
|
||||
sed -i 's/appVersion: [^\s]*/appVersion: '\"$RELEASE_VERSION\"'/g' ~/helm/charts/speckle-server/Chart.yaml
|
||||
|
||||
sed -i 's/docker_image_tag: [^\s]*/docker_image_tag: '$RELEASE_VERSION'/g' ~/helm/charts/speckle-server/values.yaml
|
||||
|
||||
|
||||
+9
-1
@@ -6,9 +6,17 @@ test-queries
|
||||
.editorconfig
|
||||
Contributing.md
|
||||
ISSUE_TEMPLATE.md
|
||||
lerna.json
|
||||
**/.env
|
||||
.env.example
|
||||
.eslintrc.json
|
||||
.mocharc.js
|
||||
readme.md
|
||||
**/Dockerfile
|
||||
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
+9
-1
@@ -31,4 +31,12 @@ events.json
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
lerna-debug.log
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
*yarn-error.log
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
yarn lint-staged
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: 'v8.11.0' # Use the sha / tag you want to point at
|
||||
rev: 'v8.15.0' # Use the sha / tag you want to point at
|
||||
hooks:
|
||||
- id: eslint
|
||||
types: [file]
|
||||
@@ -17,6 +17,8 @@ repos:
|
||||
- typescript@4.5.4
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: 'v2.5.1' # Use the sha / tag you want to point at
|
||||
rev: 'v2.6.2' # Use the sha / tag you want to point at
|
||||
hooks:
|
||||
- id: prettier
|
||||
ci:
|
||||
autoupdate_schedule: quarterly
|
||||
|
||||
+550
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+785
File diff suppressed because one or more lines are too long
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- '@speckle/fileimport-service'
|
||||
- '@speckle/frontend'
|
||||
- '@speckle/objectloader'
|
||||
- '@speckle/preview-service'
|
||||
- '@speckle/server'
|
||||
- '@speckle/viewer'
|
||||
- '@speckle/viewer-sandbox'
|
||||
@@ -0,0 +1,9 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: '@yarnpkg/plugin-workspace-tools'
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-version.cjs
|
||||
spec: '@yarnpkg/plugin-version'
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.2.0.cjs
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"packages": ["packages/*"],
|
||||
"version": "independent",
|
||||
"command": {
|
||||
"version": {
|
||||
"message": "chore(release): publish to npm\n[skip ci]"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-20661
File diff suppressed because it is too large
Load Diff
+18
-5
@@ -1,33 +1,46 @@
|
||||
{
|
||||
"packageManager": "yarn@3.2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0 <17.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn workspaces foreach -ptv run build",
|
||||
"build:public": "yarn workspaces foreach -ptv --no-private run build",
|
||||
"lint": "eslint . --ext .js,.ts,.vue --max-warnings=0",
|
||||
"prettier:check": "prettier --check .",
|
||||
"prettier:fix": "prettier --write .",
|
||||
"docker:deps:up": "docker-compose -f ./docker-compose-deps.yml up -d",
|
||||
"docker:deps:down": "docker-compose -f ./docker-compose-deps.yml down",
|
||||
"dev": "lerna run dev --parallel",
|
||||
"dev:no-server": "npm run dev -- --ignore @speckle/server",
|
||||
"dev": "yarn workspaces foreach -piv -j unlimited run dev",
|
||||
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server -piv -j unlimited run dev",
|
||||
"dev:minimal": "yarn workspaces foreach -piv -j unlimited --include '{@speckle/server,@speckle/frontend}' run dev",
|
||||
"prepare": "husky install",
|
||||
"postinstall": "husky install"
|
||||
"postinstall": "husky install",
|
||||
"cm": "cz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.4.1",
|
||||
"commitizen": "^4.2.4",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"husky": "^7.0.4",
|
||||
"lerna": "^3.22.1",
|
||||
"lint-staged": "^12.3.7",
|
||||
"prettier": "^2.5.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"tslib": "^2.3.1",
|
||||
"core-js": "3.22.4",
|
||||
"vue-cli-plugin-apollo/graphql": "^15"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
"path": "cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch via NPM",
|
||||
"request": "launch",
|
||||
"runtimeArgs": ["run-script", "dev"],
|
||||
"runtimeExecutable": "npm",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"type": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,14 +12,19 @@ RUN chmod +x /wait
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /speckle-server
|
||||
|
||||
COPY packages/fileimport-service/package*.json ./
|
||||
RUN npm ci
|
||||
COPY .yarnrc.yml .
|
||||
COPY .yarn ./.yarn
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
WORKDIR /speckle-server/packages/fileimport-service
|
||||
COPY packages/fileimport-service/package.json ./
|
||||
RUN yarn workspaces focus --production
|
||||
|
||||
COPY packages/fileimport-service/requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY packages/fileimport-service .
|
||||
|
||||
CMD ["node", "src/daemon.js"]
|
||||
CMD ["yarn", "node", "src/daemon.js"]
|
||||
|
||||
-4965
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@speckle/fileimport-service",
|
||||
"private": true,
|
||||
"version": "2.3.17-alpha.2889",
|
||||
"version": "2.5.4",
|
||||
"description": "Parse and import files of various types into a stream",
|
||||
"author": "Dimitrie Stefanescu <didimitrie@gmail.com>",
|
||||
"homepage": "https://github.com/specklesystems/speckle-server#readme",
|
||||
@@ -28,6 +28,7 @@
|
||||
"knex": "^1.0.3",
|
||||
"node-fetch": "^2.6.5",
|
||||
"pg": "^8.7.1",
|
||||
"prom-client": "^14.0.1",
|
||||
"valid-filename": "^3.1.0",
|
||||
"web-ifc": "^0.0.33"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* eslint-disable no-console */
|
||||
'use strict'
|
||||
|
||||
const {
|
||||
initPrometheusMetrics,
|
||||
metricDuration,
|
||||
metricInputFileSize,
|
||||
metricOperationErrors
|
||||
} = require('./prometheusMetrics')
|
||||
const knex = require('../knex')
|
||||
|
||||
const { getFileStream } = require('./filesApi')
|
||||
@@ -39,13 +45,16 @@ async function startTask() {
|
||||
async function doTask(task) {
|
||||
let tempUserToken = null
|
||||
let serverApi = null
|
||||
let fileTypeForMetric = 'unknown'
|
||||
let fileSizeForMetric = 0
|
||||
|
||||
const metricDurationEnd = metricDuration.startTimer()
|
||||
try {
|
||||
console.log('Doing task ', task)
|
||||
const { rows } = await knex.raw(
|
||||
`
|
||||
SELECT
|
||||
id as "fileId", "streamId", "branchName", "userId", "fileName", "fileType"
|
||||
id as "fileId", "streamId", "branchName", "userId", "fileName", "fileType", "fileSize"
|
||||
FROM file_uploads
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
@@ -56,6 +65,8 @@ async function doTask(task) {
|
||||
if (!info) {
|
||||
throw new Error('Internal error: DB inconsistent')
|
||||
}
|
||||
fileTypeForMetric = info.fileType || 'missing_info'
|
||||
fileSizeForMetric = Number(info.fileSize) || 0
|
||||
|
||||
fs.mkdirSync(TMP_INPUT_DIR, { recursive: true })
|
||||
|
||||
@@ -89,7 +100,7 @@ async function doTask(task) {
|
||||
{
|
||||
USER_TOKEN: tempUserToken
|
||||
},
|
||||
10 * 60 * 1000
|
||||
20 * 60 * 1000
|
||||
)
|
||||
} else if (info.fileType === 'stl') {
|
||||
await runProcessWithTimeout(
|
||||
@@ -165,7 +176,10 @@ async function doTask(task) {
|
||||
`,
|
||||
[err.toString(), task.id]
|
||||
)
|
||||
metricOperationErrors.labels(fileTypeForMetric).inc()
|
||||
}
|
||||
metricDurationEnd({ op: fileTypeForMetric })
|
||||
metricInputFileSize.labels(fileTypeForMetric).observe(fileSizeForMetric)
|
||||
|
||||
fs.rmSync(TMP_INPUT_DIR, { force: true, recursive: true })
|
||||
if (fs.existsSync(TMP_RESULTS_PATH)) fs.unlinkSync(TMP_RESULTS_PATH)
|
||||
@@ -234,6 +248,7 @@ async function tick() {
|
||||
// Check for another task very soon
|
||||
setTimeout(tick, 10)
|
||||
} catch (err) {
|
||||
metricOperationErrors.labels('main_loop').inc()
|
||||
console.log('Error executing task: ', err)
|
||||
setTimeout(tick, 5000)
|
||||
}
|
||||
@@ -241,6 +256,7 @@ async function tick() {
|
||||
|
||||
async function main() {
|
||||
console.log('Starting FileUploads Service...')
|
||||
initPrometheusMetrics()
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
shouldExit = true
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
'use strict'
|
||||
|
||||
const http = require('http')
|
||||
const prometheusClient = require('prom-client')
|
||||
const knex = require('../knex')
|
||||
|
||||
let metricFree = null
|
||||
let metricUsed = null
|
||||
let metricPendingAquires = null
|
||||
let metricQueryDuration = null
|
||||
let metricQueryErrors = null
|
||||
|
||||
const queryStartTime = {}
|
||||
prometheusClient.register.clear()
|
||||
prometheusClient.register.setDefaultLabels({
|
||||
project: 'speckle-server',
|
||||
app: 'fileimport-service'
|
||||
})
|
||||
prometheusClient.collectDefaultMetrics()
|
||||
|
||||
let prometheusInitialized = false
|
||||
|
||||
function initKnexPrometheusMetrics() {
|
||||
metricFree = new prometheusClient.Gauge({
|
||||
name: 'speckle_server_knex_free',
|
||||
help: 'Number of free DB connections',
|
||||
collect() {
|
||||
this.set(knex.client.pool.numFree())
|
||||
}
|
||||
})
|
||||
|
||||
metricUsed = new prometheusClient.Gauge({
|
||||
name: 'speckle_server_knex_used',
|
||||
help: 'Number of used DB connections',
|
||||
collect() {
|
||||
this.set(knex.client.pool.numUsed())
|
||||
}
|
||||
})
|
||||
|
||||
metricPendingAquires = new prometheusClient.Gauge({
|
||||
name: 'speckle_server_knex_pending',
|
||||
help: 'Number of pending DB connection aquires',
|
||||
collect() {
|
||||
this.set(knex.client.pool.numPendingAcquires())
|
||||
}
|
||||
})
|
||||
|
||||
metricQueryDuration = new prometheusClient.Summary({
|
||||
name: 'speckle_server_knex_query_duration',
|
||||
help: 'Summary of the DB query durations in seconds'
|
||||
})
|
||||
|
||||
metricQueryErrors = new prometheusClient.Counter({
|
||||
name: 'speckle_server_knex_query_errors',
|
||||
help: 'Number of DB queries with errors'
|
||||
})
|
||||
|
||||
knex.on('query', (data) => {
|
||||
const queryId = data.__knexQueryUid + ''
|
||||
queryStartTime[queryId] = Date.now()
|
||||
})
|
||||
|
||||
knex.on('query-response', (data, obj, builder) => {
|
||||
const queryId = obj.__knexQueryUid + ''
|
||||
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
|
||||
delete queryStartTime[queryId]
|
||||
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
|
||||
})
|
||||
|
||||
knex.on('query-error', (err, querySpec) => {
|
||||
const queryId = querySpec.__knexQueryUid + ''
|
||||
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
|
||||
delete queryStartTime[queryId]
|
||||
|
||||
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
|
||||
metricQueryErrors.inc()
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initPrometheusMetrics() {
|
||||
if (prometheusInitialized) return
|
||||
prometheusInitialized = true
|
||||
|
||||
initKnexPrometheusMetrics()
|
||||
|
||||
// Define the HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/metrics') {
|
||||
res.setHeader('Content-Type', prometheusClient.register.contentType)
|
||||
res.end(await prometheusClient.register.metrics())
|
||||
} else {
|
||||
res.end('Speckle FileImport Service - prometheus metrics')
|
||||
}
|
||||
})
|
||||
server.listen(Number(process.env.PROMETHEUS_METRICS_PORT) || 9093)
|
||||
},
|
||||
|
||||
metricDuration: new prometheusClient.Histogram({
|
||||
name: 'speckle_server_operation_duration',
|
||||
help: 'Summary of the operation durations in seconds',
|
||||
buckets: [0.5, 1, 5, 10, 30, 60, 300, 600, 900, 1200],
|
||||
labelNames: ['op']
|
||||
}),
|
||||
|
||||
metricOperationErrors: new prometheusClient.Counter({
|
||||
name: 'speckle_server_operation_errors',
|
||||
help: 'Number of operations with errors',
|
||||
labelNames: ['op']
|
||||
}),
|
||||
|
||||
metricInputFileSize: new prometheusClient.Histogram({
|
||||
name: 'speckle_server_operation_file_size',
|
||||
help: 'Size of the operation input file size',
|
||||
buckets: [
|
||||
1000,
|
||||
100 * 1000,
|
||||
500 * 1000,
|
||||
1000 * 1000,
|
||||
5 * 1000 * 1000,
|
||||
10 * 1000 * 1000,
|
||||
100 * 1000 * 1000
|
||||
],
|
||||
labelNames: ['op']
|
||||
})
|
||||
}
|
||||
@@ -1,41 +1,31 @@
|
||||
# NOTE: Docker context should be set to git root directory, to include the viewer
|
||||
|
||||
# build stage
|
||||
FROM node:16.13-bullseye-slim as deps-build
|
||||
|
||||
WORKDIR /opt/objectloader
|
||||
COPY packages/objectloader /opt/objectloader
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
# this whole thing is required cause npm 6.* doesn't allow for pack output dir specification
|
||||
RUN mkdir /packages
|
||||
# invoke npm pack and move its result to the packages folder when done
|
||||
RUN npm pack --pack-destination=/packages/
|
||||
|
||||
WORKDIR /opt/viewer
|
||||
COPY packages/viewer/package*.json ./
|
||||
# this installs objectloader from a tarball
|
||||
RUN npm i $(find /packages -type f -name "speckle*.tgz")
|
||||
RUN npm install
|
||||
COPY packages/viewer .
|
||||
RUN npm run build
|
||||
RUN npm pack --pack-destination=/packages/
|
||||
|
||||
FROM node:16.13-bullseye-slim as build-stage
|
||||
|
||||
WORKDIR /opt/frontend
|
||||
COPY --from=deps-build /packages /packages
|
||||
COPY packages/frontend/package*.json ./
|
||||
# RUN npm install ../viewer
|
||||
RUN npm i $(find /packages -type f -name "speckle*.tgz")
|
||||
RUN npm ci
|
||||
COPY packages/frontend .
|
||||
RUN npm run build
|
||||
WORKDIR /speckle-server
|
||||
COPY .yarnrc.yml .
|
||||
COPY .yarn ./.yarn
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Onyl copy in the relevant package.json files for the dependencies
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
COPY packages/viewer/package.json ./packages/viewer/
|
||||
COPY packages/objectloader/package.json ./packages/objectloader/
|
||||
|
||||
RUN yarn workspaces focus -A
|
||||
|
||||
# Onyl copy in the relevant source files for the dependencies
|
||||
COPY packages/objectloader ./packages/objectloader/
|
||||
COPY packages/viewer ./packages/viewer/
|
||||
COPY packages/frontend ./packages/frontend/
|
||||
|
||||
# This way the foreach only builds the frontend and its deps
|
||||
RUN yarn workspaces foreach -pt run build
|
||||
|
||||
# production stage
|
||||
FROM openresty/openresty:1.19.9.1-bullseye as production-stage
|
||||
COPY --from=build-stage /opt/frontend/dist /usr/share/nginx/html
|
||||
COPY --from=build-stage /speckle-server/packages/frontend/dist /usr/share/nginx/html
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
COPY packages/frontend/nginx/nginx.conf /etc/nginx/conf.d
|
||||
EXPOSE 80
|
||||
|
||||
@@ -10,7 +10,7 @@ Note that this package contains two vue apps, the main frontend (located under @
|
||||
|
||||
Notes:
|
||||
|
||||
- In **development** mode, the Speckle Server will proxy the frontend from `localhost:3000` to `localhost:8080`. If you don't see anything, ensure you've run `npm run serve` in the frontend package.
|
||||
- In **development** mode, the Speckle Server will proxy the frontend from `localhost:3000` to `localhost:8080`. If you don't see anything, ensure you've run `yarn serve` in the frontend package.
|
||||
|
||||
- In **production** mode, the Speckle Frontend will be statically served by nginx (see the Dockerfile in the current directory).
|
||||
|
||||
@@ -22,18 +22,12 @@ Comprehensive developer and user documentation can be found in our:
|
||||
|
||||
## Project setup
|
||||
|
||||
The frontend now includes the viewer. Until we get to publish it as a separate module, there's a few extra steps:
|
||||
|
||||
- make sure you build the [Speckle Viewer](../viewer)
|
||||
- afterwards, run
|
||||
```
|
||||
lerna bootstrap
|
||||
```
|
||||
Make sure you follow the Developing and Debugging section in the project root readme.
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
|
||||
```
|
||||
npm run serve
|
||||
yarn serve
|
||||
```
|
||||
|
||||
### Packaging for production
|
||||
|
||||
@@ -18,7 +18,7 @@ function plugin(api) {
|
||||
// Add caching config
|
||||
jsRule
|
||||
.use('cache-loader')
|
||||
.loader(require.resolve('cache-loader'))
|
||||
.loader('cache-loader')
|
||||
.options(
|
||||
api.genCacheConfig('js-esbuild-loader', {
|
||||
target,
|
||||
|
||||
Generated
-42615
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@speckle/frontend",
|
||||
"version": "2.3.17-alpha.2889",
|
||||
"version": "2.5.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vue-cli-service serve --mode development",
|
||||
"serve": "ws -p 8080 -d dist -r '/embed(.*) -> /embedApp.html' '/([a-zA-Z0-9-_/]*)(\\?.*)? -> /app.html' ",
|
||||
"build": "vue-cli-service build --mode production --silent",
|
||||
"build:dev": "vue-cli-service build --mode development --silent",
|
||||
"build:profile": "npm run build -- --profile",
|
||||
"build:dev:profile": "npm run build:dev -- --profile",
|
||||
"build:profile": "yarn build -- --profile",
|
||||
"build:dev:profile": "yarn build:dev -- --profile",
|
||||
"inspect": "vue-cli-service inspect --mode production",
|
||||
"inspect:dev": "vue-cli-service inspect --mode development",
|
||||
"lint": "eslint . --ext .js,.ts,.vue"
|
||||
@@ -17,13 +17,14 @@
|
||||
"node": "^16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@speckle/viewer": "^2.4.2",
|
||||
"@speckle/viewer": "workspace:^",
|
||||
"@tryghost/content-api": "^1.5.12",
|
||||
"@vuejs-community/vue-filter-date-format": "^1.6.3",
|
||||
"@vuejs-community/vue-filter-date-parse": "^1.1.6",
|
||||
"apexcharts": "^3.33.1",
|
||||
"crypto-random-string": "^3.3.0",
|
||||
"dompurify": "^2.3.6",
|
||||
"linkify-urls": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"numeral": "^2.0.6",
|
||||
"portal-vue": "^2.1.7",
|
||||
@@ -70,6 +71,7 @@
|
||||
"vue-cli-plugin-vuetify": "^2.0.8",
|
||||
"vue-template-compiler": "^2.6.12",
|
||||
"vuetify-loader": "^1.6.0",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0"
|
||||
},
|
||||
"vuePlugins": {
|
||||
|
||||
@@ -38,7 +38,11 @@ import 'vue2-perfect-scrollbar/dist/vue2-perfect-scrollbar.css'
|
||||
Vue.use(PerfectScrollbar)
|
||||
|
||||
import VTooltip from 'v-tooltip'
|
||||
Vue.use(VTooltip, { defaultDelay: 300, defaultBoundariesElement: document.body })
|
||||
Vue.use(VTooltip, {
|
||||
defaultDelay: 300,
|
||||
defaultBoundariesElement: document.body,
|
||||
defaultHtml: false
|
||||
})
|
||||
|
||||
import VueMixpanel from 'vue-mixpanel'
|
||||
Vue.use(VueMixpanel, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<v-timeline-item medium>
|
||||
<v-timeline-item v-show="!activityGroup[0].actionType.includes('comment_')" medium>
|
||||
<template #icon>
|
||||
<user-avatar v-if="user" :id="user.id" :avatar="user.avatar" :name="user.name" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
@mouseenter="hover = true"
|
||||
@mouseleave="hover = false"
|
||||
>
|
||||
<div
|
||||
v-if="!link"
|
||||
:class="`flex-grow-1 d-flex px-2 py-1 mb-2 align-center rounded-xl elevation-2 ${
|
||||
$userId() === reply.authorId ? 'primary white--text' : 'background'
|
||||
}`"
|
||||
style="width: 290px"
|
||||
>
|
||||
<div
|
||||
:class="`d-inline-block ${
|
||||
$userId() === reply.authorId ? 'xxx-order-last' : ''
|
||||
}`"
|
||||
>
|
||||
<user-avatar :id="reply.authorId" :size="30" />
|
||||
</div>
|
||||
<div
|
||||
:class="`reply-box d-inline-block mx-2 py-2 flex-grow-1 float-left caption`"
|
||||
v-html="linkifiedText"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
style="width: 300px"
|
||||
:class="`flex-grow-1 d-flex px-2 py-1 mb-2 align-center`"
|
||||
>
|
||||
<div
|
||||
:class="`d-inline-block ${
|
||||
$userId() === reply.authorId ? 'xxx-order-last' : ''
|
||||
}`"
|
||||
>
|
||||
<user-avatar :id="reply.authorId" :size="30" />
|
||||
</div>
|
||||
<div
|
||||
:class="`reply-box d-inline-block py-2 flex-grow-1 float-left caption ${
|
||||
$userId() === reply.authorId ? 'pr-3' : 'pl-1'
|
||||
}`"
|
||||
>
|
||||
<div class="d-block">
|
||||
<v-btn
|
||||
v-tooltip="reply.text"
|
||||
block
|
||||
rounded
|
||||
:href="reply.text"
|
||||
target="_blank"
|
||||
:class="`reply-box overflow-hidden ${
|
||||
$userId() === reply.authorId ? 'primary white--text' : 'background'
|
||||
}`"
|
||||
>
|
||||
<span class="caption">
|
||||
{{ link.host.substring(0, 18) }} {{ link.host.length > 20 ? '...' : '' }}
|
||||
</span>
|
||||
<v-icon small class="ml-2">mdi-open-in-new</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 20px; overflow: hidden">
|
||||
<v-scroll-x-transition>
|
||||
<v-btn
|
||||
v-show="hover && canArchive"
|
||||
v-tooltip="'Archive'"
|
||||
x-small
|
||||
icon
|
||||
class="ml-1"
|
||||
@click="showArchiveDialog = true"
|
||||
>
|
||||
<v-icon small>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-scroll-x-transition>
|
||||
</div>
|
||||
<v-dialog v-model="showArchiveDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-toolbar color="error" dark flat>
|
||||
<v-app-bar-nav-icon style="pointer-events: none">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-app-bar-nav-icon>
|
||||
<v-toolbar-title>
|
||||
Archive Comment {{ index === 0 ? 'Thread' : '' }}
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon @click="showArchiveDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-card-text class="mt-4">
|
||||
This comment {{ index === 0 ? 'thread, including all replies, ' : '' }} will
|
||||
be archived. Are you sure?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn text @click="showArchiveDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" text @click="archiveComment()">Archive</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import linkifyUrls from 'linkify-urls'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserAvatar: () => import('@/main/components/common/UserAvatar')
|
||||
},
|
||||
props: {
|
||||
reply: { type: Object, default: () => null },
|
||||
stream: { type: Object, default: () => null },
|
||||
index: { type: Number, default: 0 }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hover: false,
|
||||
showArchiveDialog: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canArchive() {
|
||||
if (!this.reply || !this.stream) return false
|
||||
if (this.stream.role === 'stream:owner' || this.reply.authorId === this.$userId())
|
||||
return true
|
||||
return false
|
||||
},
|
||||
link() {
|
||||
if (!this.reply) return false
|
||||
try {
|
||||
const url = new URL(this.reply.text)
|
||||
return url
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
linkifiedText() {
|
||||
return linkifyUrls(this.reply.text, {
|
||||
attributes: {
|
||||
target: '_blank',
|
||||
class:
|
||||
this.reply.authorId === this.$userId()
|
||||
? 'comment-link white--text font-weight-bold text-decoration-none'
|
||||
: 'comment-link font-weight-bold text-decoration-none'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async archiveComment() {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation commentArchive($streamId: String!, $commentId: String!) {
|
||||
commentArchive(streamId: $streamId, commentId: $commentId)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
streamId: this.$route.params.streamId,
|
||||
commentId: this.reply.id
|
||||
}
|
||||
})
|
||||
this.$emit('deleted', this.reply.id)
|
||||
this.$mixpanel.track('Comment Action', { type: 'action', name: 'archive' })
|
||||
this.$eventHub.$emit('notification', {
|
||||
text: this.index === 0 ? 'Thread archived.' : 'Comment archived.'
|
||||
})
|
||||
} catch (e) {
|
||||
this.$eventHub.$emit('notification', {
|
||||
text: e.message
|
||||
})
|
||||
}
|
||||
this.showArchiveDialog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
>>> .comment-link:after {
|
||||
content: ' ↗ ';
|
||||
}
|
||||
|
||||
.reply-box {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
class="no-mouse pa-2"
|
||||
class="no-mouse py-2 pl-2"
|
||||
:style="`${
|
||||
$vuetify.breakpoint.xs ? 'width: 90vw;' : 'width: 300px;'
|
||||
} xxx-background: rgba(0.5, 0.5, 0.5, 0.5)`"
|
||||
$vuetify.breakpoint.xs
|
||||
? 'width: 90vw; padding-right:30px;'
|
||||
: 'padding-right:30px; width: 330px;'
|
||||
} ${hovered ? 'opacity: 1;' : 'opacity: 1;'} transition: opacity 0.2s ease;`"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<div v-if="$vuetify.breakpoint.xs" class="text-right mb-5 mouse">
|
||||
<v-btn
|
||||
@@ -50,22 +54,13 @@
|
||||
<timeago :datetime="reply.createdAt" class="font-italic ma-1"></timeago>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:key="index"
|
||||
:class="`d-flex px-2 py-1 mb-2 align-center rounded-xl elevation-2 ${
|
||||
$userId() === reply.authorId ? 'primary white--text' : 'background'
|
||||
}`"
|
||||
>
|
||||
<div :class="`${$userId() === reply.authorId ? 'order-last' : ''}`">
|
||||
<user-avatar :id="reply.authorId" :size="30" />
|
||||
</div>
|
||||
<div
|
||||
:class="`mx-2 px-4 py-2 flex-grow-1 float-left caption`"
|
||||
style="overflow-wrap: break-word"
|
||||
>
|
||||
{{ reply.text }}
|
||||
</div>
|
||||
</div>
|
||||
<comment-thread-reply
|
||||
:key="index + 'reply'"
|
||||
:reply="reply"
|
||||
:stream="stream"
|
||||
:index="index"
|
||||
@deleted="handleReplyDeleteEvent"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="$loggedIn()" class="px-0 mb-4">
|
||||
<v-slide-y-transition>
|
||||
@@ -167,7 +162,7 @@ import debounce from 'lodash/debounce'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserAvatar: () => import('@/main/components/common/UserAvatar')
|
||||
CommentThreadReply: () => import('@/main/components/comments/CommentThreadReply')
|
||||
},
|
||||
props: {
|
||||
comment: { type: Object, default: () => null }
|
||||
@@ -192,6 +187,7 @@ export default {
|
||||
stream(id: $streamId) {
|
||||
id
|
||||
role
|
||||
allowPublicComments
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -292,6 +288,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hovered: true,
|
||||
replyText: null,
|
||||
localReplies: [],
|
||||
minimize: false,
|
||||
@@ -303,7 +300,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
canReply() {
|
||||
return !!this.stream?.role
|
||||
return !!this.stream?.role || this.stream?.allowPublicComments
|
||||
},
|
||||
canArchiveThread() {
|
||||
if (!this.comment || !this.stream) return false
|
||||
@@ -357,7 +354,7 @@ export default {
|
||||
deep: true,
|
||||
async handler(newVal) {
|
||||
if (!this.$loggedIn() || !this.canReply) return
|
||||
|
||||
this.hovered = true
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation commentView($streamId: String!, $commentId: String!) {
|
||||
@@ -503,6 +500,14 @@ export default {
|
||||
this.$emit('refresh-layout') // needed for layout reshuffle in parent
|
||||
}, 100)
|
||||
},
|
||||
handleReplyDeleteEvent(id) {
|
||||
if (this.comment.id === id) {
|
||||
this.$emit('deleted', this.comment)
|
||||
return
|
||||
}
|
||||
const idx = this.localReplies.findIndex((r) => r.id === id)
|
||||
this.localReplies.splice(idx, 1)
|
||||
},
|
||||
async archiveComment() {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
|
||||
@@ -26,8 +26,41 @@
|
||||
</v-list>
|
||||
<v-scroll-y-transition>
|
||||
<div v-show="expand" class="px-2">
|
||||
<div class="d-flex align-center px-2 mb-3">
|
||||
<span class="caption mr-1">Filter</span>
|
||||
<v-btn
|
||||
x-small
|
||||
class="ml-2"
|
||||
:depressed="filter === 'all'"
|
||||
:zzzcolor="`${filter === 'all' ? 'primary' : ''}`"
|
||||
@click="$emit('set-filter', 'all')"
|
||||
>
|
||||
<v-icon x-small class="mr-2">mdi-comment-outline</v-icon>
|
||||
all
|
||||
</v-btn>
|
||||
<v-btn
|
||||
x-small
|
||||
class="ml-2"
|
||||
:depressed="filter === 'unread'"
|
||||
:zzzcolor="`${filter === 'unread' ? 'primary' : ''}`"
|
||||
@click="$emit('set-filter', 'unread')"
|
||||
>
|
||||
<v-icon x-small class="mr-2">mdi-comment-alert-outline</v-icon>
|
||||
unread
|
||||
</v-btn>
|
||||
<v-btn
|
||||
x-small
|
||||
class="ml-2"
|
||||
:depressed="filter === 'none'"
|
||||
:zzzcolor="`${filter === 'none' ? 'primary' : ''}`"
|
||||
@click="$emit('set-filter', 'none')"
|
||||
>
|
||||
<v-icon x-small class="mr-2">mdi-comment-off-outline</v-icon>
|
||||
none
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-row
|
||||
v-for="comment in comments"
|
||||
v-for="comment in visibleComments"
|
||||
:key="comment.id + '-card-sidebar'"
|
||||
no-gutters
|
||||
:class="`${isUnread(comment) ? 'border' : ''} my-2 property-row rounded-lg ${
|
||||
@@ -96,6 +129,10 @@ export default {
|
||||
comments: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
filter: {
|
||||
type: String,
|
||||
default: 'all'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -103,6 +140,19 @@ export default {
|
||||
expand: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visibleComments() {
|
||||
switch (this.filter) {
|
||||
case 'all':
|
||||
return this.comments
|
||||
case 'unread':
|
||||
return this.comments.filter((c) => this.isUnread(c))
|
||||
case 'none':
|
||||
return this.comments // important, hides in the display, but you can still see all comments
|
||||
}
|
||||
return this.comments
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isUnread(comment) {
|
||||
return new Date(comment.updatedAt) - new Date(comment.viewedAt) > 0
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<router-link :to="`/streams/${stream.id}`">
|
||||
<preview-image
|
||||
:url="`/preview/${stream.id}`"
|
||||
:color="hover"
|
||||
:height="previewHeight"
|
||||
></preview-image>
|
||||
<stream-favorite-btn :user="user" :stream="stream" class="favorite-button" />
|
||||
|
||||
@@ -168,8 +168,12 @@ export default {
|
||||
}
|
||||
const test = prev[prev.length - 1][0]
|
||||
let action = 'split' // split | combine | skip
|
||||
|
||||
if (curr.actionType === test.actionType && curr.streamId === test.streamId) {
|
||||
if (curr.actionType.includes('stream_permissions')) {
|
||||
if (
|
||||
curr.actionType.includes('stream_permissions') ||
|
||||
curr.actionType.includes('comment_')
|
||||
) {
|
||||
//skip multiple stream_permission actions on the same user, just pick the last!
|
||||
if (
|
||||
prev[prev.length - 1].some(
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
class="absolute-pos"
|
||||
>
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
class="d-flex"
|
||||
:style="`height: 48px; width: ${$vuetify.breakpoint.xs ? '90vw' : '320px'}`"
|
||||
>
|
||||
<v-btn
|
||||
@@ -31,42 +31,65 @@
|
||||
icon
|
||||
:dark="!expand"
|
||||
:class="`mouse elevation-5 ${!expand ? 'primary' : 'background'} mr-2`"
|
||||
:loading="loading"
|
||||
@click="toggleExpand()"
|
||||
>
|
||||
<v-icon v-if="!expand" dark>mdi-plus</v-icon>
|
||||
<v-icon v-if="!expand" dark small>mdi-message</v-icon>
|
||||
<v-icon v-else dark x-small>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-slide-x-transition>
|
||||
<div
|
||||
v-if="expand && !$vuetify.breakpoint.xs"
|
||||
style="width: 100%"
|
||||
class="d-flex"
|
||||
style="width: 100%; top: -10px; position: relative"
|
||||
class=""
|
||||
>
|
||||
<v-textarea
|
||||
v-if="$loggedIn() && canComment"
|
||||
v-model="commentText"
|
||||
solo
|
||||
hide-details
|
||||
autofocus
|
||||
auto-grow
|
||||
rows="1"
|
||||
placeholder="Your comment..."
|
||||
class="mouse rounded-xl caption elevation-15"
|
||||
append-icon="mdi-send"
|
||||
@keydown.enter.exact.prevent="addComment()"
|
||||
></v-textarea>
|
||||
<v-btn
|
||||
v-if="$loggedIn() && canComment"
|
||||
v-tooltip="'Send comment (press enter)'"
|
||||
icon
|
||||
dark
|
||||
large
|
||||
class="mouse elevation-0 primary pa-0 ma-o"
|
||||
style="left: -47px; top: 1px; height: 48px; width: 48px"
|
||||
@click="addComment()"
|
||||
>
|
||||
<v-icon dark small>mdi-send</v-icon>
|
||||
</v-btn>
|
||||
<div class="d-flex">
|
||||
<v-textarea
|
||||
v-if="$loggedIn() && canComment"
|
||||
v-model="commentText"
|
||||
:disabled="loading"
|
||||
solo
|
||||
hide-details
|
||||
autofocus
|
||||
auto-grow
|
||||
rows="1"
|
||||
placeholder="Your comment..."
|
||||
class="mouse rounded-xl caption elevation-15"
|
||||
append-icon="mdi-send"
|
||||
@keydown.enter.exact.prevent="addComment()"
|
||||
></v-textarea>
|
||||
<v-btn
|
||||
v-if="$loggedIn() && canComment"
|
||||
v-tooltip="'Send comment (press enter)'"
|
||||
:disabled="loading"
|
||||
icon
|
||||
dark
|
||||
large
|
||||
class="mouse elevation-0 primary pa-0 ma-o"
|
||||
style="left: -47px; top: 1px; height: 48px; width: 48px"
|
||||
@click="addComment()"
|
||||
>
|
||||
<v-icon dark small>mdi-send</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-if="$loggedIn() && canComment" class="d-flex mt-2 mouse">
|
||||
<template v-for="reaction in $store.state.commentReactions">
|
||||
<v-btn
|
||||
:key="reaction"
|
||||
class="mr-2"
|
||||
fab
|
||||
small
|
||||
@click="addCommentDirect(reaction)"
|
||||
>
|
||||
<span
|
||||
class="text-h5"
|
||||
style="position: relative; top: 1px; left: -1px"
|
||||
>
|
||||
{{ reaction }}
|
||||
</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="!canComment && $loggedIn()"
|
||||
class="caption background px-4 py-2 rounded-xl elevation-2"
|
||||
@@ -91,7 +114,7 @@
|
||||
v-if="$vuetify.breakpoint.xs"
|
||||
v-model="expand"
|
||||
class="elevation-0 flat"
|
||||
@input="toggleExpand()"
|
||||
@click:outside="toggleExpand()"
|
||||
>
|
||||
<div
|
||||
v-if="!canComment && $loggedIn()"
|
||||
@@ -107,6 +130,7 @@
|
||||
<v-textarea
|
||||
v-model="commentText"
|
||||
solo
|
||||
:disabled="loading"
|
||||
hide-details
|
||||
autofocus
|
||||
auto-grow
|
||||
@@ -118,6 +142,7 @@
|
||||
></v-textarea>
|
||||
<v-btn
|
||||
v-tooltip="'Send comment (press enter)'"
|
||||
:disabled="loading"
|
||||
icon
|
||||
dark
|
||||
large
|
||||
@@ -139,6 +164,21 @@
|
||||
<v-icon small class="mr-1">mdi-account</v-icon>
|
||||
Sign in to comment
|
||||
</v-btn>
|
||||
<div class="my-2 d-flex justify-center" style="position: relative">
|
||||
<template v-for="reaction in $store.state.commentReactions">
|
||||
<v-btn
|
||||
:key="reaction"
|
||||
class="mr-2"
|
||||
fab
|
||||
small
|
||||
@click="addCommentDirect(reaction)"
|
||||
>
|
||||
<span class="text-h5" style="position: relative; top: 1px; left: -1px">
|
||||
{{ reaction }}
|
||||
</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</v-slide-x-transition>
|
||||
@@ -161,8 +201,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
// TODO: Need to fix the viewer package build process to be able to properly reference THREE.js
|
||||
/* global THREE */
|
||||
import * as THREE from 'three'
|
||||
import gql from 'graphql-tag'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
@@ -188,6 +227,7 @@ export default {
|
||||
stream(id: $streamId) {
|
||||
id
|
||||
role
|
||||
allowPublicComments
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -201,12 +241,13 @@ export default {
|
||||
location: null,
|
||||
expand: false,
|
||||
visible: true,
|
||||
loading: false,
|
||||
commentText: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canComment() {
|
||||
return !!this.stream?.role
|
||||
return !!this.stream?.role || this.stream?.allowPublicComments
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -225,7 +266,12 @@ export default {
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
async addCommentDirect(emoji) {
|
||||
this.commentText = emoji
|
||||
await this.addComment()
|
||||
},
|
||||
async addComment() {
|
||||
if (this.loading) return
|
||||
if (!this.commentText || this.commentText.length < 1) {
|
||||
this.$eventHub.$emit('notification', {
|
||||
text: `Comment cannot be empty.`
|
||||
@@ -263,6 +309,7 @@ export default {
|
||||
.map((res) => ({ resourceId: res, resourceType: this.$resourceType(res) }))
|
||||
)
|
||||
}
|
||||
this.loading = true
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
@@ -277,6 +324,7 @@ export default {
|
||||
text: e.message
|
||||
})
|
||||
}
|
||||
this.loading = false
|
||||
this.expand = false
|
||||
this.visible = false
|
||||
this.commentText = null
|
||||
|
||||
@@ -27,9 +27,10 @@
|
||||
<!-- Comment bubbles -->
|
||||
<div
|
||||
v-for="comment in activeComments"
|
||||
v-show="isVisible(comment)"
|
||||
:key="comment.id"
|
||||
:ref="`comment-${comment.id}`"
|
||||
:class="`absolute-pos rounded-xl no-mouse`"
|
||||
:class="`absolute-pos rounded-xl no-mouse `"
|
||||
:style="`transition: opacity 0.2s ease; z-index:${
|
||||
comment.expanded ? '20' : '10'
|
||||
}; ${
|
||||
@@ -51,6 +52,12 @@
|
||||
small
|
||||
icon
|
||||
:class="`elevation-5 pa-0 ma-0 mouse ${
|
||||
$store.state.emojis.indexOf(comment.text.split(' ')[0]) != -1 &&
|
||||
!comment.expanded
|
||||
? 'emoji-btn transparent elevation-0'
|
||||
: ''
|
||||
}
|
||||
${
|
||||
comment.expanded || comment.bouncing || isUnread(comment)
|
||||
? 'dark white--text primary'
|
||||
: 'background'
|
||||
@@ -59,7 +66,16 @@
|
||||
comment.expanded ? collapseComment(comment) : expandComment(comment)
|
||||
"
|
||||
>
|
||||
<v-icon v-if="!comment.expanded" x-small class="">mdi-comment</v-icon>
|
||||
<template
|
||||
v-if="$store.state.emojis.indexOf(comment.text.split(' ')[0]) == -1"
|
||||
>
|
||||
<v-icon v-if="!comment.expanded" x-small class="">mdi-comment</v-icon>
|
||||
</template>
|
||||
<template v-else-if="!comment.expanded">
|
||||
<span class="text-h5">
|
||||
{{ comment.text.split(' ')[0] }}
|
||||
</span>
|
||||
</template>
|
||||
<v-icon v-if="comment.expanded" x-small class="">mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-slide-x-transition>
|
||||
@@ -88,6 +104,7 @@
|
||||
<!-- Comment Threads -->
|
||||
<div
|
||||
v-for="comment in activeComments"
|
||||
v-show="isVisible(comment)"
|
||||
:key="comment.id + '-card'"
|
||||
:ref="`commentcard-${comment.id}`"
|
||||
:class="`hover-bg absolute-pos rounded-xl overflow-y-auto ${
|
||||
@@ -116,32 +133,42 @@
|
||||
<portal v-if="activeComments.length !== 0" to="comments">
|
||||
<comments-viewer-navbar
|
||||
:comments="activeComments"
|
||||
:filter="commentsFilter"
|
||||
@select-comment="
|
||||
(e) => {
|
||||
if (!e.expanded && !showComments) showComments = true
|
||||
e.expanded ? collapseComment(e) : expandComment(e)
|
||||
}
|
||||
"
|
||||
@set-filter="
|
||||
(state) => {
|
||||
commentsFilter = state
|
||||
}
|
||||
"
|
||||
/>
|
||||
</portal>
|
||||
<portal to="viewercontrols" :order="5">
|
||||
<v-btn
|
||||
key="comment-toggle-button"
|
||||
v-tooltip="`Toggle comments (${activeComments.length})`"
|
||||
v-tooltip="currentCommentVisStatus"
|
||||
rounded
|
||||
icon
|
||||
class="mr-2"
|
||||
@click="toggleComments()"
|
||||
>
|
||||
<v-icon v-if="showComments" small>mdi-comment-outline</v-icon>
|
||||
<v-icon v-if="!showComments" small>mdi-comment-off-outline</v-icon>
|
||||
<v-icon v-if="commentsFilter === 'all'" small>mdi-comment-outline</v-icon>
|
||||
<v-icon v-if="commentsFilter === 'unread'" small class="primary--text">
|
||||
mdi-comment-alert-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="commentsFilter === 'none'" small>mdi-comment-off-outline</v-icon>
|
||||
<!-- {{ commentsFilter }} -->
|
||||
<!-- <v-icon v-if="!showComments" small>mdi-comment-off-outline</v-icon> -->
|
||||
</v-btn>
|
||||
</portal>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
// TODO: Need to fix the viewer package build process to be able to properly reference THREE.js
|
||||
/* global THREE */
|
||||
import * as THREE from 'three'
|
||||
import debounce from 'lodash/debounce'
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
@@ -270,6 +297,7 @@ export default {
|
||||
return {
|
||||
localComments: [],
|
||||
showComments: true,
|
||||
commentsFilter: 'all', // 'unread', 'none'
|
||||
openCommentOnInit: null
|
||||
}
|
||||
},
|
||||
@@ -279,6 +307,17 @@ export default {
|
||||
},
|
||||
hasExpandedComment() {
|
||||
return this.localComments.filter((c) => c.expanded).length !== 0
|
||||
},
|
||||
currentCommentVisStatus() {
|
||||
switch (this.commentsFilter) {
|
||||
case 'all':
|
||||
return 'Showing all comments'
|
||||
case 'unread':
|
||||
return 'Showing unread comments only'
|
||||
case 'none':
|
||||
return 'Comments hidden'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -326,8 +365,31 @@ export default {
|
||||
isUnread(comment) {
|
||||
return new Date(comment.updatedAt) - new Date(comment.viewedAt) > 0
|
||||
},
|
||||
isVisible(comment) {
|
||||
if (comment.expanded) return true
|
||||
switch (this.commentsFilter) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'unread':
|
||||
return this.isUnread(comment)
|
||||
case 'none':
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
toggleComments() {
|
||||
this.showComments = !this.showComments
|
||||
// this.showComments = !this.showComments
|
||||
switch (this.commentsFilter) {
|
||||
case 'all':
|
||||
this.commentsFilter = 'unread'
|
||||
break
|
||||
case 'unread':
|
||||
this.commentsFilter = 'none'
|
||||
break
|
||||
case 'none':
|
||||
this.commentsFilter = 'all'
|
||||
break
|
||||
}
|
||||
},
|
||||
expandComment(comment) {
|
||||
for (const c of this.localComments) {
|
||||
@@ -503,6 +565,13 @@ export default {
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
>>> .emoji-btn {
|
||||
background-color: initial !important;
|
||||
}
|
||||
>>> .emoji-btn .v-btn__content {
|
||||
color: initial;
|
||||
}
|
||||
|
||||
.absolute-pos {
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card class="mx-2 mb-2 rounded-lg">
|
||||
<div @mouseenter="hovered = true" @mouseleave="hovered = false">
|
||||
<v-card
|
||||
class="mx-2 my-4 rounded-lg"
|
||||
:elevation="`${hovered ? 10 : 2}`"
|
||||
style="transition: all 0.2s ease"
|
||||
>
|
||||
<v-toolbar
|
||||
v-ripple
|
||||
class="transparent"
|
||||
@@ -41,20 +45,23 @@
|
||||
mdi-filter
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-btn small icon @click.stop="expanded = !expanded">
|
||||
<v-icon x-small>{{ expanded ? 'mdi-minus' : 'mdi-plus' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<div class="caption my-2 px-2 pb-2">
|
||||
<div
|
||||
class="caption my-2 px-2 pb-2"
|
||||
style="cursor: pointer"
|
||||
@click.stop="expanded = !expanded"
|
||||
>
|
||||
{{ commit.message }}
|
||||
<v-divider class="my-2" />
|
||||
<timeago :datetime="commit.createdAt"></timeago>
|
||||
,
|
||||
{{ new Date(commit.createdAt).toLocaleString() }}
|
||||
<v-btn block depressed x-small class="mt-4" @click.stop="expanded = !expanded">
|
||||
{{ expanded ? 'Hide' : 'Expand' }} Data View
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-expand-transition>
|
||||
<div v-show="expanded" class="px-1 pb-2">
|
||||
<v-divider class="mx-2 my-2" />
|
||||
<object-properties
|
||||
:obj="{
|
||||
referencedId:
|
||||
@@ -84,7 +91,8 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false
|
||||
expanded: false,
|
||||
hovered: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
},
|
||||
props: {
|
||||
obj: {
|
||||
type: Object,
|
||||
type: [Object, Array],
|
||||
default: () => null
|
||||
},
|
||||
streamId: {
|
||||
@@ -106,8 +106,8 @@ export default {
|
||||
generateKVPs() {
|
||||
for (const key of Object.keys(this.realObject)) {
|
||||
if (this.ignoredProps.indexOf(key) !== -1) continue
|
||||
const value = this.realObject[key]
|
||||
const type = Array.isArray(this.realObject[key])
|
||||
let value = this.realObject[key]
|
||||
let type = Array.isArray(this.realObject[key])
|
||||
? 'array'
|
||||
: typeof this.realObject[key]
|
||||
const extras = []
|
||||
@@ -120,6 +120,13 @@ export default {
|
||||
)
|
||||
extras.push('visibility')
|
||||
|
||||
// handle undefined as well as null 'values'
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (value == null) {
|
||||
value = 'null'
|
||||
type = 'null'
|
||||
}
|
||||
|
||||
this.kvps.push({
|
||||
key: this.cleanKey(key),
|
||||
originalKey: key,
|
||||
|
||||
@@ -131,11 +131,11 @@ export default {
|
||||
},
|
||||
props: {
|
||||
prop: {
|
||||
type: Object,
|
||||
type: [Object, Array],
|
||||
default: () => null
|
||||
},
|
||||
parent: {
|
||||
type: Object,
|
||||
type: [Object, Array],
|
||||
default: () => null
|
||||
},
|
||||
refId: {
|
||||
|
||||
@@ -87,8 +87,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
// TODO: Need to fix the viewer package build process to be able to properly reference THREE.js
|
||||
/* global THREE */
|
||||
import * as THREE from 'three'
|
||||
import gql from 'graphql-tag'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
@@ -18,14 +18,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="text-right flex-grow-1">
|
||||
<v-btn
|
||||
v-tooltip="'TODO: Add by object id'"
|
||||
dark
|
||||
small
|
||||
icon
|
||||
class="ml-2"
|
||||
@click="showObjectDialog = true"
|
||||
>
|
||||
<v-btn dark small icon class="ml-2" @click="showObjectDialog = true">
|
||||
<v-icon small>mdi-cube-outline</v-icon>
|
||||
</v-btn>
|
||||
<v-btn dark icon class="ml-2" @click="$emit('close')">
|
||||
|
||||
@@ -138,16 +138,16 @@
|
||||
<viewer-controls @show-add-overlay="showAddOverlay = true" />
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
:style="`
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
top: -64px;
|
||||
${!$vuetify.breakpoint.smAndDown ? 'top: -64px;' : 'top: -56px;'}
|
||||
left: 0;
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
overflow: none;
|
||||
"
|
||||
`"
|
||||
class=""
|
||||
>
|
||||
<viewer-bubbles key="a" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-container class="pa-0">
|
||||
<portal to="toolbar">
|
||||
<div class="d-flex align-center">
|
||||
<div v-if="stream" class="d-flex align-center">
|
||||
<div class="text-truncate">
|
||||
<router-link
|
||||
v-tooltip="stream.name"
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</portal>
|
||||
<v-row>
|
||||
<v-row v-if="stream">
|
||||
<v-col v-if="stream.role !== 'stream:owner'" cols="12">
|
||||
<v-alert type="warning">
|
||||
Your permission level ({{ stream.role }}) is not high enough to edit this
|
||||
@@ -34,6 +34,7 @@
|
||||
</template>
|
||||
<v-card-text>
|
||||
<v-form ref="form" v-model="valid" class="px-2" @submit.prevent="save">
|
||||
<h2>Name and description</h2>
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
:rules="validation.nameRules"
|
||||
@@ -49,7 +50,7 @@
|
||||
class="mt-5"
|
||||
:disabled="stream.role !== 'stream:owner'"
|
||||
/>
|
||||
|
||||
<h2>Privacy</h2>
|
||||
<v-switch
|
||||
v-model="isPublic"
|
||||
inset
|
||||
@@ -63,6 +64,25 @@
|
||||
persistent-hint
|
||||
:disabled="stream.role !== 'stream:owner'"
|
||||
/>
|
||||
<br />
|
||||
<h2>Comments</h2>
|
||||
<v-switch
|
||||
v-model="allowPublicComments"
|
||||
inset
|
||||
class="mt-5"
|
||||
:label="
|
||||
allowPublicComments
|
||||
? 'Anyone can comment'
|
||||
: 'Only collaborators can comment'
|
||||
"
|
||||
:hint="
|
||||
allowPublicComments
|
||||
? 'Any signed in user can leave a comment; the stream needs to be public.'
|
||||
: 'Only collaborators can comment.'
|
||||
"
|
||||
persistent-hint
|
||||
:disabled="stream.role !== 'stream:owner'"
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
@@ -71,6 +91,7 @@
|
||||
color="primary"
|
||||
type="submit"
|
||||
:disabled="!canSave"
|
||||
block
|
||||
@click="save"
|
||||
>
|
||||
Save Changes
|
||||
@@ -79,7 +100,7 @@
|
||||
</section-card>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<section-card expandable :expand="false">
|
||||
<section-card :expand="true">
|
||||
<template #header>Danger Zone</template>
|
||||
|
||||
<v-card-text class="d-flex align-center">
|
||||
@@ -169,6 +190,7 @@ export default {
|
||||
name
|
||||
description
|
||||
isPublic
|
||||
allowPublicComments
|
||||
role
|
||||
}
|
||||
}
|
||||
@@ -185,7 +207,8 @@ export default {
|
||||
({
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
isPublic: this.isPublic
|
||||
isPublic: this.isPublic,
|
||||
allowPublicComments: this.allowPublicComments
|
||||
} = stream)
|
||||
|
||||
return stream
|
||||
@@ -202,6 +225,7 @@ export default {
|
||||
streamNameConfirm: '',
|
||||
description: null,
|
||||
isPublic: true,
|
||||
allowPublicComments: true,
|
||||
validation: {
|
||||
nameRules: [(v) => !!v || 'A stream must have a name!']
|
||||
}
|
||||
@@ -213,11 +237,19 @@ export default {
|
||||
this.valid &&
|
||||
(this.name !== this.stream.name ||
|
||||
this.description !== this.stream.description ||
|
||||
this.isPublic !== this.stream.isPublic)
|
||||
this.isPublic !== this.stream.isPublic ||
|
||||
this.allowPublicComments !== this.stream.allowPublicComments)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
allowPublicComments(newVal) {
|
||||
if (newVal && !this.isPublic) this.isPublic = true
|
||||
},
|
||||
isPublic(newVal) {
|
||||
if (!newVal && this.allowPublicComments) this.allowPublicComments = false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async save() {
|
||||
this.loading = true
|
||||
@@ -234,7 +266,8 @@ export default {
|
||||
id: this.stream.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
isPublic: this.isPublic
|
||||
isPublic: this.isPublic,
|
||||
allowPublicComments: this.allowPublicComments
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
import emojis from './emojis'
|
||||
Vue.use(Vuex)
|
||||
|
||||
// Note: this is currently used only for 3d viewer filtering purposes. All other state
|
||||
@@ -21,7 +21,9 @@ const store = new Vuex.Store({
|
||||
hideCategoryValues: [],
|
||||
selectedComment: null,
|
||||
addingComment: false,
|
||||
preventCommentCollapse: false
|
||||
preventCommentCollapse: false,
|
||||
commentReactions: ['❤️', '✏️', '🔥', '📍', '😲'],
|
||||
emojis
|
||||
},
|
||||
mutations: {
|
||||
setViewerBusy(state, { viewerBusyState }) {
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
{{ stream.role.split(':')[1] }}
|
||||
</v-chip>
|
||||
<span
|
||||
v-tooltip="
|
||||
`Last updated: ${new Date(stream.updatedAt).toLocaleString()}<br>
|
||||
Commits: ${stream.commits.totalCount} <br>
|
||||
Branches: ${stream.branches.totalCount}`
|
||||
"
|
||||
v-tooltip="{
|
||||
html: true,
|
||||
content: `Last updated: ${new Date(stream.updatedAt).toLocaleString()}<br>
|
||||
Commits: ${stream.commits.totalCount} <br>
|
||||
Branches: ${stream.branches.totalCount}`
|
||||
}"
|
||||
class="caption mx-1"
|
||||
>
|
||||
Updated
|
||||
|
||||
Generated
-7303
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@speckle/objectloader",
|
||||
"version": "2.4.2",
|
||||
"version": "2.5.4",
|
||||
"description": "Simple API helper to stream in objects from the Speckle Server.",
|
||||
"main": "dist/objectloader.js",
|
||||
"module": "dist/objectloader.esm.js",
|
||||
@@ -17,6 +17,7 @@
|
||||
"lint": "eslint . --ext .js,.ts",
|
||||
"build:dev": "rollup --config",
|
||||
"build": "NODE_ENV=production rollup --config",
|
||||
"prepack": "yarn build",
|
||||
"dev": "rollup --config --watch",
|
||||
"example": "EXAMPLE_BUILD=1 rollup --config && http-server ./examples/browser -p 3031 -o"
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ This is a small utility class that helps you stream an object and all its sub-co
|
||||
|
||||
### Examples
|
||||
|
||||
If you've got this repo checked out locally, you can run `npm run example` to run an example web page running ObjectLoader in the browser at 'http://127.0.0.1:3031/'. This will run the example HTML found under ./examples/browser/'.
|
||||
If you've got this repo checked out locally, you can run `yarn example` to run an example web page running ObjectLoader in the browser at 'http://127.0.0.1:3031/'. This will run the example HTML found under ./examples/browser/'.
|
||||
|
||||
To test ObjectLoader in a node environment, just run `node ./examples/node/script.mjs`
|
||||
|
||||
@@ -80,7 +80,8 @@ let loader = new ObjectLoader({
|
||||
|
||||
## Development
|
||||
|
||||
Run `npm run build` to build prod release, run `npm run build:dev` to build dev release. Or run `npm run dev` to run the build in `watch` mode.
|
||||
Run `yarn build` to build prod release, run `yarn build:dev` to build dev release.
|
||||
Or run `yarn dev` to run the build in `watch` mode.
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
@@ -2,41 +2,34 @@
|
||||
|
||||
|
||||
# build stage
|
||||
FROM node:14.16-buster-slim as deps-build
|
||||
FROM node:14.16-buster-slim as build-stage
|
||||
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
WORKDIR /opt/objectloader
|
||||
COPY packages/objectloader/package*.json ./
|
||||
RUN npm install --production=false
|
||||
COPY packages/objectloader .
|
||||
RUN npm run build
|
||||
# this whole thing is required cause npm 6.* doesn't allow for pack output dir specification
|
||||
RUN mkdir /packages
|
||||
# invoke npm pack and move its result to the packages folder when done
|
||||
RUN mv $(npm pack) /packages/
|
||||
|
||||
WORKDIR /opt/viewer
|
||||
COPY packages/viewer/package*.json ./
|
||||
# this installs objectloader from a tarball
|
||||
RUN npm i $(find /packages -type f -name "speckle*.tgz")
|
||||
# Install dependencies and devDependencies
|
||||
RUN npm install --production=false
|
||||
COPY packages/viewer .
|
||||
RUN npm run build
|
||||
RUN mv $(npm pack) /packages/
|
||||
WORKDIR /speckle-server
|
||||
COPY .yarnrc.yml .
|
||||
COPY .yarn ./.yarn
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Onyl copy in the relevant package.json files for the dependencies
|
||||
COPY packages/preview-service/package.json ./packages/preview-service/
|
||||
COPY packages/viewer/package.json ./packages/viewer/
|
||||
COPY packages/objectloader/package.json ./packages/objectloader/
|
||||
|
||||
RUN yarn workspaces focus -A
|
||||
RUN yarn
|
||||
|
||||
# Onyl copy in the relevant source files for the dependencies
|
||||
COPY packages/objectloader ./packages/objectloader/
|
||||
COPY packages/viewer ./packages/viewer/
|
||||
COPY packages/preview-service ./packages/preview-service/
|
||||
|
||||
# This way the foreach only builds the frontend and its deps
|
||||
RUN yarn workspaces foreach -pt run build
|
||||
|
||||
FROM node:14.16-buster-slim as build-stage
|
||||
|
||||
WORKDIR /opt/preview-service
|
||||
COPY --from=deps-build /packages /packages
|
||||
COPY packages/preview-service/package*.json ./
|
||||
# this installs viewer and objectloader from a tarball
|
||||
RUN npm i $(find /packages -type f -name "speckle*.tgz")
|
||||
RUN npm ci --production=false
|
||||
COPY packages/preview-service .
|
||||
RUN npm run build-fe
|
||||
|
||||
FROM node:14.16-buster-slim as node
|
||||
|
||||
@@ -55,9 +48,20 @@ RUN chmod +x /wait
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
COPY --from=build-stage /opt/preview-service /opt/preview-service
|
||||
WORKDIR /speckle-server
|
||||
COPY .yarnrc.yml .
|
||||
COPY .yarn ./.yarn
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
WORKDIR /opt/preview-service
|
||||
RUN yarn plugin import workspace-tools
|
||||
|
||||
# Onyl copy in the relevant package.json files for the dependencies
|
||||
COPY packages/preview-service/package.json ./packages/preview-service/
|
||||
|
||||
WORKDIR /speckle-server/packages/preview-service
|
||||
|
||||
RUN yarn workspaces focus --production
|
||||
COPY --from=build-stage /speckle-server/packages/preview-service ./
|
||||
|
||||
ENTRYPOINT [ "tini", "--" ]
|
||||
CMD ["node", "bin/www"]
|
||||
CMD ["yarn", "node", "bin/www"]
|
||||
|
||||
@@ -10,10 +10,6 @@ const indexRouter = require('./routes/index')
|
||||
const previewRouter = require('./routes/preview')
|
||||
const objectsRouter = require('./routes/objects')
|
||||
const apiRouter = require('./routes/api')
|
||||
const prometheusClient = require('prom-client')
|
||||
|
||||
prometheusClient.register.clear()
|
||||
prometheusClient.collectDefaultMetrics()
|
||||
|
||||
const app = express()
|
||||
|
||||
@@ -29,16 +25,6 @@ app.use('/preview', previewRouter)
|
||||
app.use('/objects', objectsRouter)
|
||||
app.use('/api', apiRouter)
|
||||
|
||||
// Expose prometheus metrics
|
||||
app.get('/metrics', async (req, res) => {
|
||||
try {
|
||||
res.set('Content-Type', prometheusClient.register.contentType)
|
||||
res.end(await prometheusClient.register.metrics())
|
||||
} catch (ex) {
|
||||
res.status(500).end(ex.message)
|
||||
}
|
||||
})
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use(function (req, res, next) {
|
||||
next(createError(404))
|
||||
|
||||
@@ -4,6 +4,7 @@ const crypto = require('crypto')
|
||||
const knex = require('../knex')
|
||||
const fetch = require('node-fetch')
|
||||
const fs = require('fs')
|
||||
const metrics = require('./prometheusMetrics')
|
||||
|
||||
let shouldExit = false
|
||||
|
||||
@@ -78,6 +79,7 @@ async function doTask(task) {
|
||||
`,
|
||||
[{}, task.streamId, task.objectId]
|
||||
)
|
||||
metrics.metricOperationErrors.labels('preview').inc()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +98,16 @@ async function tick() {
|
||||
return
|
||||
}
|
||||
|
||||
const metricDurationEnd = metrics.metricDuration.startTimer()
|
||||
|
||||
await doTask(task)
|
||||
|
||||
metricDurationEnd({ op: 'preview' })
|
||||
|
||||
// Check for another task very soon
|
||||
setTimeout(tick, 10)
|
||||
} catch (err) {
|
||||
metrics.metricOperationErrors.labels('main_loop').inc()
|
||||
console.log('Error executing task: ', err)
|
||||
setTimeout(tick, 5000)
|
||||
}
|
||||
@@ -119,6 +126,8 @@ async function startPreviewService() {
|
||||
console.log('Shutting down...')
|
||||
})
|
||||
|
||||
metrics.initPrometheusMetrics()
|
||||
|
||||
tick()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
'use strict'
|
||||
|
||||
const http = require('http')
|
||||
const prometheusClient = require('prom-client')
|
||||
const knex = require('../knex')
|
||||
|
||||
let metricFree = null
|
||||
let metricUsed = null
|
||||
let metricPendingAquires = null
|
||||
let metricQueryDuration = null
|
||||
let metricQueryErrors = null
|
||||
|
||||
const queryStartTime = {}
|
||||
prometheusClient.register.clear()
|
||||
prometheusClient.register.setDefaultLabels({
|
||||
project: 'speckle-server',
|
||||
app: 'preview-service'
|
||||
})
|
||||
prometheusClient.collectDefaultMetrics()
|
||||
|
||||
let prometheusInitialized = false
|
||||
|
||||
function initKnexPrometheusMetrics() {
|
||||
metricFree = new prometheusClient.Gauge({
|
||||
name: 'speckle_server_knex_free',
|
||||
help: 'Number of free DB connections',
|
||||
collect() {
|
||||
this.set(knex.client.pool.numFree())
|
||||
}
|
||||
})
|
||||
|
||||
metricUsed = new prometheusClient.Gauge({
|
||||
name: 'speckle_server_knex_used',
|
||||
help: 'Number of used DB connections',
|
||||
collect() {
|
||||
this.set(knex.client.pool.numUsed())
|
||||
}
|
||||
})
|
||||
|
||||
metricPendingAquires = new prometheusClient.Gauge({
|
||||
name: 'speckle_server_knex_pending',
|
||||
help: 'Number of pending DB connection aquires',
|
||||
collect() {
|
||||
this.set(knex.client.pool.numPendingAcquires())
|
||||
}
|
||||
})
|
||||
|
||||
metricQueryDuration = new prometheusClient.Summary({
|
||||
name: 'speckle_server_knex_query_duration',
|
||||
help: 'Summary of the DB query durations in seconds'
|
||||
})
|
||||
|
||||
metricQueryErrors = new prometheusClient.Counter({
|
||||
name: 'speckle_server_knex_query_errors',
|
||||
help: 'Number of DB queries with errors'
|
||||
})
|
||||
|
||||
knex.on('query', (data) => {
|
||||
const queryId = data.__knexQueryUid + ''
|
||||
queryStartTime[queryId] = Date.now()
|
||||
})
|
||||
|
||||
knex.on('query-response', (data, obj, builder) => {
|
||||
const queryId = obj.__knexQueryUid + ''
|
||||
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
|
||||
delete queryStartTime[queryId]
|
||||
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
|
||||
})
|
||||
|
||||
knex.on('query-error', (err, querySpec) => {
|
||||
const queryId = querySpec.__knexQueryUid + ''
|
||||
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
|
||||
delete queryStartTime[queryId]
|
||||
|
||||
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
|
||||
metricQueryErrors.inc()
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initPrometheusMetrics() {
|
||||
if (prometheusInitialized) return
|
||||
prometheusInitialized = true
|
||||
|
||||
initKnexPrometheusMetrics()
|
||||
|
||||
// Define the HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/metrics') {
|
||||
res.setHeader('Content-Type', prometheusClient.register.contentType)
|
||||
res.end(await prometheusClient.register.metrics())
|
||||
} else {
|
||||
res.end('Speckle Preview Service - prometheus metrics')
|
||||
}
|
||||
})
|
||||
server.listen(Number(process.env.PROMETHEUS_METRICS_PORT) || 9094)
|
||||
},
|
||||
|
||||
metricDuration: new prometheusClient.Histogram({
|
||||
name: 'speckle_server_operation_duration',
|
||||
help: 'Summary of the operation durations in seconds',
|
||||
buckets: [0.5, 1, 5, 10, 30, 60, 300, 600],
|
||||
labelNames: ['op']
|
||||
}),
|
||||
|
||||
metricOperationErrors: new prometheusClient.Counter({
|
||||
name: 'speckle_server_operation_errors',
|
||||
help: 'Number of operations with errors',
|
||||
labelNames: ['op']
|
||||
})
|
||||
}
|
||||
-13547
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@speckle/preview-service",
|
||||
"private": true,
|
||||
"version": "2.4.2",
|
||||
"version": "2.5.4",
|
||||
"description": "Generate PNG previews of Speckle objects by using a headless viewer",
|
||||
"main": "index.js",
|
||||
"homepage": "https://speckle.systems",
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "DEBUG='preview-service:*' nodemon --trace-deprecation ./bin/www",
|
||||
"build-fe": "webpack --env dev --config webpack.config.render_page.js && webpack --env build --config webpack.config.render_page.js",
|
||||
"build": "webpack --env dev --config webpack.config.render_page.js && webpack --env build --config webpack.config.render_page.js",
|
||||
"lint": "eslint . --ext .js,.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -13,7 +13,7 @@ This is an overview of this service:
|
||||
With an updated viewer installed in the current directory, you should first build the frontend-part of the preview service: The simple webpage with the viewer that will be accessed with Puppeteer to generate the preview:
|
||||
|
||||
```
|
||||
npm run build-fe
|
||||
yarn build
|
||||
```
|
||||
|
||||
This should be rerun whenever you make changes to the viewer (if you make local viewer changes, don't forget to build the viewer module before running this)
|
||||
@@ -21,7 +21,7 @@ This should be rerun whenever you make changes to the viewer (if you make local
|
||||
After the viewer web page is up to date, run the preview service with:
|
||||
|
||||
```
|
||||
npm run dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
This will use the default dev DB connection of `postgres://speckle:speckle@localhost/speckle`. You can pass the environment variable `PG_CONNECTION_STRING` to change this to a different DB.
|
||||
|
||||
@@ -45,7 +45,11 @@ const config = {
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
modules: [path.resolve('./node_modules'), path.resolve('.render_page/src')],
|
||||
modules: [
|
||||
path.resolve('../../node_modules'),
|
||||
path.resolve('./node_modules'),
|
||||
path.resolve('.render_page/src')
|
||||
],
|
||||
extensions: ['.json', '.js']
|
||||
},
|
||||
devServer: {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
FROM node:16.13-bullseye-slim as node
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
ARG SPECKLE_SERVER_VERSION=custom
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
tini \
|
||||
@@ -8,16 +11,17 @@ RUN apt-get update && apt-get install -y \
|
||||
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait
|
||||
RUN chmod +x /wait
|
||||
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
WORKDIR /speckle-server
|
||||
|
||||
ARG SPECKLE_SERVER_VERSION=custom
|
||||
ENV SPECKLE_SERVER_VERSION=${SPECKLE_SERVER_VERSION}
|
||||
WORKDIR /app
|
||||
COPY .yarnrc.yml .
|
||||
COPY .yarn ./.yarn
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
COPY packages/server/package*.json ./
|
||||
RUN npm ci
|
||||
WORKDIR /speckle-server/packages/server
|
||||
COPY packages/server/package.json .
|
||||
RUN yarn workspaces focus --production
|
||||
|
||||
COPY packages/server .
|
||||
|
||||
CMD ["node", "bin/www"]
|
||||
ENV SPECKLE_SERVER_VERSION=${SPECKLE_SERVER_VERSION}
|
||||
CMD ["yarn", "node", "bin/www"]
|
||||
|
||||
@@ -7,15 +7,14 @@ const express = require('express')
|
||||
// `express-async-errors` patches express to catch errors in async handlers. no variable needed
|
||||
require('express-async-errors')
|
||||
const compression = require('compression')
|
||||
const appRoot = require('app-root-path')
|
||||
const logger = require('morgan-debug')
|
||||
const bodyParser = require('body-parser')
|
||||
const debug = require('debug')
|
||||
const { createTerminus } = require('@godaddy/terminus')
|
||||
|
||||
const Sentry = require('@sentry/node')
|
||||
const Logging = require(`${appRoot}/logging`)
|
||||
const { errorLoggingMiddleware } = require(`${appRoot}/logging/errorLogging`)
|
||||
const Logging = require('@/logging')
|
||||
const { errorLoggingMiddleware } = require('@/logging/errorLogging')
|
||||
const prometheusClient = require('prom-client')
|
||||
|
||||
const { ApolloServer, ForbiddenError } = require('apollo-server-express')
|
||||
|
||||
Vendored
+5
-1
@@ -5,11 +5,11 @@
|
||||
|
||||
// Initializing module aliases for absolute import paths
|
||||
require('module-alias')({ base: __dirname })
|
||||
const appRoot = __dirname
|
||||
|
||||
// Initializing env vars
|
||||
const dotenv = require('dotenv')
|
||||
const { isTestEnv } = require('./modules/core/helpers/envHelper')
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
// If running in test env, load .env.test first
|
||||
// (appRoot necessary, cause env files aren't loaded through require() calls)
|
||||
@@ -25,3 +25,7 @@ if (isTestEnv()) {
|
||||
}
|
||||
|
||||
dotenv.config({ path: `${appRoot}/.env` })
|
||||
|
||||
module.exports = {
|
||||
appRoot
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ module.exports = {
|
||||
labelNames: ['route']
|
||||
})
|
||||
}
|
||||
|
||||
return responseTime(function (req, res, time) {
|
||||
let route = 'unknown'
|
||||
if (req.originalUrl === '/graphql') route = '/graphql'
|
||||
if (req.route && req.route.path) route = req.route.path
|
||||
metricRequestDuration.labels(route).observe(time / 1000)
|
||||
})
|
||||
|
||||
@@ -15,7 +15,12 @@ module.exports = function (app) {
|
||||
if (!prometheusInitialized) {
|
||||
prometheusInitialized = true
|
||||
prometheusClient.register.clear()
|
||||
prometheusClient.register.setDefaultLabels({
|
||||
project: 'speckle-server',
|
||||
app: 'server'
|
||||
})
|
||||
prometheusClient.collectDefaultMetrics()
|
||||
|
||||
initKnexPrometheusMetrics()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const appRoot = require('app-root-path')
|
||||
const knex = require(`${appRoot}/db/knex`)
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
const { dispatchStreamEvent } = require('../../webhooks/services/webhooks')
|
||||
const StreamActivity = () => knex('stream_activity')
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/* istanbul ignore file */
|
||||
const expect = require('chai').expect
|
||||
|
||||
const appRoot = require('app-root-path')
|
||||
const { createUser } = require('../../core/services/users')
|
||||
const { createPersonalAccessToken } = require('../../core/services/tokens')
|
||||
const { createObject } = require('../../core/services/objects')
|
||||
const { getUserActivity } = require('../services')
|
||||
|
||||
const { beforeEachContext, initializeTestServer } = require(`${appRoot}/test/hooks`)
|
||||
const { noErrors } = require(`${appRoot}/test/helpers`)
|
||||
const { beforeEachContext, initializeTestServer } = require('@/test/hooks')
|
||||
const { noErrors } = require('@/test/helpers')
|
||||
|
||||
let sendRequest
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/* istanbul ignore file */
|
||||
'use strict'
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
exports.init = (app) => {
|
||||
debug('speckle:modules')('💅 Init graphql api explorer module')
|
||||
|
||||
// sweet and simple
|
||||
app.get('/explorer', (req, res) => {
|
||||
res.sendFile(`${appRoot}/modules/apiexplorer/explorer.html`)
|
||||
res.sendFile(require.resolve('@/modules/apiexplorer/explorer.html'))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use strict'
|
||||
const appRoot = require('app-root-path')
|
||||
const knex = require(`${appRoot}/db/knex`)
|
||||
const debug = require('debug')
|
||||
const knex = require('@/db/knex')
|
||||
const Scopes = () => knex('scopes')
|
||||
const Apps = () => knex('server_apps')
|
||||
const AppScopes = () => knex('server_apps_scopes')
|
||||
|
||||
const { getApp, revokeExistingAppCredentials } = require('./services/apps')
|
||||
const { getApp } = require('@/modules/auth/services/apps')
|
||||
const { Scopes: ScopesConst } = require('@/modules/core/helpers/mainConstants')
|
||||
const { difference } = require('lodash')
|
||||
|
||||
let allScopes = []
|
||||
|
||||
@@ -47,25 +49,61 @@ async function registerDefaultApp(app) {
|
||||
}
|
||||
|
||||
async function updateDefaultApp(app, existingApp) {
|
||||
existingApp.scopes = existingApp.scopes.map((s) => s.name)
|
||||
const existingAppScopes = existingApp.scopes.map((s) => s.name)
|
||||
|
||||
const scopeDiffA = app.scopes.filter(
|
||||
(scope) => existingApp.scopes.indexOf(scope) === -1
|
||||
)
|
||||
const scopeDiffB = existingApp.scopes.filter(
|
||||
(scope) => app.scopes.indexOf(scope) === -1
|
||||
)
|
||||
const newScopes = difference(app.scopes, existingAppScopes)
|
||||
const removedScopes = difference(existingAppScopes, app.scopes)
|
||||
|
||||
if (scopeDiffA.length !== 0 || scopeDiffB.length !== 0) {
|
||||
await revokeExistingAppCredentials({ appId: app.id })
|
||||
const scopes = app.scopes.map((s) => ({ appId: app.id, scopeName: s }))
|
||||
await AppScopes().insert(scopes)
|
||||
let affectedTokenIds = []
|
||||
|
||||
if (newScopes.length || removedScopes.length) {
|
||||
debug('speckle:modules')(`🔑 Updating default app ${app.name}`)
|
||||
affectedTokenIds = await knex('user_server_app_tokens')
|
||||
.where({ appId: app.id })
|
||||
.pluck('tokenId')
|
||||
}
|
||||
|
||||
delete app.scopes
|
||||
await Apps().where({ id: app.id }).update(app)
|
||||
// the internal code block makes sure if an error occurred, the trx gets rolled back
|
||||
await knex.transaction(async (trx) => {
|
||||
// add new scopes to the app
|
||||
if (newScopes.length)
|
||||
await AppScopes()
|
||||
.insert(newScopes.map((s) => ({ appId: app.id, scopeName: s })))
|
||||
.transacting(trx)
|
||||
|
||||
// remove scopes from the app
|
||||
if (removedScopes.length)
|
||||
await AppScopes()
|
||||
.where({ appId: app.id })
|
||||
.whereIn('scopeName', removedScopes)
|
||||
.delete()
|
||||
.transacting(trx)
|
||||
|
||||
//update user tokens with scope changes
|
||||
if (affectedTokenIds.length)
|
||||
await Promise.all(
|
||||
affectedTokenIds.map(async (tokenId) => {
|
||||
if (newScopes.length)
|
||||
await knex('token_scopes')
|
||||
.insert(newScopes.map((s) => ({ tokenId, scopeName: s })))
|
||||
.transacting(trx)
|
||||
|
||||
if (removedScopes.length)
|
||||
await knex('token_scopes')
|
||||
.where({ tokenId })
|
||||
.whereIn('scopeName', removedScopes)
|
||||
.delete()
|
||||
.transacting(trx)
|
||||
})
|
||||
)
|
||||
delete app.scopes
|
||||
await Apps().where({ id: app.id }).update(app).transacting(trx)
|
||||
})
|
||||
}
|
||||
|
||||
// this is exported to be able to test the retention of permissions
|
||||
module.exports.updateDefaultApp = updateDefaultApp
|
||||
|
||||
const SpeckleWebApp = {
|
||||
id: 'spklwebapp',
|
||||
secret: 'spklwebapp',
|
||||
@@ -99,11 +137,12 @@ const SpeckleDesktopApp = {
|
||||
public: true,
|
||||
redirectUrl: 'speckle://account',
|
||||
scopes: [
|
||||
'streams:read',
|
||||
'streams:write',
|
||||
'profile:read',
|
||||
'profile:email',
|
||||
'users:read'
|
||||
ScopesConst.Streams.Read,
|
||||
ScopesConst.Streams.Write,
|
||||
ScopesConst.Profile.Read,
|
||||
ScopesConst.Profile.Email,
|
||||
ScopesConst.Users.Read,
|
||||
ScopesConst.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
@@ -116,11 +155,12 @@ const SpeckleConnectorApp = {
|
||||
public: true,
|
||||
redirectUrl: 'http://localhost:29363',
|
||||
scopes: [
|
||||
'streams:read',
|
||||
'streams:write',
|
||||
'profile:read',
|
||||
'profile:email',
|
||||
'users:read'
|
||||
ScopesConst.Streams.Read,
|
||||
ScopesConst.Streams.Write,
|
||||
ScopesConst.Profile.Read,
|
||||
ScopesConst.Profile.Email,
|
||||
ScopesConst.Users.Read,
|
||||
ScopesConst.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
@@ -134,10 +174,11 @@ const SpeckleExcel = {
|
||||
public: true,
|
||||
redirectUrl: 'https://speckle-excel.netlify.app',
|
||||
scopes: [
|
||||
'streams:read',
|
||||
'streams:write',
|
||||
'profile:read',
|
||||
'profile:email',
|
||||
'users:read'
|
||||
ScopesConst.Streams.Read,
|
||||
ScopesConst.Streams.Write,
|
||||
ScopesConst.Profile.Read,
|
||||
ScopesConst.Profile.Email,
|
||||
ScopesConst.Users.Read,
|
||||
ScopesConst.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
@@ -55,8 +55,10 @@ module.exports = {
|
||||
|
||||
async appUpdate(parent, args, context) {
|
||||
const app = await getApp({ id: args.app.id })
|
||||
// only admins can update the default apps, generated by the server
|
||||
if (!app.author && context.role !== 'server:admin')
|
||||
throw new ForbiddenError('You are not authorized to edit this app.')
|
||||
// only the author or an admin can update a 3rd party app
|
||||
if (app.author.id !== context.userId && context.role !== 'server:admin')
|
||||
throw new ForbiddenError('You are not authorized to edit this app.')
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict'
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
const { registerOrUpdateScope } = require(`${appRoot}/modules/shared`)
|
||||
const { registerOrUpdateScope } = require('@/modules/shared')
|
||||
|
||||
exports.init = async (app) => {
|
||||
debug('speckle:modules')('🔑 Init auth module')
|
||||
|
||||
@@ -39,6 +39,7 @@ module.exports = {
|
||||
'server_apps.id',
|
||||
'server_apps.name',
|
||||
'server_apps.description',
|
||||
'server_apps.trustByDefault',
|
||||
'server_apps.logo',
|
||||
'server_apps.termsAndConditionsLink',
|
||||
'users.name as authorName',
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use strict'
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
const redis = require('redis')
|
||||
const ExpressSession = require('express-session')
|
||||
const RedisStore = require('connect-redis')(ExpressSession)
|
||||
const passport = require('passport')
|
||||
|
||||
const sentry = require(`${appRoot}/logging/sentryHelper`)
|
||||
const sentry = require('@/logging/sentryHelper')
|
||||
const { createAuthorizationCode } = require('./services/apps')
|
||||
|
||||
module.exports = async (app) => {
|
||||
@@ -51,6 +50,16 @@ module.exports = async (app) => {
|
||||
userId: req.user.id,
|
||||
challenge: req.session.challenge
|
||||
})
|
||||
// const defaultApps = ['explorer', 'sdm', 'sca', 'spklexcel']
|
||||
// await Promise.all(
|
||||
// defaultApps.map((appId) =>
|
||||
// createAuthorizationCode({
|
||||
// appId,
|
||||
// userId: req.user.id,
|
||||
// challenge: req.session.challenge
|
||||
// })
|
||||
// )
|
||||
// )
|
||||
if (req.session) req.session.destroy()
|
||||
return res.redirect(`${process.env.CANONICAL_URL}?access_code=${ac}`)
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,16 +5,9 @@ const passport = require('passport')
|
||||
const OIDCStrategy = require('passport-azure-ad').OIDCStrategy
|
||||
const URL = require('url').URL
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
const {
|
||||
findOrCreateUser,
|
||||
getUserByEmail
|
||||
} = require(`${appRoot}/modules/core/services/users`)
|
||||
const { getServerInfo } = require(`${appRoot}/modules/core/services/generic`)
|
||||
const {
|
||||
validateInvite,
|
||||
useInvite
|
||||
} = require(`${appRoot}/modules/serverinvites/services`)
|
||||
const { findOrCreateUser, getUserByEmail } = require('@/modules/core/services/users')
|
||||
const { getServerInfo } = require('@/modules/core/services/generic')
|
||||
const { validateInvite, useInvite } = require('@/modules/serverinvites/services')
|
||||
|
||||
module.exports = async (app, session, sessionStorage, finalizeAuth) => {
|
||||
const strategy = new OIDCStrategy(
|
||||
|
||||
@@ -5,16 +5,9 @@ const passport = require('passport')
|
||||
const GithubStrategy = require('passport-github2')
|
||||
const URL = require('url').URL
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
const {
|
||||
findOrCreateUser,
|
||||
getUserByEmail
|
||||
} = require(`${appRoot}/modules/core/services/users`)
|
||||
const { getServerInfo } = require(`${appRoot}/modules/core/services/generic`)
|
||||
const {
|
||||
validateInvite,
|
||||
useInvite
|
||||
} = require(`${appRoot}/modules/serverinvites/services`)
|
||||
const { findOrCreateUser, getUserByEmail } = require('@/modules/core/services/users')
|
||||
const { getServerInfo } = require('@/modules/core/services/generic')
|
||||
const { validateInvite, useInvite } = require('@/modules/serverinvites/services')
|
||||
|
||||
module.exports = async (app, session, sessionStorage, finalizeAuth) => {
|
||||
const strategy = {
|
||||
|
||||
@@ -3,16 +3,9 @@
|
||||
const passport = require('passport')
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
const {
|
||||
findOrCreateUser,
|
||||
getUserByEmail
|
||||
} = require(`${appRoot}/modules/core/services/users`)
|
||||
const { getServerInfo } = require(`${appRoot}/modules/core/services/generic`)
|
||||
const {
|
||||
validateInvite,
|
||||
useInvite
|
||||
} = require(`${appRoot}/modules/serverinvites/services`)
|
||||
const { findOrCreateUser, getUserByEmail } = require('@/modules/core/services/users')
|
||||
const { getServerInfo } = require('@/modules/core/services/generic')
|
||||
const { validateInvite, useInvite } = require('@/modules/serverinvites/services')
|
||||
|
||||
module.exports = async (app, session, sessionStorage, finalizeAuth) => {
|
||||
const strategy = {
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
'use strict'
|
||||
const appRoot = require('app-root-path')
|
||||
const debug = require('debug')
|
||||
const {
|
||||
createUser,
|
||||
updateUser,
|
||||
validatePasssword,
|
||||
getUserByEmail
|
||||
} = require(`${appRoot}/modules/core/services/users`)
|
||||
const { getServerInfo } = require(`${appRoot}/modules/core/services/generic`)
|
||||
const {
|
||||
validateInvite,
|
||||
useInvite
|
||||
} = require(`${appRoot}/modules/serverinvites/services`)
|
||||
const { respectsLimits } = require(`${appRoot}/modules/core/services/ratelimits`)
|
||||
} = require('@/modules/core/services/users')
|
||||
const { getServerInfo } = require('@/modules/core/services/generic')
|
||||
const { validateInvite, useInvite } = require('@/modules/serverinvites/services')
|
||||
const { respectsLimits } = require('@/modules/core/services/ratelimits')
|
||||
|
||||
module.exports = async (app, session, sessionAppId, finalizeAuth) => {
|
||||
const strategy = {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/* istanbul ignore file */
|
||||
const expect = require('chai').expect
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
const { createUser } = require(`${appRoot}/modules/core/services/users`)
|
||||
const { validateToken } = require(`${appRoot}/modules/core/services/tokens`)
|
||||
const { beforeEachContext } = require(`${appRoot}/test/hooks`)
|
||||
const { createUser } = require(`@/modules/core/services/users`)
|
||||
const { validateToken } = require(`@/modules/core/services/tokens`)
|
||||
const { beforeEachContext } = require(`@/test/hooks`)
|
||||
const {
|
||||
getApp,
|
||||
getAllPublicApps,
|
||||
@@ -17,6 +16,10 @@ const {
|
||||
revokeExistingAppCredentialsForUser
|
||||
} = require('../services/apps')
|
||||
|
||||
const { Scopes } = require('@/modules/core/helpers/mainConstants')
|
||||
const { updateDefaultApp } = require('@/modules/auth/defaultApps')
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
describe('Services @apps-services', () => {
|
||||
const actor = {
|
||||
name: 'Dimitrie Stefanescu',
|
||||
@@ -63,7 +66,7 @@ describe('Services @apps-services', () => {
|
||||
const res = await createApp({
|
||||
name: 'test application',
|
||||
public: true,
|
||||
scopes: ['streams:read'],
|
||||
scopes: [Scopes.Streams.Read],
|
||||
redirectUrl: 'http://localhost:1335'
|
||||
})
|
||||
|
||||
@@ -99,7 +102,7 @@ describe('Services @apps-services', () => {
|
||||
app: {
|
||||
name: 'updated test application',
|
||||
id: myTestApp.id,
|
||||
scopes: ['streams:read', 'users:read']
|
||||
scopes: [Scopes.Streams.Read, Scopes.Users.Read]
|
||||
}
|
||||
})
|
||||
expect(res).to.be.a('string')
|
||||
@@ -107,8 +110,8 @@ describe('Services @apps-services', () => {
|
||||
const app = await getApp({ id: myTestApp.id })
|
||||
expect(app.name).to.equal('updated test application')
|
||||
expect(app.scopes).to.be.an('array')
|
||||
expect(app.scopes.map((s) => s.name)).to.include('users:read')
|
||||
expect(app.scopes.map((s) => s.name)).to.include('streams:read')
|
||||
expect(app.scopes.map((s) => s.name)).to.include(Scopes.Users.Read)
|
||||
expect(app.scopes.map((s) => s.name)).to.include(Scopes.Streams.Read)
|
||||
})
|
||||
|
||||
const challenge = 'random'
|
||||
@@ -142,7 +145,7 @@ describe('Services @apps-services', () => {
|
||||
const validation = await validateToken(response.token)
|
||||
expect(validation.valid).to.equal(true)
|
||||
expect(validation.userId).to.equal(actor.id)
|
||||
expect(validation.scopes[0]).to.equal('streams:read')
|
||||
expect(validation.scopes[0]).to.equal(Scopes.Streams.Read)
|
||||
})
|
||||
|
||||
it('Should refresh the token using the refresh token, and get a fresh refresh token and token', async () => {
|
||||
@@ -185,7 +188,7 @@ describe('Services @apps-services', () => {
|
||||
app: {
|
||||
name: 'updated test application',
|
||||
id: myTestApp.id,
|
||||
scopes: ['streams:write', 'users:read']
|
||||
scopes: [Scopes.Streams.Write, Scopes.Users.Read]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -214,6 +217,106 @@ describe('Services @apps-services', () => {
|
||||
.catch((err) => expect(err.message).to.equal('Access code not found.'))
|
||||
})
|
||||
|
||||
const defaultApps = ['spklwebapp', 'explorer', 'sdm', 'sca', 'spklexcel']
|
||||
defaultApps.forEach((speckleAppId) => {
|
||||
it(`Should not invalidate tokens, refresh tokens and access codes for default app: ${speckleAppId}, if updated`, async () => {
|
||||
const [unusedAccessCode, usedAccessCode] = await Promise.all([
|
||||
createAuthorizationCode({
|
||||
appId: speckleAppId,
|
||||
userId: actor.id,
|
||||
challenge
|
||||
}),
|
||||
createAuthorizationCode({
|
||||
appId: speckleAppId,
|
||||
userId: actor.id,
|
||||
challenge
|
||||
})
|
||||
])
|
||||
|
||||
const apiTokenResponse = await createAppTokenFromAccessCode({
|
||||
appId: speckleAppId,
|
||||
appSecret: speckleAppId,
|
||||
accessCode: usedAccessCode,
|
||||
challenge
|
||||
})
|
||||
|
||||
// We now have one unused access code, an api token and a refresh token.
|
||||
// Proceed to update the app:
|
||||
const existingApp = await getApp({ id: speckleAppId })
|
||||
|
||||
const newScopes = [Scopes.Streams.Write, Scopes.Users.Read]
|
||||
|
||||
await updateDefaultApp(
|
||||
{
|
||||
name: 'updated test application',
|
||||
id: speckleAppId,
|
||||
scopes: newScopes
|
||||
},
|
||||
existingApp
|
||||
)
|
||||
const updatedApp = await getApp({ id: speckleAppId })
|
||||
|
||||
expect(updatedApp.scopes.map((s) => s.name)).to.equalInAnyOrder(newScopes)
|
||||
|
||||
const validationResponse = await validateToken(apiTokenResponse.token)
|
||||
expect(validationResponse.valid).to.equal(true)
|
||||
|
||||
const refreshedToken = await refreshAppToken({
|
||||
refreshToken: apiTokenResponse.refreshToken,
|
||||
appId: speckleAppId,
|
||||
appSecret: speckleAppId
|
||||
})
|
||||
expect(refreshedToken.refreshToken).to.exist
|
||||
expect(refreshedToken.token).to.exist
|
||||
|
||||
const appToken = await createAppTokenFromAccessCode({
|
||||
appId: speckleAppId,
|
||||
appSecret: speckleAppId,
|
||||
accessCode: unusedAccessCode,
|
||||
challenge: 'random'
|
||||
})
|
||||
expect(appToken.token).to.exist
|
||||
expect(appToken.refreshToken).to.exist
|
||||
|
||||
const apiTokens = await knex('user_server_app_tokens')
|
||||
.join(
|
||||
'token_scopes',
|
||||
'user_server_app_tokens.tokenId',
|
||||
'=',
|
||||
'token_scopes.tokenId'
|
||||
)
|
||||
.where({
|
||||
appId: speckleAppId
|
||||
})
|
||||
|
||||
expect(newScopes).to.include.members(apiTokens.map((t) => t.scopeName))
|
||||
})
|
||||
})
|
||||
|
||||
it('Updating a default app with bad data should leave the app in an untouched state', async () => {
|
||||
const speckleAppId = 'explorer'
|
||||
const existingApp = await getApp({ id: speckleAppId })
|
||||
try {
|
||||
await updateDefaultApp(
|
||||
{
|
||||
name: 'updated test application',
|
||||
id: speckleAppId,
|
||||
scopes: ['aWeird:Scope']
|
||||
},
|
||||
existingApp
|
||||
)
|
||||
throw new Error('This should have failed')
|
||||
} catch (err) {
|
||||
// check that the weird:Scope violates a foreign key constraint...
|
||||
// leaky abstractions i know, but no better way to test this for now
|
||||
expect(err.message).to.contain('server_apps_scopes_scopename_foreign')
|
||||
}
|
||||
const notUpdatedApp = await getApp({ id: speckleAppId })
|
||||
// check that no harm was done
|
||||
expect(notUpdatedApp.name).to.equal(existingApp.name)
|
||||
expect(notUpdatedApp.scopes).to.equalInAnyOrder(existingApp.scopes)
|
||||
})
|
||||
|
||||
it('Should revoke access for a given user', async () => {
|
||||
const secondUser = {
|
||||
name: 'Dimitrie Stefanescu',
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/* eslint-disable camelcase */
|
||||
/* istanbul ignore file */
|
||||
const chai = require('chai')
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
const { createUser } = require(`${appRoot}/modules/core/services/users`)
|
||||
const { createPersonalAccessToken } = require(`${appRoot}/modules/core/services/tokens`)
|
||||
const { beforeEachContext, initializeTestServer } = require(`${appRoot}/test/hooks`)
|
||||
const { createUser } = require('@/modules/core/services/users')
|
||||
const { createPersonalAccessToken } = require('@/modules/core/services/tokens')
|
||||
const { beforeEachContext, initializeTestServer } = require('@/test/hooks')
|
||||
const {
|
||||
createAuthorizationCode,
|
||||
createAppTokenFromAccessCode
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
const crs = require('crypto-random-string')
|
||||
const chai = require('chai')
|
||||
const request = require('supertest')
|
||||
const appRoot = require('app-root-path')
|
||||
const { createStream, getStream } = require(`${appRoot}/modules/core/services/streams`)
|
||||
const { createStream, getStream } = require('@/modules/core/services/streams')
|
||||
|
||||
const { updateServerInfo } = require(`${appRoot}/modules/core/services/generic`)
|
||||
const { getUserByEmail } = require(`${appRoot}/modules/core/services/users`)
|
||||
const { LIMITS } = require(`${appRoot}/modules/core/services/ratelimits`)
|
||||
const { createAndSendInvite } = require(`${appRoot}/modules/serverinvites/services`)
|
||||
const { beforeEachContext, initializeTestServer } = require(`${appRoot}/test/hooks`)
|
||||
const { updateServerInfo } = require('@/modules/core/services/generic')
|
||||
const { getUserByEmail } = require('@/modules/core/services/users')
|
||||
const { LIMITS } = require('@/modules/core/services/ratelimits')
|
||||
const { createAndSendInvite } = require('@/modules/serverinvites/services')
|
||||
const { beforeEachContext, initializeTestServer } = require('@/test/hooks')
|
||||
const expect = chai.expect
|
||||
|
||||
let app
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const knex = require('@/db/knex')
|
||||
const appRoot = require('app-root-path')
|
||||
const { appRoot } = require('@/bootstrap')
|
||||
const fs = require('fs/promises')
|
||||
|
||||
/** @type {import('yargs').CommandModule} */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const appRoot = require('app-root-path')
|
||||
const { authorizeResolver, pubsub } = require(`${appRoot}/modules/shared`)
|
||||
const { authorizeResolver, pubsub } = require('@/modules/shared')
|
||||
const { ForbiddenError, ApolloError, withFilter } = require('apollo-server-express')
|
||||
const { getStream } = require(`${appRoot}/modules/core/services/streams`)
|
||||
const { getStream } = require('@/modules/core/services/streams')
|
||||
const { saveActivity } = require('@/modules/activitystream/services')
|
||||
|
||||
const {
|
||||
getComment,
|
||||
@@ -13,8 +13,9 @@ const {
|
||||
viewComment,
|
||||
archiveComment,
|
||||
editComment,
|
||||
streamResourceCheck
|
||||
} = require(`${appRoot}/modules/comments/services`)
|
||||
streamResourceCheck,
|
||||
formatCommentText
|
||||
} = require('@/modules/comments/services')
|
||||
|
||||
const authorizeStreamAccess = async ({ streamId, userId, auth }) => {
|
||||
const stream = await getStream({ streamId, userId })
|
||||
@@ -23,8 +24,8 @@ const authorizeStreamAccess = async ({ streamId, userId, auth }) => {
|
||||
if (!stream.isPublic && auth === false)
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
|
||||
if (!stream.isPublic) {
|
||||
await authorizeResolver(userId, streamId, 'stream:reviewer')
|
||||
if (!stream.isPublic && !stream.role) {
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +61,9 @@ module.exports = {
|
||||
limit: args.limit,
|
||||
cursor: args.cursor
|
||||
})
|
||||
},
|
||||
text(parent) {
|
||||
return formatCommentText(parent)
|
||||
}
|
||||
},
|
||||
Stream: {
|
||||
@@ -107,7 +111,16 @@ module.exports = {
|
||||
},
|
||||
|
||||
async userCommentThreadActivityBroadcast(parent, args, context) {
|
||||
await authorizeResolver(context.userId, args.streamId, 'stream:reviewer')
|
||||
if (!context.userId) return false
|
||||
|
||||
const stream = await getStream({
|
||||
streamId: args.streamId,
|
||||
userId: context.userId
|
||||
})
|
||||
|
||||
if (!stream.allowPublicComments && !stream.role)
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
|
||||
await pubsub.publish('COMMENT_THREAD_ACTIVITY', {
|
||||
commentThreadActivity: { eventType: 'reply-typing-status', data: args.data },
|
||||
streamId: args.streamId,
|
||||
@@ -117,7 +130,16 @@ module.exports = {
|
||||
},
|
||||
|
||||
async commentCreate(parent, args, context) {
|
||||
await authorizeResolver(context.userId, args.input.streamId, 'stream:reviewer')
|
||||
if (!context.userId)
|
||||
throw new ForbiddenError('Only registered users can comment.')
|
||||
|
||||
const stream = await getStream({
|
||||
streamId: args.input.streamId,
|
||||
userId: context.userId
|
||||
})
|
||||
|
||||
if (!stream.allowPublicComments && !stream.role)
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
|
||||
const id = await createComment({ userId: context.userId, input: args.input })
|
||||
|
||||
@@ -136,10 +158,21 @@ module.exports = {
|
||||
resourceIds: args.input.resources.map((res) => res.resourceId).join(',') // TODO: hack for now
|
||||
})
|
||||
|
||||
await saveActivity({
|
||||
streamId: args.input.streamId,
|
||||
resourceType: 'comment',
|
||||
resourceId: id,
|
||||
actionType: 'comment_created',
|
||||
userId: context.userId,
|
||||
info: { input: args.input },
|
||||
message: `Comment added: ${id} (${args.input})`
|
||||
})
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
async commentEdit(parent, args, context) {
|
||||
// NOTE: This is NOT in use anywhere
|
||||
await authorizeResolver(context.userId, args.input.streamId, 'stream:reviewer')
|
||||
await editComment({ userId: context.userId, input: args.input })
|
||||
return true
|
||||
@@ -147,7 +180,14 @@ module.exports = {
|
||||
|
||||
// used for flagging a comment as viewed
|
||||
async commentView(parent, args, context) {
|
||||
await authorizeResolver(context.userId, args.streamId, 'stream:reviewer')
|
||||
const stream = await getStream({
|
||||
streamId: args.streamId,
|
||||
userId: context.userId
|
||||
})
|
||||
|
||||
if (!stream.allowPublicComments && !stream.role)
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
|
||||
await viewComment({ userId: context.userId, commentId: args.commentId })
|
||||
return true
|
||||
},
|
||||
@@ -158,7 +198,9 @@ module.exports = {
|
||||
userId: context.userId,
|
||||
auth: context.auth
|
||||
})
|
||||
await archiveComment({ ...args, userId: context.userId })
|
||||
|
||||
await archiveComment({ ...args, userId: context.userId }) // NOTE: permissions check inside service
|
||||
|
||||
await pubsub.publish('COMMENT_THREAD_ACTIVITY', {
|
||||
commentThreadActivity: {
|
||||
eventType: args.archived ? 'comment-archived' : 'comment-added'
|
||||
@@ -166,11 +208,30 @@ module.exports = {
|
||||
streamId: args.streamId,
|
||||
commentId: args.commentId
|
||||
})
|
||||
|
||||
await saveActivity({
|
||||
streamId: args.streamId,
|
||||
resourceType: 'comment',
|
||||
resourceId: args.commentId,
|
||||
actionType: 'comment_archived',
|
||||
userId: context.userId,
|
||||
info: { input: args },
|
||||
message: `Comment archived`
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
async commentReply(parent, args, context) {
|
||||
await authorizeResolver(context.userId, args.input.streamId, 'stream:reviewer')
|
||||
if (!context.userId)
|
||||
throw new ForbiddenError('Only registered users can comment.')
|
||||
|
||||
const stream = await getStream({
|
||||
streamId: args.input.streamId,
|
||||
userId: context.userId
|
||||
})
|
||||
|
||||
if (!stream.allowPublicComments && !stream.role)
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
|
||||
const id = await createCommentReply({
|
||||
authorId: context.userId,
|
||||
@@ -192,6 +253,16 @@ module.exports = {
|
||||
streamId: args.input.streamId,
|
||||
commentId: args.input.parentComment
|
||||
})
|
||||
|
||||
await saveActivity({
|
||||
streamId: args.input.streamId,
|
||||
resourceType: 'comment',
|
||||
resourceId: args.input.parentComment,
|
||||
actionType: 'comment_reply',
|
||||
userId: context.userId,
|
||||
info: { input: args.input },
|
||||
message: `Comment reply created.`
|
||||
})
|
||||
return id
|
||||
}
|
||||
},
|
||||
@@ -200,7 +271,14 @@ module.exports = {
|
||||
subscribe: withFilter(
|
||||
() => pubsub.asyncIterator(['VIEWER_ACTIVITY']),
|
||||
async (payload, variables, context) => {
|
||||
await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer')
|
||||
const stream = await getStream({
|
||||
streamId: payload.streamId,
|
||||
userId: context.userId
|
||||
})
|
||||
|
||||
if (!stream.allowPublicComments && !stream.role)
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
|
||||
return (
|
||||
payload.streamId === variables.streamId &&
|
||||
payload.resourceId === variables.resourceId
|
||||
@@ -212,12 +290,20 @@ module.exports = {
|
||||
subscribe: withFilter(
|
||||
() => pubsub.asyncIterator(['COMMENT_ACTIVITY']),
|
||||
async (payload, variables, context) => {
|
||||
await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer')
|
||||
const stream = await getStream({
|
||||
streamId: payload.streamId,
|
||||
userId: context.userId
|
||||
})
|
||||
|
||||
if (!stream.allowPublicComments && !stream.role)
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
|
||||
// if we're listening for a stream's root comments events
|
||||
if (!variables.resourceIds) {
|
||||
return payload.streamId === variables.streamId
|
||||
}
|
||||
|
||||
// otherwise perform a deeper check
|
||||
try {
|
||||
// prevents comment exfiltration by listening in to a auth'ed stream, but different commit ("stream hopping" for subscriptions)
|
||||
await streamResourceCheck({
|
||||
@@ -247,7 +333,14 @@ module.exports = {
|
||||
subscribe: withFilter(
|
||||
() => pubsub.asyncIterator(['COMMENT_THREAD_ACTIVITY']),
|
||||
async (payload, variables, context) => {
|
||||
await authorizeResolver(context.userId, payload.streamId, 'stream:reviewer')
|
||||
const stream = await getStream({
|
||||
streamId: payload.streamId,
|
||||
userId: context.userId
|
||||
})
|
||||
|
||||
if (!stream.allowPublicComments && !stream.role)
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
|
||||
return (
|
||||
payload.streamId === variables.streamId &&
|
||||
payload.commentId === variables.commentId
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
'use strict'
|
||||
const crs = require('crypto-random-string')
|
||||
const appRoot = require('app-root-path')
|
||||
const knex = require(`${appRoot}/db/knex`)
|
||||
const knex = require('@/db/knex')
|
||||
const sanitizeHtml = require('sanitize-html')
|
||||
|
||||
const Comments = () => knex('comments')
|
||||
const CommentLinks = () => knex('comment_links')
|
||||
const CommentViews = () => knex('comment_views')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Format comment text field (e.g. for returning to frontend or saving to DB)
|
||||
* @param {Object} comment
|
||||
* @returns {string}
|
||||
*/
|
||||
formatCommentText(comment) {
|
||||
if (!comment || !comment.text) return ''
|
||||
return sanitizeHtml(comment.text)
|
||||
},
|
||||
async streamResourceCheck({ streamId, resources }) {
|
||||
// this itches - a for loop with queries... but okay let's hit the road now
|
||||
for (const res of resources) {
|
||||
@@ -75,6 +84,7 @@ module.exports = {
|
||||
|
||||
comment.id = crs({ length: 10 })
|
||||
comment.authorId = userId
|
||||
comment.text = module.exports.formatCommentText(comment)
|
||||
|
||||
await Comments().insert(comment)
|
||||
try {
|
||||
@@ -106,6 +116,8 @@ module.exports = {
|
||||
streamId,
|
||||
parentComment: parentCommentId
|
||||
}
|
||||
comment.text = module.exports.formatCommentText(comment)
|
||||
|
||||
await Comments().insert(comment)
|
||||
try {
|
||||
const commentLink = { resourceId: parentCommentId, resourceType: 'comment' }
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
const expect = require('chai').expect
|
||||
const crs = require('crypto-random-string')
|
||||
|
||||
const appRoot = require('app-root-path')
|
||||
const { beforeEachContext } = require(`${appRoot}/test/hooks`)
|
||||
const { createUser } = require(`${appRoot}/modules/core/services/users`)
|
||||
const { createStream } = require(`${appRoot}/modules/core/services/streams`)
|
||||
const { createCommitByBranchName } = require(`${appRoot}/modules/core/services/commits`)
|
||||
const { beforeEachContext } = require('@/test/hooks')
|
||||
const { createUser } = require('@/modules/core/services/users')
|
||||
const { createStream } = require('@/modules/core/services/streams')
|
||||
const { createCommitByBranchName } = require('@/modules/core/services/commits')
|
||||
|
||||
const { createObject } = require(`${appRoot}/modules/core/services/objects`)
|
||||
const { createObject } = require('@/modules/core/services/objects')
|
||||
const {
|
||||
createComment,
|
||||
getComments,
|
||||
@@ -79,6 +78,22 @@ describe('Comments @comments', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Should not accept complex HTML in the comment text', async () => {
|
||||
const commentId = await createComment({
|
||||
userId: user.id,
|
||||
input: {
|
||||
streamId: stream.id,
|
||||
resources: [{ resourceId: stream.id, resourceType: 'stream' }],
|
||||
text: 'Some <img src/onerror=alert(1)> <strong>epic</strong> <a href="javascript:alert(1)">cool</a> text!',
|
||||
data: { justSome: crs({ length: 10 }) }
|
||||
}
|
||||
})
|
||||
|
||||
const comment = await getComment({ id: commentId })
|
||||
expect(comment).to.be.ok
|
||||
expect((comment.text.match(/alert/) || []).length).to.equal(0)
|
||||
})
|
||||
|
||||
it('Should not be allowed to comment without specifying at least one target resource', async () => {
|
||||
await createComment({
|
||||
userId: user.id,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { SchemaDirectiveVisitor } = require('apollo-server-express')
|
||||
const { defaultFieldResolver } = require('graphql')
|
||||
const appRoot = require('app-root-path')
|
||||
const { validateServerRole } = require(`${appRoot}/modules/shared`)
|
||||
const { validateServerRole } = require('@/modules/shared')
|
||||
|
||||
module.exports = {
|
||||
hasRole: class HasRoleDirective extends SchemaDirectiveVisitor {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { SchemaDirectiveVisitor } = require('apollo-server-express')
|
||||
const { defaultFieldResolver } = require('graphql')
|
||||
const appRoot = require('app-root-path')
|
||||
const { validateScopes } = require(`${appRoot}/modules/shared`)
|
||||
const { validateScopes } = require('@/modules/shared')
|
||||
|
||||
module.exports = {
|
||||
hasScope: class HasScopeDirective extends SchemaDirectiveVisitor {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use strict'
|
||||
|
||||
const appRoot = require('app-root-path')
|
||||
const {
|
||||
ForbiddenError,
|
||||
UserInputError,
|
||||
ApolloError,
|
||||
withFilter
|
||||
} = require('apollo-server-express')
|
||||
const { authorizeResolver, pubsub } = require(`${appRoot}/modules/shared`)
|
||||
const { authorizeResolver, pubsub } = require('@/modules/shared')
|
||||
|
||||
const {
|
||||
createBranch,
|
||||
@@ -19,7 +18,7 @@ const {
|
||||
} = require('../../services/branches')
|
||||
|
||||
const { getUserById } = require('../../services/users')
|
||||
const { saveActivity } = require(`${appRoot}/modules/activitystream/services`)
|
||||
const { saveActivity } = require('@/modules/activitystream/services')
|
||||
|
||||
// subscription events
|
||||
const BRANCH_CREATED = 'BRANCH_CREATED'
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
'use strict'
|
||||
|
||||
const appRoot = require('app-root-path')
|
||||
const {
|
||||
ForbiddenError,
|
||||
UserInputError,
|
||||
ApolloError,
|
||||
withFilter
|
||||
} = require('apollo-server-express')
|
||||
const { authorizeResolver, pubsub } = require(`${appRoot}/modules/shared`)
|
||||
const { saveActivity } = require(`${appRoot}/modules/activitystream/services`)
|
||||
const { authorizeResolver, pubsub } = require('@/modules/shared')
|
||||
const { saveActivity } = require('@/modules/activitystream/services')
|
||||
|
||||
const {
|
||||
createCommitByBranchName,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use strict'
|
||||
const appRoot = require('app-root-path')
|
||||
const {
|
||||
validateServerRole,
|
||||
validateScopes,
|
||||
authorizeResolver
|
||||
} = require(`${appRoot}/modules/shared`)
|
||||
} = require('@/modules/shared')
|
||||
|
||||
const {
|
||||
createObjects,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use strict'
|
||||
const appRoot = require('app-root-path')
|
||||
const { validateServerRole, validateScopes } = require(`${appRoot}/modules/shared`)
|
||||
const { validateServerRole, validateScopes } = require('@/modules/shared')
|
||||
const {
|
||||
updateServerInfo,
|
||||
getServerInfo,
|
||||
|
||||
@@ -222,7 +222,8 @@ module.exports = {
|
||||
streamId: args.stream.id,
|
||||
name: args.stream.name,
|
||||
description: args.stream.description,
|
||||
isPublic: args.stream.isPublic
|
||||
isPublic: args.stream.isPublic,
|
||||
allowPublicComments: args.stream.allowPublicComments
|
||||
}
|
||||
|
||||
await updateStream(update)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use strict'
|
||||
const appRoot = require('app-root-path')
|
||||
const { UserInputError } = require('apollo-server-express')
|
||||
const {
|
||||
getUser,
|
||||
@@ -15,7 +14,7 @@ const {
|
||||
unmakeUserAdmin,
|
||||
archiveUser
|
||||
} = require('../../services/users')
|
||||
const { saveActivity } = require(`${appRoot}/modules/activitystream/services`)
|
||||
const { saveActivity } = require('@/modules/activitystream/services')
|
||||
const { validateServerRole, validateScopes } = require(`@/modules/shared`)
|
||||
const zxcvbn = require('zxcvbn')
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type Stream {
|
||||
name: String!
|
||||
description: String
|
||||
isPublic: Boolean!
|
||||
allowPublicComments: Boolean!
|
||||
"""
|
||||
Your role for this stream. `null` if request is not authenticated, or the stream is not explicitly shared with you.
|
||||
"""
|
||||
@@ -164,6 +165,7 @@ input StreamUpdateInput {
|
||||
name: String
|
||||
description: String
|
||||
isPublic: Boolean
|
||||
allowPublicComments: Boolean
|
||||
}
|
||||
|
||||
input StreamGrantPermissionInput {
|
||||
|
||||
@@ -31,7 +31,8 @@ const Scopes = Object.freeze({
|
||||
},
|
||||
Users: {
|
||||
Read: 'users:read',
|
||||
Email: 'users:email'
|
||||
Email: 'users:email',
|
||||
Invite: 'users:invite'
|
||||
},
|
||||
Server: {
|
||||
Stats: 'server:stats',
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use strict'
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
const {
|
||||
registerOrUpdateScope,
|
||||
registerOrUpdateRole
|
||||
} = require(`${appRoot}/modules/shared`)
|
||||
const { registerOrUpdateScope, registerOrUpdateRole } = require('@/modules/shared')
|
||||
|
||||
exports.init = async (app) => {
|
||||
debug('speckle:modules')('💥 Init core module')
|
||||
|
||||
@@ -5,8 +5,7 @@ https://speckle.community/t/error-in-grasshopper-while-receiving-data-you-dont-h
|
||||
*/
|
||||
|
||||
exports.up = async (knex) => {
|
||||
const appRoot = require('app-root-path')
|
||||
const roles = require(`${appRoot}/modules/core/roles.js`)
|
||||
const roles = require('@/modules/core/roles.js')
|
||||
|
||||
const Users = () => knex('users')
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* istanbul ignore file */
|
||||
exports.up = async (knex) => {
|
||||
await knex.schema.alterTable('streams', (table) => {
|
||||
table.boolean('allowPublicComments').defaultTo(false)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = async (knex) => {
|
||||
await knex.schema.alterTable('streams', (table) => {
|
||||
table.dropColumn('allowPublicComments')
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
'use strict'
|
||||
const appRoot = require('app-root-path')
|
||||
const {
|
||||
validateScopes,
|
||||
validateServerRole,
|
||||
authorizeResolver
|
||||
} = require(`${appRoot}/modules/shared`)
|
||||
} = require('@/modules/shared')
|
||||
|
||||
const { getStream } = require('../services/streams')
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use strict'
|
||||
const zlib = require('zlib')
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
const cors = require('cors')
|
||||
|
||||
const { contextMiddleware } = require(`${appRoot}/modules/shared`)
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { validatePermissionsReadStream } = require('./authUtils')
|
||||
const { SpeckleObjectsStream } = require('./speckleObjectsStream')
|
||||
const { getObjectsStream } = require('../services/objects')
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
const zlib = require('zlib')
|
||||
const cors = require('cors')
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
const { contextMiddleware } = require(`${appRoot}/modules/shared`)
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { validatePermissionsWriteStream } = require('./authUtils')
|
||||
|
||||
const { hasObjects } = require('../services/objects')
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use strict'
|
||||
const zlib = require('zlib')
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
const cors = require('cors')
|
||||
|
||||
const { contextMiddleware } = require(`${appRoot}/modules/shared`)
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { validatePermissionsReadStream } = require('./authUtils')
|
||||
|
||||
const { getObject, getObjectChildrenStream } = require('../services/objects')
|
||||
|
||||
@@ -3,9 +3,8 @@ const zlib = require('zlib')
|
||||
const cors = require('cors')
|
||||
const Busboy = require('busboy')
|
||||
const debug = require('debug')
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
const { contextMiddleware } = require(`${appRoot}/modules/shared`)
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { validatePermissionsWriteStream } = require('./authUtils')
|
||||
|
||||
const { createObjectsBatched } = require('../services/objects')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict'
|
||||
const crs = require('crypto-random-string')
|
||||
const appRoot = require('app-root-path')
|
||||
const knex = require(`${appRoot}/db/knex`)
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
const Streams = () => knex('streams')
|
||||
const Branches = () => knex('branches')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict'
|
||||
const crs = require('crypto-random-string')
|
||||
const appRoot = require('app-root-path')
|
||||
const knex = require(`${appRoot}/db/knex`)
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
const Streams = () => knex('streams')
|
||||
const Commits = () => knex('commits')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user