committed by
GitHub
parent
5871339a18
commit
aeeb88340d
@@ -16,7 +16,6 @@ const config = {
|
||||
],
|
||||
'no-var': 'error',
|
||||
'no-alert': 'error',
|
||||
'no-param-reassign': 'warn',
|
||||
eqeqeq: 'warn'
|
||||
},
|
||||
ignorePatterns: ['node_modules', 'dist', 'public']
|
||||
|
||||
+5
-1
@@ -2,7 +2,11 @@
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .js,.ts,.vue"
|
||||
"lint": "eslint . --ext .js,.ts,.vue",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.4.1",
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"javascript.suggest.autoImports": true,
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../jsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
Generated
+38996
-38977
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,7 @@
|
||||
"apexcharts": "^3.33.1",
|
||||
"crypto-random-string": "^3.3.0",
|
||||
"dompurify": "^2.3.6",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"numeral": "^2.0.6",
|
||||
"portal-vue": "^2.1.7",
|
||||
"tween": "^0.9.0",
|
||||
@@ -58,6 +57,7 @@
|
||||
"vuetify-loader": "^1.6.0",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"prettier": "^2.5.1"
|
||||
"prettier": "^2.5.1",
|
||||
"@types/lodash": "^4.14.180"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
query {
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
description
|
||||
adminContact
|
||||
canonicalUrl
|
||||
inviteOnly
|
||||
version
|
||||
termsOfService
|
||||
roles {
|
||||
name
|
||||
description
|
||||
resourceTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const MAIN_SERVER_INFO_FIELDS = gql`
|
||||
fragment MainServerInfoFields on ServerInfo {
|
||||
name
|
||||
company
|
||||
description
|
||||
adminContact
|
||||
canonicalUrl
|
||||
termsOfService
|
||||
inviteOnly
|
||||
version
|
||||
}
|
||||
`
|
||||
|
||||
export const SERVER_INFO_ROLES_FIELDS = gql`
|
||||
fragment ServerInfoRolesFields on ServerInfo {
|
||||
roles {
|
||||
name
|
||||
description
|
||||
resourceTarget
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SERVER_INFO_SCOPES_FIELDS = gql`
|
||||
fragment ServerInfoScopesFields on ServerInfo {
|
||||
scopes {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Get main server info
|
||||
*/
|
||||
export const MainServerInfoQuery = gql`
|
||||
query MainServerInfo {
|
||||
serverInfo {
|
||||
...MainServerInfoFields
|
||||
}
|
||||
}
|
||||
|
||||
${MAIN_SERVER_INFO_FIELDS}
|
||||
`
|
||||
|
||||
export const FullServerInfoQuery = gql`
|
||||
query FullServerInfo {
|
||||
serverInfo {
|
||||
...MainServerInfoFields
|
||||
...ServerInfoRolesFields
|
||||
...ServerInfoScopesFields
|
||||
}
|
||||
}
|
||||
|
||||
${MAIN_SERVER_INFO_FIELDS}
|
||||
${SERVER_INFO_ROLES_FIELDS}
|
||||
${SERVER_INFO_SCOPES_FIELDS}
|
||||
`
|
||||
@@ -1,5 +1,5 @@
|
||||
query Streams($cursor: String) {
|
||||
streams(cursor: $cursor, limit:3 ) {
|
||||
streams(cursor: $cursor, limit: 10) {
|
||||
totalCount
|
||||
cursor
|
||||
items {
|
||||
@@ -34,6 +34,8 @@ query Streams($cursor: String) {
|
||||
branches {
|
||||
totalCount
|
||||
}
|
||||
favoritedDate
|
||||
favoritesCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
/**
|
||||
* Common stream fields when querying for streams
|
||||
*/
|
||||
export const COMMON_STREAM_FIELDS = gql`
|
||||
fragment CommonStreamFields on Stream {
|
||||
id
|
||||
name
|
||||
description
|
||||
role
|
||||
isPublic
|
||||
createdAt
|
||||
updatedAt
|
||||
collaborators {
|
||||
id
|
||||
name
|
||||
company
|
||||
avatar
|
||||
role
|
||||
}
|
||||
commits(limit: 1) {
|
||||
totalCount
|
||||
}
|
||||
branches {
|
||||
totalCount
|
||||
}
|
||||
favoritedDate
|
||||
favoritesCount
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Retrieve a single stream
|
||||
*/
|
||||
export const StreamQuery = gql`
|
||||
query Stream($id: String!) {
|
||||
stream(id: $id) {
|
||||
...CommonStreamFields
|
||||
}
|
||||
}
|
||||
|
||||
${COMMON_STREAM_FIELDS}
|
||||
`
|
||||
@@ -1,25 +0,0 @@
|
||||
query {
|
||||
user {
|
||||
id
|
||||
suuid
|
||||
email
|
||||
name
|
||||
bio
|
||||
company
|
||||
avatar
|
||||
verified
|
||||
profiles
|
||||
role
|
||||
suuid
|
||||
streams {
|
||||
totalCount
|
||||
}
|
||||
commits(limit: 1) {
|
||||
totalCount
|
||||
items {
|
||||
id
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { COMMON_STREAM_FIELDS } from '@/graphql/streams'
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const COMMON_USER_FIELDS = gql`
|
||||
fragment CommonUserFields on User {
|
||||
id
|
||||
suuid
|
||||
email
|
||||
name
|
||||
bio
|
||||
company
|
||||
avatar
|
||||
verified
|
||||
profiles
|
||||
role
|
||||
suuid
|
||||
streams {
|
||||
totalCount
|
||||
}
|
||||
commits(limit: 1) {
|
||||
totalCount
|
||||
items {
|
||||
id
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* User data with favorite streams
|
||||
*/
|
||||
export const UserFavoriteStreamsQuery = gql`
|
||||
query UserFavoriteStreams($cursor: String) {
|
||||
user {
|
||||
...CommonUserFields
|
||||
favoriteStreams(cursor: $cursor, limit: 10) {
|
||||
totalCount
|
||||
cursor
|
||||
items {
|
||||
...CommonStreamFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${COMMON_USER_FIELDS}
|
||||
${COMMON_STREAM_FIELDS}
|
||||
`
|
||||
|
||||
/**
|
||||
* Get main user metadata
|
||||
*/
|
||||
export const MainUserDataQuery = gql`
|
||||
query MainUserData {
|
||||
user {
|
||||
...CommonUserFields
|
||||
}
|
||||
}
|
||||
|
||||
${COMMON_USER_FIELDS}
|
||||
`
|
||||
|
||||
/**
|
||||
* Main metadata + extra info shown on profile page
|
||||
*/
|
||||
export const ProfileSelfQuery = gql`
|
||||
query MainUserData {
|
||||
user {
|
||||
...CommonUserFields
|
||||
totalOwnedStreamsFavorites
|
||||
}
|
||||
}
|
||||
|
||||
${COMMON_USER_FIELDS}
|
||||
`
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Speckle role constants
|
||||
*/
|
||||
export const Roles = Object.freeze({
|
||||
Stream: {
|
||||
Owner: 'stream:owner',
|
||||
Contributor: 'stream:contributor',
|
||||
Reviewer: 'stream:reviewer'
|
||||
},
|
||||
Server: {
|
||||
Admin: 'server:admin',
|
||||
User: 'server:user',
|
||||
ArchivedUser: 'server:archived-user'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Keys for values stored in localStorage
|
||||
*/
|
||||
export const LocalStorageKeys = Object.freeze({
|
||||
AuthToken: 'AuthToken',
|
||||
RefreshToken: 'RefreshToken'
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Check whether or not a stream can be favorited by the active user
|
||||
*/
|
||||
export function canBeFavorited(stream) {
|
||||
return stream && (stream.isPublic || stream.role)
|
||||
}
|
||||
@@ -2,22 +2,13 @@
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { MainServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
apollo: {
|
||||
serverInfo: {
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
description
|
||||
adminContact
|
||||
}
|
||||
}
|
||||
`
|
||||
query: MainServerInfoQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import App from '@/main/App.vue'
|
||||
import { createProvider } from '@/vue-apollo'
|
||||
import { checkAccessCodeAndGetTokens, prefetchUserAndSetSuuid } from '@/plugins/authHelpers'
|
||||
|
||||
import router from '@/main/router'
|
||||
import router from '@/main/router/index'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
@@ -61,7 +61,7 @@ Vue.component(HistogramSlider.name, HistogramSlider)
|
||||
import VueApexCharts from 'vue-apexcharts'
|
||||
Vue.use(VueApexCharts)
|
||||
|
||||
Vue.component('Apexchart', VueApexCharts)
|
||||
Vue.component('ApexChart', VueApexCharts)
|
||||
|
||||
import { formatNumber } from '@/plugins/formatNumber'
|
||||
// Filter to turn any number into a nice string like '10k', '5.5m'
|
||||
@@ -78,11 +78,12 @@ Vue.filter('capitalize', (value) => {
|
||||
// adds various helper methods
|
||||
import '@/plugins/helpers'
|
||||
|
||||
let AuthToken = localStorage.getItem('AuthToken')
|
||||
let RefreshToken = localStorage.getItem('RefreshToken')
|
||||
const AuthToken = localStorage.getItem(LocalStorageKeys.AuthToken)
|
||||
const RefreshToken = localStorage.getItem(LocalStorageKeys.RefreshToken)
|
||||
const apolloProvider = createProvider()
|
||||
|
||||
if (AuthToken) {
|
||||
prefetchUserAndSetSuuid()
|
||||
prefetchUserAndSetSuuid(apolloProvider.defaultClient)
|
||||
.then(() => {
|
||||
initVue()
|
||||
})
|
||||
@@ -95,7 +96,7 @@ if (AuthToken) {
|
||||
} else {
|
||||
checkAccessCodeAndGetTokens()
|
||||
.then(() => {
|
||||
return prefetchUserAndSetSuuid()
|
||||
return prefetchUserAndSetSuuid(apolloProvider.defaultClient)
|
||||
})
|
||||
.then(() => {
|
||||
initVue()
|
||||
@@ -106,13 +107,14 @@ if (AuthToken) {
|
||||
}
|
||||
|
||||
import store from '@/main/store'
|
||||
import { LocalStorageKeys } from '@/helpers/mainConstants'
|
||||
|
||||
function initVue() {
|
||||
new Vue({
|
||||
router,
|
||||
vuetify,
|
||||
store,
|
||||
apolloProvider: createProvider(),
|
||||
apolloProvider,
|
||||
render: (h) => h(App)
|
||||
}).$mount('#app')
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<v-icon x-small color="primary" class="mr-1">{{ icons[value.name] }}</v-icon>
|
||||
{{ capitalize(value.name.split('History')[0]) }} history
|
||||
</p>
|
||||
<apexchart class="primary--text" type="bar" :options="options" :series="[value]" />
|
||||
<apex-chart class="primary--text" type="bar" :options="options" :series="[value]" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</section-card>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
:color="hover"
|
||||
:height="previewHeight"
|
||||
></preview-image>
|
||||
<stream-favorite-btn :user="user" :stream="stream" class="favorite-button" />
|
||||
</router-link>
|
||||
<v-toolbar class="transparent elevation-0" dense>
|
||||
<v-toolbar-title>
|
||||
@@ -28,6 +29,8 @@
|
||||
{{ stream.branches.totalCount }}
|
||||
<v-icon x-small class="">mdi-source-commit</v-icon>
|
||||
{{ stream.commits.totalCount }}
|
||||
<v-icon x-small class="">mdi-heart-multiple</v-icon>
|
||||
{{ stream.favoritesCount }}
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
@@ -44,6 +47,7 @@
|
||||
:link-to-collabs="false"
|
||||
/>
|
||||
<div
|
||||
v-if="stream.role"
|
||||
:class="`caption text-right flex-grow-1 ${
|
||||
stream.role.split(':')[1] === 'owner' ? 'primary--text' : ''
|
||||
}`"
|
||||
@@ -63,14 +67,25 @@
|
||||
<script>
|
||||
export default {
|
||||
components: {
|
||||
PreviewImage: () => import('@/main/components/common/PreviewImage'),
|
||||
CollaboratorsDisplay: () => import('@/main/components/stream/CollaboratorsDisplay')
|
||||
PreviewImage: () => import('@/main/components/common/PreviewImage.vue'),
|
||||
CollaboratorsDisplay: () => import('@/main/components/stream/CollaboratorsDisplay'),
|
||||
StreamFavoriteBtn: () => import('@/main/components/stream/favorites/StreamFavoriteBtn.vue')
|
||||
},
|
||||
props: {
|
||||
stream: { type: Object, default: () => null },
|
||||
previewHeight: { type: Number, default: () => 180 },
|
||||
showCollabs: { type: Boolean, default: true },
|
||||
showDescription: { type: Boolean, default: true }
|
||||
showDescription: { type: Boolean, default: true },
|
||||
user: { type: Object, default: () => null }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.favorite-button {
|
||||
$margin: 10px;
|
||||
|
||||
position: absolute;
|
||||
top: $margin;
|
||||
right: $margin;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import { Viewer } from '@speckle/viewer'
|
||||
import throttle from 'lodash.throttle'
|
||||
import throttle from 'lodash/throttle'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
// - juggle the container div out of this component's dom when the component is managed out by vue
|
||||
// - juggle the container div back in of this component's dom when it's back.
|
||||
|
||||
this.$mixpanel.track('Viewer Action', { type: 'action', name: 'load' })
|
||||
this.$mixpanel.track('Viewer Action', { type: 'action', name: 'load' })
|
||||
let renderDomElement = document.getElementById('renderer')
|
||||
if (!renderDomElement) {
|
||||
renderDomElement = document.createElement('div')
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<span class="caption">+{{ collaborators.length - 4 }}</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="collaborators.length <= 5"
|
||||
v-if="stream.role === Roles.Stream.Owner && collaborators.length <= 5"
|
||||
v-tooltip="'Manage collaborators'"
|
||||
icon
|
||||
x-small
|
||||
@@ -40,16 +40,20 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { Roles } from '@/helpers/mainConstants'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserAvatar: () => import('@/main/components/common/UserAvatar')
|
||||
},
|
||||
// props: ['stream'],
|
||||
props: {
|
||||
stream: { type: Object, default: () => null },
|
||||
size: { type: Number, default: 20 },
|
||||
linkToCollabs: { type: Boolean, default: true }
|
||||
},
|
||||
data() {
|
||||
return { Roles }
|
||||
},
|
||||
computed: {
|
||||
collaborators() {
|
||||
return this.stream ? this.stream.collaborators : []
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<no-data-placeholder>
|
||||
<h2>Nothing favorited yet!</h2>
|
||||
<p class="caption">Once you mark any streams as favorite, they're going to appear here.</p>
|
||||
<template #actions>
|
||||
<v-list rounded class="transparent">
|
||||
<v-list-item link class="primary mb-4" to="/streams">
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-format-list-bulleted</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Go to your streams</v-list-item-title>
|
||||
<v-list-item-subtitle class="caption">
|
||||
Favorite streams to make them appear here
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</no-data-placeholder>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NoDataPlaceholder from '@/main/components/common/NoDataPlaceholder.vue'
|
||||
|
||||
export default {
|
||||
name: 'FavoriteStreamsPlaceholder',
|
||||
components: {
|
||||
NoDataPlaceholder
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<v-btn v-if="user && canFavorite" icon color="red darken-3" @click="onFavoriteClick">
|
||||
<v-icon>
|
||||
{{ isFavorited ? 'mdi-heart' : 'mdi-heart-outline' }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { canBeFavorited } from '@/helpers/streamHelpers'
|
||||
import { UserFavoriteStreamsQuery } from '@/graphql/user'
|
||||
import { COMMON_STREAM_FIELDS } from '@/graphql/streams'
|
||||
|
||||
export default {
|
||||
name: 'StreamFavoriteBtn',
|
||||
props: {
|
||||
stream: { type: Object, required: true },
|
||||
user: { type: Object, required: true }
|
||||
},
|
||||
computed: {
|
||||
isFavorited() {
|
||||
return !!this.stream.favoritedDate
|
||||
},
|
||||
canFavorite() {
|
||||
return canBeFavorited(this.stream)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onFavoriteClick(e) {
|
||||
e.preventDefault() // Preventing click on the parent link
|
||||
|
||||
const newIsFavorited = !this.isFavorited
|
||||
const { id, favoritesCount } = this.stream
|
||||
|
||||
// Pre-generate optimistic results
|
||||
const newFavoritedDate = newIsFavorited ? new Date().toISOString() : null
|
||||
const newFavoritesCount = favoritesCount + (newIsFavorited ? 1 : -1)
|
||||
|
||||
// Toggle favorited status
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation ($sid: String!, $favorited: Boolean!) {
|
||||
streamFavorite(streamId: $sid, favorited: $favorited) {
|
||||
id
|
||||
favoritedDate
|
||||
favoritesCount
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
sid: this.stream.id,
|
||||
favorited: newIsFavorited
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
streamFavorite: {
|
||||
__typename: 'Stream',
|
||||
id,
|
||||
favoritedDate: newFavoritedDate,
|
||||
favoritesCount: newFavoritesCount
|
||||
}
|
||||
},
|
||||
update: (cache, { data: { streamFavorite } }) => {
|
||||
const { id, favoritedDate } = streamFavorite || {}
|
||||
const isNowFavorited = !!favoritedDate
|
||||
|
||||
// Check favoriteStreams cache
|
||||
let data
|
||||
try {
|
||||
data = cache.readQuery({ query: UserFavoriteStreamsQuery })
|
||||
} catch (e) {
|
||||
// Cache isn't filled probably (sucks that this throws)
|
||||
return
|
||||
}
|
||||
|
||||
// Doesn't exist, no need to update anything
|
||||
if (!data) return
|
||||
|
||||
const streams = data?.user?.favoriteStreams?.items || []
|
||||
let newStreams, newTotalCount
|
||||
|
||||
if (isNowFavorited) {
|
||||
// Add to favorite streams query
|
||||
// Stream should be in the cache (how else are you favoriting it?)
|
||||
const stream = cache.readFragment({
|
||||
id: `Stream:${id}`,
|
||||
fragment: COMMON_STREAM_FIELDS
|
||||
})
|
||||
|
||||
newStreams = streams.slice()
|
||||
newStreams.unshift(stream)
|
||||
newTotalCount = data.user.favoriteStreams.totalCount + 1
|
||||
} else {
|
||||
// Drop from favorite streams query
|
||||
if (streams.length < 1) return
|
||||
|
||||
newStreams = streams.filter((s) => s.id !== id)
|
||||
newTotalCount = data.user.favoriteStreams.totalCount - 1
|
||||
}
|
||||
|
||||
cache.writeQuery({
|
||||
query: UserFavoriteStreamsQuery,
|
||||
data: {
|
||||
user: {
|
||||
...data.user,
|
||||
favoriteStreams: {
|
||||
...data.user.favoriteStreams,
|
||||
items: newStreams,
|
||||
totalCount: Math.min(newTotalCount - 1, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,6 +81,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { FullServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -120,21 +121,12 @@ export default {
|
||||
apollo: {
|
||||
scopes: {
|
||||
prefetch: true,
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
scopes {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
query: FullServerInfoQuery,
|
||||
update: (data) => data.serverInfo.scopes
|
||||
},
|
||||
app: {
|
||||
query: gql`
|
||||
query($id: String!) {
|
||||
query ($id: String!) {
|
||||
app(id: $id) {
|
||||
id
|
||||
name
|
||||
@@ -226,7 +218,7 @@ export default {
|
||||
try {
|
||||
let res = await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($app: AppUpdateInput!) {
|
||||
mutation ($app: AppUpdateInput!) {
|
||||
appUpdate(app: $app)
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { FullServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -88,21 +89,12 @@ export default {
|
||||
apollo: {
|
||||
scopes: {
|
||||
prefetch: true,
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
scopes {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
query: FullServerInfoQuery,
|
||||
update: (data) => data.serverInfo.scopes
|
||||
},
|
||||
app: {
|
||||
query: gql`
|
||||
query($id: String!) {
|
||||
query ($id: String!) {
|
||||
app(id: $id) {
|
||||
id
|
||||
name
|
||||
@@ -181,7 +173,7 @@ export default {
|
||||
try {
|
||||
let res = await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($app: AppCreateInput!) {
|
||||
mutation ($app: AppCreateInput!) {
|
||||
appCreate(app: $app)
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { FullServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -63,16 +64,7 @@ export default {
|
||||
},
|
||||
apollo: {
|
||||
scopes: {
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
scopes {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
query: FullServerInfoQuery,
|
||||
update: (data) => data.serverInfo.scopes
|
||||
}
|
||||
},
|
||||
@@ -115,11 +107,11 @@ export default {
|
||||
if (!this.$refs.form.validate()) return
|
||||
|
||||
this.$matomo && this.$matomo.trackPageView('user/token/create')
|
||||
this.$mixpanel.track('Token Action', { type: 'action', name: 'create' })
|
||||
this.$mixpanel.track('Token Action', { type: 'action', name: 'create' })
|
||||
try {
|
||||
let res = await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($token: ApiTokenCreateInput!) {
|
||||
mutation ($token: ApiTokenCreateInput!) {
|
||||
apiTokenCreate(token: $token)
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -38,7 +38,17 @@
|
||||
<b>Bio:</b>
|
||||
{{ user.bio ? user.bio : 'No bio provided.' }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<v-tooltip top z-index="101">
|
||||
<template #activator="{ on, attrs }">
|
||||
<span v-bind="attrs" v-on="on">
|
||||
{{ user.totalOwnedStreamsFavorites || 0 }}
|
||||
<v-icon color="red darken-3">mdi-heart</v-icon>
|
||||
</span>
|
||||
</template>
|
||||
<span>Total amount of favorites for all streams owned by this user</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
<span v-if="isSelf" class="caption">
|
||||
id:
|
||||
<code>{{ user.id }}</code>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</v-app>
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import Blurb from '@/main/components/auth/Blurb'
|
||||
import { MainServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
components: { Blurb },
|
||||
@@ -33,18 +33,7 @@ export default {
|
||||
},
|
||||
apollo: {
|
||||
serverInfo: {
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
description
|
||||
adminContact
|
||||
termsOfService
|
||||
inviteOnly
|
||||
}
|
||||
}
|
||||
`
|
||||
query: MainServerInfoQuery
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
+5
-13
@@ -58,10 +58,12 @@
|
||||
</v-app>
|
||||
</template>
|
||||
<script>
|
||||
import userQuery from '@/graphql/user.gql'
|
||||
import gql from 'graphql-tag'
|
||||
import { MainUserDataQuery } from '@/graphql/user'
|
||||
import { MainServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
name: 'TheMain',
|
||||
components: {
|
||||
MainNav: () => import('@/main/navigation/MainNav'),
|
||||
MainNavBottom: () => import('@/main/navigation/MainNavBottom'),
|
||||
@@ -72,20 +74,10 @@ export default {
|
||||
},
|
||||
apollo: {
|
||||
serverInfo: {
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
description
|
||||
adminContact
|
||||
version
|
||||
}
|
||||
}
|
||||
`
|
||||
query: MainServerInfoQuery
|
||||
},
|
||||
user: {
|
||||
query: userQuery
|
||||
query: MainUserDataQuery
|
||||
},
|
||||
$subscribe: {
|
||||
userStreamAdded: {
|
||||
@@ -33,21 +33,13 @@
|
||||
</v-card>
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { MainServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
props: { shadow: { type: Boolean, default: false } },
|
||||
apollo: {
|
||||
serverInfo: {
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
version
|
||||
company
|
||||
description
|
||||
adminContact
|
||||
}
|
||||
}
|
||||
`
|
||||
query: MainServerInfoQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,25 @@
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<portal-target name="subnav-feed" />
|
||||
<v-list-item link to="/streams">
|
||||
<v-list-item-icon>
|
||||
<v-icon class="mt-2">mdi-folder-outline</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Streams</v-list-item-title>
|
||||
<v-list-item-subtitle class="caption">All your streams</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-group prepend-icon="mdi-folder-outline mt-2" group="streams">
|
||||
<template #activator>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Streams</v-list-item-title>
|
||||
<v-list-item-subtitle class="caption">All your streams</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
|
||||
<v-list-item link to="/streams" exact-path>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Your Streams</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item link to="/streams/favorite">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Favorites</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
<portal-target name="subnav-streams" />
|
||||
<v-list-item link to="/commits">
|
||||
<v-list-item-icon>
|
||||
@@ -98,7 +108,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import userQuery from '@/graphql/user.gql'
|
||||
import { MainUserDataQuery } from '@/graphql/user'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MainLogo: () => import('@/main/navigation/MainLogo'),
|
||||
@@ -109,7 +120,7 @@ export default {
|
||||
props: { expanded: { type: Boolean, default: false }, drawer: { type: Boolean, default: true } },
|
||||
apollo: {
|
||||
user: {
|
||||
query: userQuery
|
||||
query: MainUserDataQuery
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -46,12 +46,12 @@
|
||||
</template>
|
||||
<script>
|
||||
import { signOut } from '@/plugins/authHelpers'
|
||||
import userQuery from '@/graphql/user.gql'
|
||||
import { MainUserDataQuery } from '@/graphql/user'
|
||||
|
||||
export default {
|
||||
apollo: {
|
||||
user: {
|
||||
query: userQuery,
|
||||
query: MainUserDataQuery,
|
||||
skip() {
|
||||
return !this.loggedIn
|
||||
}
|
||||
|
||||
@@ -69,11 +69,11 @@ export default {
|
||||
apollo: {
|
||||
user: {
|
||||
query: gql`
|
||||
query($cursor: String) {
|
||||
query ($cursor: String) {
|
||||
user {
|
||||
id
|
||||
name
|
||||
commits(limit: 3, cursor: $cursor) {
|
||||
commits(limit: 10, cursor: $cursor) {
|
||||
totalCount
|
||||
cursor
|
||||
items {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<portal to="toolbar">Favorite Streams</portal>
|
||||
<!-- No streams -->
|
||||
<favorite-streams-placeholder v-if="!streams.length" />
|
||||
<!-- Streams found -->
|
||||
<v-row v-else>
|
||||
<v-col v-for="stream in streams" :key="stream.id" cols="12" sm="6" md="6" lg="4" xl="3">
|
||||
<stream-preview-card :stream="stream" :user="user" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="6" lg="4" xl="3">
|
||||
<infinite-loading class="" @infinite="infiniteHandler">
|
||||
<div slot="no-more">
|
||||
<v-card class="pa-4">The end - no more streams to display.</v-card>
|
||||
</div>
|
||||
<div slot="no-results">
|
||||
<v-card class="pa-4">The end - no more streams to display.</v-card>
|
||||
</div>
|
||||
</infinite-loading>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { UserFavoriteStreamsQuery } from '@/graphql/user'
|
||||
|
||||
export default {
|
||||
name: 'TheFavoriteStreams',
|
||||
components: {
|
||||
FavoriteStreamsPlaceholder: () =>
|
||||
import('@/main/components/stream/favorites/FavoriteStreamsPlaceholder.vue'),
|
||||
InfiniteLoading: () => import('vue-infinite-loading'),
|
||||
StreamPreviewCard: () => import('@/main/components/common/StreamPreviewCard.vue')
|
||||
},
|
||||
apollo: {
|
||||
user: {
|
||||
query: UserFavoriteStreamsQuery
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streams() {
|
||||
return this.user?.favoriteStreams?.items || []
|
||||
},
|
||||
/**
|
||||
* Whether or not there are more streams to load
|
||||
*/
|
||||
allStreamsLoaded() {
|
||||
return this.streams.length && this.streams.length >= this.user.favoriteStreams.totalCount
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
infiniteHandler($state) {
|
||||
if (this.allStreamsLoaded) {
|
||||
$state.loaded()
|
||||
$state.complete()
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch more favorites
|
||||
this.$apollo.queries.user.fetchMore({
|
||||
variables: {
|
||||
cursor: this.user.favoriteStreams.cursor
|
||||
},
|
||||
updateQuery: (previousResult, { fetchMoreResult }) => {
|
||||
const newFavorites = fetchMoreResult.user.favoriteStreams
|
||||
const oldFavorites = previousResult.user.favoriteStreams
|
||||
|
||||
const { items: newItems } = newFavorites
|
||||
const { items: allItems } = oldFavorites
|
||||
|
||||
for (const stream of newItems) {
|
||||
if (allItems.findIndex((s) => s.id === stream.id) === -1) allItems.push(stream)
|
||||
}
|
||||
|
||||
// set vue-infinite state
|
||||
newItems.length === 0 ? $state.complete() : $state.loaded()
|
||||
|
||||
return {
|
||||
user: {
|
||||
...previousResult.user,
|
||||
favoriteStreams: {
|
||||
...fetchMoreResult.user.favoriteStreams,
|
||||
items: allItems
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+34
-12
@@ -46,13 +46,15 @@
|
||||
<!-- Streams display -->
|
||||
<v-row v-if="streams && streams.items.length > 0">
|
||||
<v-col v-for="(stream, i) in filteredStreams" :key="i" cols="12" sm="6" md="6" lg="4" xl="3">
|
||||
<stream-preview-card :key="i + 'card'" :stream="stream"></stream-preview-card>
|
||||
<stream-preview-card :key="i + 'card'" :stream="stream" :user="user"></stream-preview-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="6" lg="4" xl="3">
|
||||
<infinite-loading :identifier="infiniteId" class="" @infinite="infiniteHandler">
|
||||
<div slot="no-more">
|
||||
The end - no more streams to display.
|
||||
{{ streamFilter !== 1 ? 'Remove filters to see more.' : '' }}
|
||||
<v-card class="pa-4">
|
||||
The end - no more streams to display.
|
||||
{{ streamFilter !== 1 ? 'Remove filters to see more.' : '' }}
|
||||
</v-card>
|
||||
</div>
|
||||
<div slot="no-results">
|
||||
<v-card class="pa-4">
|
||||
@@ -67,12 +69,13 @@
|
||||
</template>
|
||||
<script>
|
||||
import streamsQuery from '@/graphql/streams.gql'
|
||||
import userQuery from '@/graphql/user.gql'
|
||||
import { MainUserDataQuery } from '@/graphql/user'
|
||||
|
||||
export default {
|
||||
name: 'TheStreams',
|
||||
components: {
|
||||
InfiniteLoading: () => import('vue-infinite-loading'),
|
||||
StreamPreviewCard: () => import('@/main/components/common/StreamPreviewCard'),
|
||||
StreamPreviewCard: () => import('@/main/components/common/StreamPreviewCard.vue'),
|
||||
NoDataPlaceholder: () => import('@/main/components/common/NoDataPlaceholder')
|
||||
},
|
||||
apollo: {
|
||||
@@ -80,7 +83,7 @@ export default {
|
||||
query: streamsQuery
|
||||
},
|
||||
user: {
|
||||
query: userQuery
|
||||
query: MainUserDataQuery
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -100,6 +103,12 @@ export default {
|
||||
if (this.streamFilter === 4)
|
||||
return this.streams.items.filter((s) => s.role === 'stream:reviewer')
|
||||
return this.streams.items
|
||||
},
|
||||
/**
|
||||
* Whether or not there are more streams to load
|
||||
*/
|
||||
allStreamsLoaded() {
|
||||
return this.streams && this.streams.items.length >= this.streams.totalCount
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -121,26 +130,39 @@ export default {
|
||||
if (this.streamFilter === 4 && role === 'stream:reviewer') return true
|
||||
return false
|
||||
},
|
||||
// TODO: Prevent extra load if we've hit all items
|
||||
infiniteHandler($state) {
|
||||
if (this.allStreamsLoaded) {
|
||||
$state.loaded()
|
||||
$state.complete()
|
||||
return
|
||||
}
|
||||
|
||||
this.$apollo.queries.streams.fetchMore({
|
||||
variables: {
|
||||
cursor: this.streams.cursor
|
||||
cursor: this.streams?.cursor
|
||||
},
|
||||
// Transform the previous result with new data
|
||||
updateQuery: (previousResult, { fetchMoreResult }) => {
|
||||
const newItems = fetchMoreResult.streams.items
|
||||
let allItems = [...previousResult.streams.items]
|
||||
const allItems = [...previousResult.streams.items]
|
||||
const newTotalCount = fetchMoreResult.streams.totalCount
|
||||
|
||||
for (const stream of newItems) {
|
||||
if (allItems.findIndex((s) => s.id === stream.id) === -1) allItems.push(stream)
|
||||
}
|
||||
//set vue-infinite state
|
||||
if (newItems.length === 0) $state.complete()
|
||||
else $state.loaded()
|
||||
|
||||
// Update infinite loader state
|
||||
if (newItems.length === 0) {
|
||||
$state.complete()
|
||||
} else {
|
||||
$state.loaded()
|
||||
}
|
||||
|
||||
return {
|
||||
streams: {
|
||||
__typename: previousResult.streams.__typename,
|
||||
totalCount: fetchMoreResult.streams.totalCount,
|
||||
totalCount: newTotalCount,
|
||||
cursor: fetchMoreResult.streams.cursor,
|
||||
// Merging the new streams
|
||||
items: allItems
|
||||
@@ -89,6 +89,7 @@
|
||||
import gql from 'graphql-tag'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { isEmailValid } from '@/plugins/authHelpers'
|
||||
import { MainServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
name: 'AdminInvites',
|
||||
@@ -142,14 +143,7 @@ export default {
|
||||
prefetch: true
|
||||
},
|
||||
serverInfo: {
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
name
|
||||
canonicalUrl
|
||||
}
|
||||
}
|
||||
`
|
||||
query: MainServerInfoQuery
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { MainServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
name: 'ServerInfoAdminCard',
|
||||
@@ -83,18 +84,7 @@ export default {
|
||||
},
|
||||
apollo: {
|
||||
serverInfo: {
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
description
|
||||
adminContact
|
||||
termsOfService
|
||||
inviteOnly
|
||||
}
|
||||
}
|
||||
`,
|
||||
query: MainServerInfoQuery,
|
||||
update(data) {
|
||||
delete data.serverInfo.__typename
|
||||
this.serverModifications = Object.assign({}, data.serverInfo)
|
||||
@@ -107,7 +97,7 @@ export default {
|
||||
this.loading = true
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($info: ServerInfoUpdateInput!) {
|
||||
mutation ($info: ServerInfoUpdateInput!) {
|
||||
serverInfoUpdate(info: $info)
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import debounce from 'lodash.debounce'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
export default {
|
||||
name: 'AdminStreams',
|
||||
@@ -199,7 +199,7 @@ export default {
|
||||
let ids = [this.manipulatedStream.id]
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($ids: [String!]) {
|
||||
mutation ($ids: [String!]) {
|
||||
streamsDelete(ids: $ids)
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import debounce from 'lodash.debounce'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
export default {
|
||||
name: 'UserAdmin',
|
||||
@@ -173,7 +173,7 @@ export default {
|
||||
async deleteUser(user) {
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($userEmail: String!) {
|
||||
mutation ($userEmail: String!) {
|
||||
adminDeleteUser(userConfirmation: { email: $userEmail })
|
||||
}
|
||||
`,
|
||||
@@ -215,7 +215,7 @@ export default {
|
||||
async updateUserRole(userId, newRole) {
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($userId: String!, $newRole: String!) {
|
||||
mutation ($userId: String!, $newRole: String!) {
|
||||
userRoleChange(userRoleInput: { id: $userId, role: $newRole })
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -104,11 +104,7 @@
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" class="py-2 pl-9" style="margin-top: -18px">
|
||||
<v-row
|
||||
v-show="passwordStrength !== 1 && this.form.password"
|
||||
no-gutters
|
||||
align="center"
|
||||
>
|
||||
<v-row v-show="passwordStrength !== 1 && form.password" no-gutters align="center">
|
||||
<v-col
|
||||
cols="12"
|
||||
class="flex-grow-1 flex-shrink-0"
|
||||
@@ -119,9 +115,9 @@
|
||||
height="5"
|
||||
class="mt-1 mb-0"
|
||||
:color="`${
|
||||
passwordStrength >= 75 && this.form.password === this.form.passwordConf
|
||||
passwordStrength >= 75 && form.password === form.passwordConf
|
||||
? 'green'
|
||||
: passwordStrength >= 50 && this.form.password === this.form.passwordConf
|
||||
: passwordStrength >= 50 && form.password === form.passwordConf
|
||||
? 'orange'
|
||||
: 'red'
|
||||
}`"
|
||||
@@ -129,13 +125,13 @@
|
||||
</v-col>
|
||||
<v-col cols="12" class="caption text-center mt-3">
|
||||
{{
|
||||
this.pwdSuggestions
|
||||
? this.pwdSuggestions
|
||||
: this.form.password && this.form.password === this.form.passwordConf
|
||||
pwdSuggestions
|
||||
? pwdSuggestions
|
||||
: form.password && form.password === form.passwordConf
|
||||
? 'Looks good.'
|
||||
: null
|
||||
}}
|
||||
<span v-if="this.form.password !== this.form.passwordConf">
|
||||
<span v-if="form.password !== form.passwordConf">
|
||||
<b>Passwords do not match.</b>
|
||||
</span>
|
||||
</v-col>
|
||||
@@ -161,7 +157,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import debounce from 'lodash.debounce'
|
||||
import debounce from 'lodash/debounce'
|
||||
import crs from 'crypto-random-string'
|
||||
|
||||
import Strategies from '@/main/components/auth/Strategies'
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
id="new-password"
|
||||
v-model="form.password"
|
||||
label="new password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id="new-password"
|
||||
:rules="validation.passwordRules"
|
||||
filled
|
||||
single-line
|
||||
@@ -43,7 +43,7 @@
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" class="py-2" style="margin-top: -18px">
|
||||
<v-row v-show="passwordStrength !== 1 && this.form.password" no-gutters align="center">
|
||||
<v-row v-show="passwordStrength !== 1 && form.password" no-gutters align="center">
|
||||
<v-col
|
||||
cols="12"
|
||||
class="flex-grow-1 flex-shrink-0"
|
||||
@@ -59,14 +59,8 @@
|
||||
></v-progress-linear>
|
||||
</v-col>
|
||||
<v-col cols="12" class="caption text-center mt-3">
|
||||
{{
|
||||
this.pwdSuggestions
|
||||
? this.pwdSuggestions
|
||||
: this.form.password
|
||||
? 'Looks good.'
|
||||
: null
|
||||
}}
|
||||
<span v-if="this.form.password !== this.form.passwordConf">
|
||||
{{ pwdSuggestions ? pwdSuggestions : form.password ? 'Looks good.' : null }}
|
||||
<span v-if="form.password !== form.passwordConf">
|
||||
<b>Passwords do not match.</b>
|
||||
</span>
|
||||
</v-col>
|
||||
@@ -84,7 +78,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import debounce from 'lodash.debounce'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
export default {
|
||||
name: 'ResetPasswordRequest',
|
||||
|
||||
@@ -213,9 +213,9 @@
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import serverQuery from '@/graphql/server.gql'
|
||||
import streamCollaboratorsQuery from '@/graphql/streamCollaborators.gql'
|
||||
import userSearchQuery from '@/graphql/userSearch.gql'
|
||||
import { FullServerInfoQuery } from '@/graphql/server'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -262,7 +262,7 @@ export default {
|
||||
},
|
||||
serverInfo: {
|
||||
prefetch: true,
|
||||
query: serverQuery
|
||||
query: FullServerInfoQuery
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -308,7 +308,7 @@ export default {
|
||||
async removeUser(user) {
|
||||
this.loading = true
|
||||
this.$matomo && this.$matomo.trackPageView('stream/remove-collaborator')
|
||||
this.$mixpanel.track('Permission Action', { type: 'action', name: 'remove' })
|
||||
this.$mixpanel.track('Permission Action', { type: 'action', name: 'remove' })
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
@@ -358,7 +358,7 @@ export default {
|
||||
this.$apollo.queries.stream.refetch()
|
||||
},
|
||||
async grantPermissionUser(user) {
|
||||
this.$mixpanel.track('Permission Action', { type: 'action', name: 'add' })
|
||||
this.$mixpanel.track('Permission Action', { type: 'action', name: 'add' })
|
||||
this.$matomo && this.$matomo.trackPageView('stream/add-collaborator')
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import debounce from 'lodash.debounce'
|
||||
import debounce from 'lodash/debounce'
|
||||
import streamCommitQuery from '@/graphql/commit.gql'
|
||||
import streamObjectQuery from '@/graphql/objectSingleNoData.gql'
|
||||
import Viewer from '@/main/components/common/Viewer' // do not import async
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export default {
|
||||
name: 'Details',
|
||||
name: 'StreamHome',
|
||||
components: {
|
||||
NoDataPlaceholder: () => import('@/main/components/common/NoDataPlaceholder'),
|
||||
ListItemCommit: () => import('@/main/components/stream/ListItemCommit'),
|
||||
|
||||
+13
-33
@@ -5,7 +5,7 @@
|
||||
<stream-nav :stream="stream" />
|
||||
|
||||
<!-- Stream Page App Bar (Toolbar) -->
|
||||
<stream-toolbar :stream="stream" />
|
||||
<stream-toolbar :stream="stream" :user="user" />
|
||||
|
||||
<!-- Stream Child Routes -->
|
||||
<div v-if="!error">
|
||||
@@ -26,13 +26,15 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { StreamQuery } from '@/graphql/streams'
|
||||
import { MainUserDataQuery } from '@/graphql/user'
|
||||
|
||||
export default {
|
||||
name: 'Stream',
|
||||
name: 'TheStream',
|
||||
components: {
|
||||
ErrorPlaceholder: () => import('@/main/components/common/ErrorPlaceholder'),
|
||||
StreamNav: () => import('@/main/navigation/StreamNav'),
|
||||
StreamToolbar: () => import('@/main/toolbars/StreamToolbar')
|
||||
ErrorPlaceholder: () => import('@/main/components/common/ErrorPlaceholder.vue'),
|
||||
StreamNav: () => import('@/main/navigation/StreamNav.vue'),
|
||||
StreamToolbar: () => import('@/main/toolbars/StreamToolbar.vue')
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -43,32 +45,7 @@ export default {
|
||||
},
|
||||
apollo: {
|
||||
stream: {
|
||||
query: gql`
|
||||
query Stream($id: String!) {
|
||||
stream(id: $id) {
|
||||
id
|
||||
name
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
description
|
||||
isPublic
|
||||
commits {
|
||||
totalCount
|
||||
}
|
||||
collaborators {
|
||||
id
|
||||
name
|
||||
role
|
||||
company
|
||||
avatar
|
||||
}
|
||||
branches {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
query: StreamQuery,
|
||||
variables() {
|
||||
return {
|
||||
id: this.$route.params.streamId
|
||||
@@ -79,10 +56,13 @@ export default {
|
||||
else this.error = err
|
||||
}
|
||||
},
|
||||
user: {
|
||||
query: MainUserDataQuery
|
||||
},
|
||||
$subscribe: {
|
||||
branchCreated: {
|
||||
query: gql`
|
||||
subscription($streamId: String!) {
|
||||
subscription ($streamId: String!) {
|
||||
branchCreated(streamId: $streamId)
|
||||
}
|
||||
`,
|
||||
@@ -107,7 +87,7 @@ export default {
|
||||
},
|
||||
commitCreated: {
|
||||
query: gql`
|
||||
subscription($streamId: String!) {
|
||||
subscription ($streamId: String!) {
|
||||
commitCreated(streamId: $streamId)
|
||||
}
|
||||
`,
|
||||
@@ -58,6 +58,7 @@ export default {
|
||||
profiles
|
||||
role
|
||||
suuid
|
||||
totalOwnedStreamsFavorites
|
||||
streams {
|
||||
totalCount
|
||||
items {
|
||||
|
||||
@@ -48,8 +48,9 @@
|
||||
</v-container>
|
||||
</template>
|
||||
<script>
|
||||
import userQuery from '@/graphql/user.gql'
|
||||
import { ProfileSelfQuery } from '@/graphql/user'
|
||||
import { signOut } from '@/plugins/authHelpers'
|
||||
|
||||
export default {
|
||||
name: 'Profile',
|
||||
components: {
|
||||
@@ -63,7 +64,7 @@ export default {
|
||||
data: () => ({}),
|
||||
apollo: {
|
||||
user: {
|
||||
query: userQuery
|
||||
query: ProfileSelfQuery
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
|
||||
@@ -57,7 +57,7 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Home | Speckle'
|
||||
},
|
||||
component: () => import('@/main/layouts/Main.vue'),
|
||||
component: () => import('@/main/layouts/TheMain.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -81,14 +81,22 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Streams | Speckle'
|
||||
},
|
||||
component: () => import('@/main/pages/Streams.vue')
|
||||
component: () => import('@/main/pages/TheStreams.vue')
|
||||
},
|
||||
{
|
||||
path: 'streams/favorite',
|
||||
name: 'favorite-streams',
|
||||
meta: {
|
||||
title: 'Favorite Streams | Speckle'
|
||||
},
|
||||
component: () => import('@/main/pages/TheFavoriteStreams.vue')
|
||||
},
|
||||
{
|
||||
path: 'streams/:streamId',
|
||||
meta: {
|
||||
title: 'Stream | Speckle'
|
||||
},
|
||||
component: () => import('@/main/pages/stream/Stream.vue'),
|
||||
component: () => import('@/main/pages/stream/TheStream.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
{{ stream.commits.totalCount }}
|
||||
<v-icon style="font-size: 11px" class="ml-1">mdi-source-branch</v-icon>
|
||||
{{ stream.branches.totalCount }}
|
||||
<v-icon x-small class="">mdi-heart-multiple</v-icon>
|
||||
{{ stream.favoritesCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-none d-sm-inline-block">
|
||||
@@ -39,14 +41,15 @@
|
||||
</div>
|
||||
</portal>
|
||||
<portal to="actions">
|
||||
<stream-favorite-btn :stream="stream" :user="user" />
|
||||
<v-btn
|
||||
v-if="stream"
|
||||
v-tooltip="'Share this stream'"
|
||||
elevation="0"
|
||||
text
|
||||
rounded
|
||||
@click="shareStream = true"
|
||||
class="mr-1"
|
||||
@click="shareStream = true"
|
||||
>
|
||||
<v-icon v-if="!stream.isPublic" x-small class="mr-1 grey--text">mdi-lock</v-icon>
|
||||
<v-icon v-else x-small class="mr-1 grey--text">mdi-lock-open</v-icon>
|
||||
@@ -66,9 +69,13 @@
|
||||
export default {
|
||||
components: {
|
||||
CollaboratorsDisplay: () => import('@/main/components/stream/CollaboratorsDisplay'),
|
||||
ShareStreamDialog: () => import('@/main/dialogs/ShareStream')
|
||||
ShareStreamDialog: () => import('@/main/dialogs/ShareStream'),
|
||||
StreamFavoriteBtn: () => import('@/main/components/stream/favorites/StreamFavoriteBtn.vue')
|
||||
},
|
||||
props: {
|
||||
stream: { type: Object, required: true },
|
||||
user: { type: Object, required: true }
|
||||
},
|
||||
props: ['stream'],
|
||||
data() {
|
||||
return { shareStream: false }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { MainUserDataQuery } from '@/graphql/user'
|
||||
import { LocalStorageKeys } from '@/helpers/mainConstants'
|
||||
import { createProvider } from '@/vue-apollo'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const appId = 'spklwebapp'
|
||||
@@ -13,8 +16,8 @@ export async function checkAccessCodeAndGetTokens() {
|
||||
let response = await getTokenFromAccessCode(accessCode)
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (response.hasOwnProperty('token')) {
|
||||
localStorage.setItem('AuthToken', response.token)
|
||||
localStorage.setItem('RefreshToken', response.refreshToken)
|
||||
localStorage.setItem(LocalStorageKeys.AuthToken, response.token)
|
||||
localStorage.setItem(LocalStorageKeys.RefreshToken, response.refreshToken)
|
||||
window.history.replaceState({}, document.title, '/')
|
||||
return true
|
||||
}
|
||||
@@ -24,37 +27,33 @@ export async function checkAccessCodeAndGetTokens() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user id and suuid and sets them in local storage.
|
||||
* Gets the user id and suuid, sets them in local storage
|
||||
* @param {import('apollo-client').ApolloClient} apolloClient
|
||||
* @return {Object} The full graphql response.
|
||||
*/
|
||||
export async function prefetchUserAndSetSuuid() {
|
||||
let token = localStorage.getItem('AuthToken')
|
||||
if (token) {
|
||||
let testResponse = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ query: `{ user { id email suuid streams { totalCount } } }` })
|
||||
})
|
||||
export async function prefetchUserAndSetSuuid(apolloClient) {
|
||||
let token = localStorage.getItem(LocalStorageKeys.AuthToken)
|
||||
if (!token) return
|
||||
|
||||
let data = (await testResponse.json()).data
|
||||
if (data.user) {
|
||||
// eslint-disable-next-line camelcase
|
||||
let distinct_id =
|
||||
'@' +
|
||||
crypto.createHash('md5').update(data.user.email.toLowerCase()).digest('hex').toUpperCase()
|
||||
// Pull user info (& remember it in the Apollo cache)
|
||||
const { data } = await apolloClient.query({
|
||||
query: MainUserDataQuery
|
||||
})
|
||||
|
||||
localStorage.setItem('suuid', data.user.suuid)
|
||||
localStorage.setItem('distinct_id', distinct_id)
|
||||
localStorage.setItem('uuid', data.user.id)
|
||||
localStorage.setItem('stcount', data.user.streams.totalCount)
|
||||
return data
|
||||
} else {
|
||||
await signOut()
|
||||
throw new Error('Failed to set user')
|
||||
}
|
||||
if (data.user) {
|
||||
// eslint-disable-next-line camelcase
|
||||
let distinct_id =
|
||||
'@' +
|
||||
crypto.createHash('md5').update(data.user.email.toLowerCase()).digest('hex').toUpperCase()
|
||||
|
||||
localStorage.setItem('suuid', data.user.suuid)
|
||||
localStorage.setItem('distinct_id', distinct_id)
|
||||
localStorage.setItem('uuid', data.user.id)
|
||||
localStorage.setItem('stcount', data.user.streams.totalCount)
|
||||
return data
|
||||
} else {
|
||||
await signOut()
|
||||
throw new Error('Failed to set user')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +86,13 @@ export async function signOut(mixpanelInstance) {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: localStorage.getItem('AuthToken'),
|
||||
refreshToken: localStorage.getItem('RefreshToken')
|
||||
token: localStorage.getItem(LocalStorageKeys.AuthToken),
|
||||
refreshToken: localStorage.getItem(LocalStorageKeys.RefreshToken)
|
||||
})
|
||||
})
|
||||
|
||||
localStorage.removeItem('AuthToken')
|
||||
localStorage.removeItem('RefreshToken')
|
||||
localStorage.removeItem(LocalStorageKeys.AuthToken)
|
||||
localStorage.removeItem(LocalStorageKeys.RefreshToken)
|
||||
localStorage.removeItem('suuid')
|
||||
localStorage.removeItem('uuid')
|
||||
localStorage.removeItem('distinct_id')
|
||||
@@ -103,13 +102,13 @@ export async function signOut(mixpanelInstance) {
|
||||
window.location = '/'
|
||||
|
||||
if (mixpanelInstance) {
|
||||
mixpanelInstance.track('Log Out', { type: 'action' })
|
||||
mixpanelInstance.track('Log Out', { type: 'action' })
|
||||
mixpanelInstance.reset()
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshToken() {
|
||||
let refreshToken = localStorage.getItem('RefreshToken')
|
||||
let refreshToken = localStorage.getItem(LocalStorageKeys.RefreshToken)
|
||||
if (!refreshToken) throw new Error('No refresh token found')
|
||||
|
||||
let refreshResponse = await fetch('/auth/token', {
|
||||
@@ -128,14 +127,15 @@ export async function refreshToken() {
|
||||
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (data.hasOwnProperty('token')) {
|
||||
localStorage.setItem('AuthToken', data.token)
|
||||
localStorage.setItem('RefreshToken', data.refreshToken)
|
||||
localStorage.setItem(LocalStorageKeys.AuthToken, data.token)
|
||||
localStorage.setItem(LocalStorageKeys.RefreshToken, data.refreshToken)
|
||||
await prefetchUserAndSetSuuid()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmailValid(email) {
|
||||
const emailValidator = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
const emailValidator =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
return emailValidator.test(email)
|
||||
}
|
||||
|
||||
@@ -2,117 +2,116 @@
|
||||
import Vue from 'vue'
|
||||
import VueApollo from 'vue-apollo'
|
||||
import { createApolloClient, restartWebsockets } from 'vue-cli-plugin-apollo/graphql-client'
|
||||
import { SubscriptionClient } from 'subscriptions-transport-ws';
|
||||
import { SubscriptionClient } from 'subscriptions-transport-ws'
|
||||
import { LocalStorageKeys } from '@/helpers/mainConstants'
|
||||
|
||||
// Install the vue plugin
|
||||
Vue.use( VueApollo )
|
||||
Vue.use(VueApollo)
|
||||
|
||||
// Name of the localStorage item
|
||||
const AUTH_TOKEN = 'AuthToken'
|
||||
let hasAuthToken = !!localStorage.getItem(AUTH_TOKEN)
|
||||
|
||||
const AUTH_TOKEN = LocalStorageKeys.AuthToken
|
||||
// Http endpoint
|
||||
const httpEndpoint = process.env.VUE_APP_GRAPHQL_HTTP || `${window.location.origin}/graphql`
|
||||
// WS endpoint
|
||||
const wsEndpoint = process.env.VUE_APP_GRAPHQL_WS || `${window.location.origin.replace('http', 'ws')}/graphql`
|
||||
const wsEndpoint =
|
||||
process.env.VUE_APP_GRAPHQL_WS || `${window.location.origin.replace('http', 'ws')}/graphql`
|
||||
|
||||
// Subscription Client
|
||||
const subscriptionClient = new SubscriptionClient( wsEndpoint, {
|
||||
reconnect: hasAuthToken,
|
||||
connectionParams:{
|
||||
headers:{
|
||||
'Authorization' : localStorage.getItem(AUTH_TOKEN)
|
||||
}
|
||||
}
|
||||
} )
|
||||
|
||||
// Config
|
||||
const defaultOptions = {
|
||||
// You can use `https` for secure connection (recommended in production)
|
||||
httpEndpoint,
|
||||
// You can use `wss` for secure connection (recommended in production)
|
||||
// Use `null` to disable subscriptions
|
||||
wsEndpoint: hasAuthToken ? wsEndpoint : null,
|
||||
// LocalStorage token
|
||||
tokenName: AUTH_TOKEN,
|
||||
// Enable Automatic Query persisting with Apollo Engine
|
||||
persisting: false,
|
||||
// Use websockets for everything (no HTTP)
|
||||
// You need to pass a `wsEndpoint` for this to work
|
||||
websocketsOnly: false,
|
||||
// Is being rendered on the server?
|
||||
ssr: false,
|
||||
// Subscription Client
|
||||
networkInterface: subscriptionClient
|
||||
|
||||
// Override default apollo link
|
||||
// note: don't override httpLink here, specify httpLink options in the
|
||||
// httpLinkOptions property of defaultOptions.
|
||||
// link: myLink
|
||||
|
||||
// Override default cache
|
||||
// cache: myCache
|
||||
|
||||
// Override the way the Authorization header is set
|
||||
// getAuth: (tokenName) => ...
|
||||
|
||||
// Additional ApolloClient options
|
||||
// apollo: { ... }
|
||||
|
||||
// Client local data (see apollo-link-state)
|
||||
// clientState: { resolvers: { ... }, defaults: { ... } }
|
||||
function hasAuthToken() {
|
||||
return !!localStorage.getItem(AUTH_TOKEN)
|
||||
}
|
||||
|
||||
// Call this in the Vue app file
|
||||
export function createProvider( options = {} ) {
|
||||
function createSubscriptionClient() {
|
||||
return new SubscriptionClient(wsEndpoint, {
|
||||
reconnect: hasAuthToken(),
|
||||
connectionParams: {
|
||||
headers: {
|
||||
Authorization: localStorage.getItem(AUTH_TOKEN)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getDefaultOptions() {
|
||||
return {
|
||||
// You can use `https` for secure connection (recommended in production)
|
||||
httpEndpoint,
|
||||
// You can use `wss` for secure connection (recommended in production)
|
||||
// Use `null` to disable subscriptions
|
||||
wsEndpoint: hasAuthToken() ? wsEndpoint : null,
|
||||
// LocalStorage token
|
||||
tokenName: AUTH_TOKEN,
|
||||
// Enable Automatic Query persisting with Apollo Engine
|
||||
persisting: false,
|
||||
// Use websockets for everything (no HTTP)
|
||||
// You need to pass a `wsEndpoint` for this to work
|
||||
websocketsOnly: false,
|
||||
// Is being rendered on the server?
|
||||
ssr: false,
|
||||
// Subscription Client
|
||||
networkInterface: createSubscriptionClient(),
|
||||
// Extra cache settings
|
||||
inMemoryCacheOptions: {
|
||||
cacheRedirects: {
|
||||
Query: {
|
||||
user: (_, args, { getCacheKey }) => getCacheKey({ __typename: 'User', id: args.id }),
|
||||
stream: (_, args, { getCacheKey }) => getCacheKey({ __typename: 'Stream', id: args.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create a Vue Apollo provider instance
|
||||
*/
|
||||
export function createProvider(options = {}) {
|
||||
// console.log( defaultOptions )
|
||||
// Create apollo client
|
||||
const { apolloClient, wsClient } = createApolloClient( {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
} )
|
||||
apolloClient.wsClient = hasAuthToken ? wsClient : null
|
||||
const { apolloClient, wsClient } = createApolloClient({
|
||||
...getDefaultOptions(),
|
||||
...options
|
||||
})
|
||||
apolloClient.wsClient = hasAuthToken() ? wsClient : null
|
||||
|
||||
// Create vue apollo provider
|
||||
const apolloProvider = new VueApollo( {
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: apolloClient,
|
||||
defaultOptions: {
|
||||
$query: {
|
||||
// fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
}
|
||||
},
|
||||
errorHandler( error ) {
|
||||
errorHandler(error) {
|
||||
// eslint-disable-next-line no-console
|
||||
// console.log( '%cError', 'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;', error.message )
|
||||
},
|
||||
} )
|
||||
}
|
||||
})
|
||||
|
||||
return apolloProvider
|
||||
}
|
||||
|
||||
// Manually call this when user log in
|
||||
export async function onLogin( apolloClient, token ) {
|
||||
if ( typeof localStorage !== 'undefined' && token ) {
|
||||
localStorage.setItem( AUTH_TOKEN, token )
|
||||
export async function onLogin(apolloClient, token) {
|
||||
if (typeof localStorage !== 'undefined' && token) {
|
||||
localStorage.setItem(AUTH_TOKEN, token)
|
||||
}
|
||||
if ( apolloClient.wsClient ) restartWebsockets( apolloClient.wsClient )
|
||||
if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient)
|
||||
try {
|
||||
await apolloClient.resetStore( )
|
||||
} catch ( e ) {
|
||||
await apolloClient.resetStore()
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
// console.log( '%cError on cache reset (login)', 'color: orange;', e.message )
|
||||
}
|
||||
}
|
||||
|
||||
// Manually call this when user log out
|
||||
export async function onLogout( apolloClient ) {
|
||||
if ( typeof localStorage !== 'undefined' ) {
|
||||
localStorage.removeItem( AUTH_TOKEN )
|
||||
export async function onLogout(apolloClient) {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(AUTH_TOKEN)
|
||||
}
|
||||
if ( apolloClient.wsClient ) restartWebsockets( apolloClient.wsClient )
|
||||
if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient)
|
||||
try {
|
||||
await apolloClient.resetStore( )
|
||||
} catch ( e ) {
|
||||
await apolloClient.resetStore()
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
// console.log( '%cError on cache reset (logout)', 'color: orange;', e.message )
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
module.exports = {
|
||||
configureWebpack: {
|
||||
devtool: 'source-map'
|
||||
devtool: 'eval-source-map'
|
||||
},
|
||||
productionSourceMap: false,
|
||||
pages: {
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
env: {
|
||||
browser: true
|
||||
browser: true,
|
||||
es2022: true
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 13
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
+6
-1
@@ -1,3 +1,8 @@
|
||||
{
|
||||
"mochaExplorer.env": { "NODE_ENV": "test" }
|
||||
"mochaExplorer.env": { "NODE_ENV": "test" },
|
||||
"mochaExplorer.files": "src/**/*.spec.js",
|
||||
"javascript.suggest.autoImports": true,
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
||||
+53
-40
@@ -3,7 +3,6 @@
|
||||
|
||||
require('./bootstrap')
|
||||
const http = require('http')
|
||||
const url = require('url')
|
||||
const express = require('express')
|
||||
// `express-async-errors` patches express to catch errors in async handlers. no variable needed
|
||||
require('express-async-errors')
|
||||
@@ -11,12 +10,10 @@ const compression = require('compression')
|
||||
const appRoot = require('app-root-path')
|
||||
const logger = require('morgan-debug')
|
||||
const bodyParser = require('body-parser')
|
||||
const path = require('path')
|
||||
const debug = require('debug')
|
||||
const { createTerminus } = require('@godaddy/terminus')
|
||||
|
||||
const Sentry = require('@sentry/node')
|
||||
const Tracing = require('@sentry/tracing')
|
||||
const Logging = require(`${appRoot}/logging`)
|
||||
const { startup: MatStartup } = require(`${appRoot}/logging/matomoHelper`)
|
||||
const { errorLoggingMiddleware } = require(`${appRoot}/logging/errorLogging`)
|
||||
@@ -24,46 +21,27 @@ const prometheusClient = require('prom-client')
|
||||
|
||||
const { ApolloServer, ForbiddenError } = require('apollo-server-express')
|
||||
|
||||
const { contextApiTokenHelper } = require('./modules/shared')
|
||||
const { buildContext } = require('./modules/shared')
|
||||
const knex = require('./db/knex')
|
||||
const { buildErrorFormatter } = require('@/modules/core/graph/setup')
|
||||
const { isDevEnv, isTestEnv } = require('@/modules/core/helpers/envHelper')
|
||||
|
||||
let graphqlServer
|
||||
|
||||
/**
|
||||
* Initialises the express application together with the graphql server middleware.
|
||||
* @return {[type]} an express application and the graphql server
|
||||
* Create Apollo Server instance
|
||||
* @param {Partial<import('apollo-server-express').ApolloServerExpressConfig>} optionOverrides Optionally override ctor options
|
||||
* @returns {import('apollo-server-express').ApolloServer}
|
||||
*/
|
||||
exports.init = async () => {
|
||||
const app = express()
|
||||
exports.buildApolloServer = (optionOverrides) => {
|
||||
const debug = optionOverrides?.debug || isDevEnv() || isTestEnv()
|
||||
const { graph } = require('./modules')
|
||||
|
||||
Logging(app)
|
||||
MatStartup()
|
||||
|
||||
// Initialise prometheus metrics
|
||||
// (Re-)Initialise prometheus metrics
|
||||
prometheusClient.register.clear()
|
||||
prometheusClient.collectDefaultMetrics()
|
||||
|
||||
// Moves things along automatically on restart.
|
||||
// Should perhaps be done manually?
|
||||
await knex.migrate.latest()
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(logger('speckle', 'dev', {}))
|
||||
}
|
||||
|
||||
if (process.env.COMPRESSION) {
|
||||
app.use(compression())
|
||||
}
|
||||
|
||||
app.use(bodyParser.json({ limit: '100mb' }))
|
||||
app.use(bodyParser.urlencoded({ limit: '100mb', extended: false }))
|
||||
|
||||
const { init, graph } = require('./modules')
|
||||
|
||||
// Initialise default modules, including rest api handlers
|
||||
await init(app)
|
||||
|
||||
// Initialise graphql server
|
||||
// Init metrics
|
||||
const metricConnectCounter = new prometheusClient.Counter({
|
||||
name: 'speckle_server_apollo_connect',
|
||||
help: 'Number of connects'
|
||||
@@ -72,11 +50,12 @@ exports.init = async () => {
|
||||
name: 'speckle_server_apollo_clients',
|
||||
help: 'Number of currently connected clients'
|
||||
})
|
||||
graphqlServer = new ApolloServer({
|
||||
|
||||
return new ApolloServer({
|
||||
...graph(),
|
||||
context: contextApiTokenHelper,
|
||||
context: buildContext,
|
||||
subscriptions: {
|
||||
onConnect: (connectionParams, webSocket, context) => {
|
||||
onConnect: (connectionParams) => {
|
||||
metricConnectCounter.inc()
|
||||
metricConnectedClients.inc()
|
||||
try {
|
||||
@@ -96,17 +75,51 @@ exports.init = async () => {
|
||||
throw new ForbiddenError('You need a token to subscribe')
|
||||
}
|
||||
},
|
||||
onDisconnect: (webSocket, context) => {
|
||||
onDisconnect: () => {
|
||||
metricConnectedClients.dec()
|
||||
// debug( `speckle:debug` )( 'ws on disconnect connect event' )
|
||||
}
|
||||
},
|
||||
plugins: [require(`${appRoot}/logging/apolloPlugin`)],
|
||||
tracing: process.env.NODE_ENV === 'development',
|
||||
tracing: debug,
|
||||
introspection: true,
|
||||
playground: true
|
||||
playground: true,
|
||||
formatError: buildErrorFormatter(debug),
|
||||
debug,
|
||||
...optionOverrides
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises the express application together with the graphql server middleware.
|
||||
*/
|
||||
exports.init = async () => {
|
||||
const app = express()
|
||||
|
||||
Logging(app)
|
||||
MatStartup()
|
||||
|
||||
// Moves things along automatically on restart.
|
||||
// Should perhaps be done manually?
|
||||
await knex.migrate.latest()
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(logger('speckle', 'dev', {}))
|
||||
}
|
||||
|
||||
if (process.env.COMPRESSION) {
|
||||
app.use(compression())
|
||||
}
|
||||
|
||||
app.use(bodyParser.json({ limit: '100mb' }))
|
||||
app.use(bodyParser.urlencoded({ limit: '100mb', extended: false }))
|
||||
|
||||
const { init } = require('./modules')
|
||||
|
||||
// Initialise default modules, including rest api handlers
|
||||
await init(app)
|
||||
|
||||
// Initialise graphql server
|
||||
graphqlServer = module.exports.buildApolloServer()
|
||||
graphqlServer.applyMiddleware({ app: app })
|
||||
|
||||
// Expose prometheus metrics
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
const path = require('path')
|
||||
const yargs = require('yargs')
|
||||
require('../bootstrap')
|
||||
|
||||
const execution = yargs
|
||||
.scriptName('./bin/cli')
|
||||
.usage('$0 <cmd> [args]')
|
||||
.commandDir(path.resolve(__dirname, '../modules/cli/commands'))
|
||||
.demandCommand()
|
||||
.fail((msg, err, yargs) => {
|
||||
if (!err) {
|
||||
// If validation error (no err instance) then just show help and show the message
|
||||
console.error(yargs.help())
|
||||
console.error('\n', msg)
|
||||
} else {
|
||||
// If actual app error occurred, show the msg, but don't show help info
|
||||
console.error(err)
|
||||
console.error('\n', 'Specify --help for available options')
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
})
|
||||
.help().argv
|
||||
|
||||
const promise = Promise.resolve(execution)
|
||||
promise.then(() => {
|
||||
yargs.exit(0)
|
||||
})
|
||||
Vendored
+8
-2
@@ -2,11 +2,17 @@
|
||||
/**
|
||||
* Bootstrap module that should be imported at the very top of each entry point module
|
||||
*/
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
// Initializing module aliases for absolute import paths
|
||||
require('module-alias')({ base: __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 (if it even exists)
|
||||
// If running in test env, load .env.test first
|
||||
// (appRoot necessary, cause env files aren't loaded through require() calls)
|
||||
if (isTestEnv()) {
|
||||
dotenv.config({ path: `${appRoot}/.env.test` })
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* istanbul ignore file */
|
||||
'use strict'
|
||||
|
||||
let env = process.env.NODE_ENV || 'development'
|
||||
let conf = require('../knexfile.js')[env]
|
||||
const env = process.env.NODE_ENV || 'development'
|
||||
const configs = require('@/knexfile.js')
|
||||
const config = configs[env]
|
||||
|
||||
conf.log = {
|
||||
config.log = {
|
||||
warn(message) {
|
||||
if (
|
||||
message ===
|
||||
@@ -18,4 +19,11 @@ const debug = require('debug')
|
||||
|
||||
debug('speckle:db-startup')(`Loaded knex conf for ${env}`)
|
||||
|
||||
module.exports = require('knex')(conf)
|
||||
/**
|
||||
* Need to override type because type def file incorrectly uses ES6
|
||||
* @type {import('knex').default}
|
||||
*/
|
||||
const knex = require('knex')
|
||||
const knexInstance = knex(config)
|
||||
|
||||
module.exports = knexInstance
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"extends": "../../jsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["bin/www", "db", "logging", "scripts", "modules", "test"]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ function walk(dir) {
|
||||
return results
|
||||
}
|
||||
|
||||
let migrationDirs = walk('./modules')
|
||||
let migrationDirs = walk(path.resolve(__dirname, './modules'))
|
||||
|
||||
// this is for readability, many users struggle to set the postgres connection uri
|
||||
// in the env variables. This way its a bit easier to understand, also backward compatible.
|
||||
@@ -33,7 +33,8 @@ if (env.POSTGRES_USER && env.POSTGRES_PASSWORD) {
|
||||
connectionUri = env.POSTGRES_URL
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
/** @type {Object<string, import('knex').Knex.Config>} */
|
||||
const config = {
|
||||
test: {
|
||||
client: 'pg',
|
||||
connection: connectionUri || 'postgres://localhost/speckle2_test',
|
||||
@@ -57,3 +58,5 @@ module.exports = {
|
||||
pool: { min: 2, max: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @type {import('yargs').CommandModule} */
|
||||
const command = {
|
||||
command: 'db',
|
||||
describe: 'DB & Migration actions',
|
||||
builder(yargs) {
|
||||
return yargs.commandDir('db').demandCommand()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = command
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @type {import('yargs').CommandModule} */
|
||||
const command = {
|
||||
command: 'migrate',
|
||||
describe: 'Migration specific commands',
|
||||
builder(yargs) {
|
||||
return yargs.commandDir('migrate').demandCommand()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = command
|
||||
@@ -0,0 +1,41 @@
|
||||
const knex = require('@/db/knex')
|
||||
const appRoot = require('app-root-path')
|
||||
const fs = require('fs/promises')
|
||||
|
||||
/** @type {import('yargs').CommandModule} */
|
||||
const command = {
|
||||
command: 'create <name> [module]',
|
||||
describe: 'Create a new migration',
|
||||
builder(yargs) {
|
||||
return yargs
|
||||
.positional('name', {
|
||||
describe: 'Migration name',
|
||||
type: 'string'
|
||||
})
|
||||
.positional('module', {
|
||||
describe: 'The server module into which this migration should be generated',
|
||||
type: 'string',
|
||||
default: 'core'
|
||||
})
|
||||
},
|
||||
async handler(argv) {
|
||||
const name = argv.name
|
||||
const migrationDir = `${appRoot}/modules/${argv.module}/migrations`
|
||||
|
||||
try {
|
||||
await fs.access(migrationDir)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Migration directory '${migrationDir}' is not accessible! Check if it exists.`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Creating migration...')
|
||||
await knex.migrate.make(name, {
|
||||
directory: migrationDir
|
||||
})
|
||||
console.log('...done')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = command
|
||||
@@ -0,0 +1,14 @@
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
/** @type {import('yargs').CommandModule} */
|
||||
const command = {
|
||||
command: 'down',
|
||||
describe: 'Undo last run migration',
|
||||
async handler() {
|
||||
console.log('Undoing last migration...')
|
||||
await knex.migrate.down()
|
||||
console.log('...done')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = command
|
||||
@@ -0,0 +1,14 @@
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
/** @type {import('yargs').CommandModule} */
|
||||
const command = {
|
||||
command: 'latest',
|
||||
describe: 'Run all migrations that have not yet been run',
|
||||
async handler() {
|
||||
console.log('Running...')
|
||||
await knex.migrate.latest()
|
||||
console.log('...done')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = command
|
||||
@@ -0,0 +1,14 @@
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
/** @type {import('yargs').CommandModule} */
|
||||
const command = {
|
||||
command: 'rollback',
|
||||
describe: 'Roll back all migrations',
|
||||
async handler() {
|
||||
console.log('Rolling back...')
|
||||
await knex.migrate.rollback(null, true)
|
||||
console.log('...done')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = command
|
||||
@@ -0,0 +1,14 @@
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
/** @type {import('yargs').CommandModule} */
|
||||
const command = {
|
||||
command: 'up',
|
||||
describe: 'Run next migration that has not yet been run',
|
||||
async handler() {
|
||||
console.log('Running...')
|
||||
await knex.migrate.up()
|
||||
console.log('...done')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = command
|
||||
@@ -0,0 +1,30 @@
|
||||
# Using CLI
|
||||
|
||||
You can run it like so from the `server` package's root directory: `./bin/cli`
|
||||
|
||||
Use the `--help` argument to get more info about each command.
|
||||
|
||||
Example for running migrations: `./bin/cli db migrate latest`
|
||||
|
||||
# Creating new commands
|
||||
|
||||
CLI is defined using [yargs](https://yargs.js.org/).We use it to define hierarchical trees of commands which allows for better organization both for command definition and for using the CLI.
|
||||
|
||||
All commands are created in the `commands` directory. Commands should be defined using [command modules](https://github.com/yargs/yargs/blob/main/docs/advanced.md#providing-a-command-module).
|
||||
|
||||
Any top-level modules under `commands` will be assumed to be command modules. If you want to define a child command for a top-level command, then configure the top-level command to look for further child commands using `.commandDir()`.
|
||||
|
||||
Then put those child command modules in a subdirectory that is named after the top level command. So if the top level command is "db", then all of its child commands should be put inside a "db" subfolder.
|
||||
|
||||
Example commands dir:
|
||||
|
||||
```
|
||||
- commands
|
||||
- db
|
||||
- migrate.js
|
||||
- db.js
|
||||
```
|
||||
|
||||
In this case, `db.js` is the command module for the top-level `db` command. And then inside the `db` folder there's a command module `migrate.js` for the `migrate` child command of `db`.
|
||||
|
||||
This results in 2 commands - `db` and `db migrate`.
|
||||
@@ -0,0 +1,59 @@
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
/**
|
||||
* Single source of truth for DB schema in the codebase
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
Streams: {
|
||||
name: 'streams',
|
||||
knex: () => knex('streams'),
|
||||
col: {
|
||||
id: 'streams.id',
|
||||
name: 'streams.name',
|
||||
description: 'streams.description',
|
||||
isPublic: 'streams.isPublic',
|
||||
clonedFrom: 'streams.clonedFrom',
|
||||
createdAt: 'streams.createdAt',
|
||||
updatedAt: 'streams.updatedAt'
|
||||
}
|
||||
},
|
||||
StreamAcl: {
|
||||
name: 'stream_acl',
|
||||
knex: () => knex('stream_acl'),
|
||||
col: {
|
||||
userId: 'stream_acl.userId',
|
||||
resourceId: 'stream_acl.resourceId',
|
||||
role: 'stream_acl.role'
|
||||
}
|
||||
},
|
||||
StreamFavorites: {
|
||||
name: 'stream_favorites',
|
||||
knex: () => knex('stream_favorites'),
|
||||
col: {
|
||||
streamId: 'stream_favorites.streamId',
|
||||
userId: 'stream_favorites.userId',
|
||||
createdAt: 'stream_favorites.createdAt',
|
||||
cursor: 'stream_favorites.cursor'
|
||||
}
|
||||
},
|
||||
Users: {
|
||||
name: 'users',
|
||||
knex: () => knex('users'),
|
||||
col: {
|
||||
id: 'users.id',
|
||||
suuid: 'users.suuid',
|
||||
createdAt: 'users.createdAt',
|
||||
name: 'users.name',
|
||||
bio: 'users.bio',
|
||||
company: 'users.company',
|
||||
email: 'users.email',
|
||||
verified: 'users.verified',
|
||||
avatar: 'users.avatar',
|
||||
profiles: 'users.profiles',
|
||||
passwordDigest: 'users.passwordDigest',
|
||||
ip: 'users.ip'
|
||||
}
|
||||
},
|
||||
knex
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const VError = require('verror')
|
||||
|
||||
/**
|
||||
* Base application error (don't use directly, treat it as abstract). Built on top of `verror` so that you can
|
||||
* chain errors (e.cause is the previous error) and also add arbitrary metadata using the `info` option.
|
||||
*
|
||||
* This allows for much nicer error handling & monitoring
|
||||
*/
|
||||
class BaseError extends VError {
|
||||
/**
|
||||
* Error code (override in child class)
|
||||
*/
|
||||
static code = 'BASE_APP_ERROR'
|
||||
|
||||
/**
|
||||
* Default message if none is passed
|
||||
*/
|
||||
static defaultMessage = 'Unexpected error occurred!'
|
||||
|
||||
/**
|
||||
* @param {string | null} message
|
||||
* @param {import('verror').Options | Error} options
|
||||
*/
|
||||
constructor(message, options) {
|
||||
// Resolve options correctly
|
||||
if (options) {
|
||||
const cause = options instanceof Error ? options : options.cause
|
||||
options = options instanceof Error ? { cause } : options
|
||||
} else {
|
||||
options = {}
|
||||
}
|
||||
|
||||
const info = {
|
||||
...(options.info || {}),
|
||||
code: new.target.code
|
||||
}
|
||||
|
||||
options.info = info
|
||||
|
||||
// Get message from defaultMessage, if it's empty
|
||||
if (!message) {
|
||||
message = new.target.defaultMessage
|
||||
}
|
||||
|
||||
// Resolve constructor name
|
||||
const constructorName = new.target.name
|
||||
options.name = constructorName
|
||||
|
||||
super(options, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collected info of this object and previous errors
|
||||
*/
|
||||
info() {
|
||||
return BaseError.info(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this to validate args
|
||||
*/
|
||||
class InvalidArgumentError extends BaseError {
|
||||
static code = 'INVALID_ARGUMENT_ERROR'
|
||||
static defaultMessage = 'Invalid arguments received'
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this to throw when user tries to access data that he shouldn't have access to
|
||||
*/
|
||||
class UnauthorizedAccessError extends BaseError {
|
||||
static code = 'UNAUTHORIZED_ACCESS_ERROR'
|
||||
static defaultMessage = 'Attempted unauthorized access to data'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BaseError,
|
||||
InvalidArgumentError,
|
||||
UnauthorizedAccessError
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
'use strict'
|
||||
const { ApolloError, ForbiddenError, UserInputError, withFilter } = require('apollo-server-express')
|
||||
const appRoot = require('app-root-path')
|
||||
|
||||
const {
|
||||
createStream,
|
||||
@@ -12,17 +11,22 @@ const {
|
||||
getUserStreamsCount,
|
||||
getStreamUsers,
|
||||
grantPermissionsStream,
|
||||
revokePermissionsStream
|
||||
} = require('../../services/streams')
|
||||
revokePermissionsStream,
|
||||
favoriteStream,
|
||||
getFavoriteStreamsCollection,
|
||||
getActiveUserStreamFavoriteDate,
|
||||
getStreamFavoritesCount,
|
||||
getOwnedFavoritesCount
|
||||
} = require('@/modules/core/services/streams')
|
||||
|
||||
const {
|
||||
authorizeResolver,
|
||||
validateScopes,
|
||||
validateServerRole,
|
||||
pubsub
|
||||
} = require(`${appRoot}/modules/shared`)
|
||||
const { saveActivity } = require(`${appRoot}/modules/activitystream/services`)
|
||||
const { respectsLimits } = require('../../services/ratelimits')
|
||||
} = require(`@/modules/shared`)
|
||||
const { saveActivity } = require(`@/modules/activitystream/services`)
|
||||
const { respectsLimits } = require('@/modules/core/services/ratelimits')
|
||||
|
||||
// subscription events
|
||||
const USER_STREAM_ADDED = 'USER_STREAM_ADDED'
|
||||
@@ -36,7 +40,7 @@ function sleep(ms) {
|
||||
})
|
||||
}
|
||||
|
||||
const _deleteStream = async (parent, args, context, info) => {
|
||||
const _deleteStream = async (parent, args, context) => {
|
||||
await saveActivity({
|
||||
streamId: args.id,
|
||||
resourceType: 'stream',
|
||||
@@ -70,7 +74,7 @@ const _deleteStream = async (parent, args, context, info) => {
|
||||
|
||||
module.exports = {
|
||||
Query: {
|
||||
async stream(parent, args, context, info) {
|
||||
async stream(parent, args, context) {
|
||||
let stream = await getStream({ streamId: args.id, userId: context.userId })
|
||||
if (!stream) throw new ApolloError('Stream not found')
|
||||
|
||||
@@ -86,7 +90,7 @@ module.exports = {
|
||||
return stream
|
||||
},
|
||||
|
||||
async streams(parent, args, context, info) {
|
||||
async streams(parent, args, context) {
|
||||
if (args.limit && args.limit > 50)
|
||||
throw new UserInputError('Cannot return more than 50 items at a time.')
|
||||
|
||||
@@ -106,7 +110,7 @@ module.exports = {
|
||||
return { totalCount, cursor: cursor, items: streams }
|
||||
},
|
||||
|
||||
async adminStreams(parent, args, context, info) {
|
||||
async adminStreams(parent, args) {
|
||||
if (args.limit && args.limit > 50)
|
||||
throw new UserInputError('Cannot return more than 50 items at a time.')
|
||||
|
||||
@@ -123,19 +127,25 @@ module.exports = {
|
||||
},
|
||||
|
||||
Stream: {
|
||||
async collaborators(parent, args, context, info) {
|
||||
async collaborators(parent) {
|
||||
let users = await getStreamUsers({ streamId: parent.id })
|
||||
return users
|
||||
}
|
||||
},
|
||||
|
||||
// async size ( parent, args, context, info ) {
|
||||
// let size = await streamSize( { streamId: parent.id } )
|
||||
// return size
|
||||
// }
|
||||
async favoritedDate(parent, _args, ctx) {
|
||||
const { id: streamId } = parent
|
||||
return await getActiveUserStreamFavoriteDate({ ctx, streamId })
|
||||
},
|
||||
|
||||
async favoritesCount(parent, _args, ctx) {
|
||||
const { id: streamId } = parent
|
||||
|
||||
return await getStreamFavoritesCount({ ctx, streamId })
|
||||
}
|
||||
},
|
||||
|
||||
User: {
|
||||
async streams(parent, args, context, info) {
|
||||
async streams(parent, args, context) {
|
||||
if (args.limit && args.limit > 50)
|
||||
throw new UserInputError('Cannot return more than 50 items.')
|
||||
// Return only the user's public streams if parent.id !== context.userId
|
||||
@@ -148,12 +158,30 @@ module.exports = {
|
||||
cursor: args.cursor,
|
||||
publicOnly: publicOnly
|
||||
})
|
||||
|
||||
return { totalCount, cursor: cursor, items: streams }
|
||||
},
|
||||
|
||||
async favoriteStreams(parent, args, context) {
|
||||
const { userId } = context
|
||||
const { id: requestedUserId } = parent || {}
|
||||
const { limit, cursor } = args
|
||||
|
||||
if (userId !== requestedUserId)
|
||||
throw new UserInputError("Cannot view another user's favorite streams")
|
||||
|
||||
return await getFavoriteStreamsCollection({ userId, limit, cursor })
|
||||
},
|
||||
|
||||
async totalOwnedStreamsFavorites(parent, _args, ctx) {
|
||||
const { id: userId } = parent
|
||||
|
||||
return await getOwnedFavoritesCount({ ctx, userId })
|
||||
}
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
async streamCreate(parent, args, context, info) {
|
||||
async streamCreate(parent, args, context) {
|
||||
if (!(await respectsLimits({ action: 'STREAM_CREATE', source: context.userId }))) {
|
||||
throw new Error('Blocked due to rate-limiting. Try again later')
|
||||
}
|
||||
@@ -176,7 +204,7 @@ module.exports = {
|
||||
return id
|
||||
},
|
||||
|
||||
async streamUpdate(parent, args, context, info) {
|
||||
async streamUpdate(parent, args, context) {
|
||||
await authorizeResolver(context.userId, args.stream.id, 'stream:owner')
|
||||
|
||||
let oldValue = await getStream({ streamId: args.stream.id })
|
||||
@@ -226,7 +254,7 @@ module.exports = {
|
||||
return results.every((res) => res === true)
|
||||
},
|
||||
|
||||
async streamGrantPermission(parent, args, context, info) {
|
||||
async streamGrantPermission(parent, args, context) {
|
||||
await authorizeResolver(context.userId, args.permissionParams.streamId, 'stream:owner')
|
||||
|
||||
if (context.userId === args.permissionParams.userId)
|
||||
@@ -258,7 +286,7 @@ module.exports = {
|
||||
return granted
|
||||
},
|
||||
|
||||
async streamRevokePermission(parent, args, context, info) {
|
||||
async streamRevokePermission(parent, args, context) {
|
||||
await authorizeResolver(context.userId, args.permissionParams.streamId, 'stream:owner')
|
||||
|
||||
if (context.userId === args.permissionParams.userId)
|
||||
@@ -283,6 +311,13 @@ module.exports = {
|
||||
}
|
||||
|
||||
return revoked
|
||||
},
|
||||
|
||||
async streamFavorite(_parent, args, ctx) {
|
||||
const { streamId, favorited } = args
|
||||
const { userId } = ctx
|
||||
|
||||
return await favoriteStream({ userId, streamId, favorited })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const {
|
||||
archiveUser
|
||||
} = require('../../services/users')
|
||||
const { saveActivity } = require(`${appRoot}/modules/activitystream/services`)
|
||||
const { validateServerRole, validateScopes } = require(`${appRoot}/modules/shared`)
|
||||
const { validateServerRole, validateScopes } = require(`@/modules/shared`)
|
||||
const zxcvbn = require('zxcvbn')
|
||||
|
||||
module.exports = {
|
||||
@@ -26,6 +26,9 @@ module.exports = {
|
||||
},
|
||||
|
||||
async user(parent, args, context) {
|
||||
// User wants info about himself and he's not authenticated - just return null
|
||||
if (!context.auth && !args.id) return null
|
||||
|
||||
await validateServerRole(context, 'server:user')
|
||||
|
||||
if (!args.id) await validateScopes(context.scopes, 'profile:read')
|
||||
|
||||
@@ -32,6 +32,12 @@ type Stream {
|
||||
updatedAt: DateTime!
|
||||
collaborators: [StreamCollaborator]!
|
||||
size: String
|
||||
"""
|
||||
Date when you favorited this stream. `null` if stream isn't viewed from a specific user's perspective or if it isn't favorited.
|
||||
"""
|
||||
favoritedDate: DateTime
|
||||
# How many times this stream has been favorited
|
||||
favoritesCount: Int!
|
||||
}
|
||||
|
||||
extend type User {
|
||||
@@ -39,6 +45,16 @@ extend type User {
|
||||
All the streams that a user has access to.
|
||||
"""
|
||||
streams(limit: Int! = 25, cursor: String): StreamCollection
|
||||
|
||||
"""
|
||||
All the streams that a user has favorited
|
||||
"""
|
||||
favoriteStreams(limit: Int! = 25, cursor: String): StreamCollection @hasRole(role: "server:user")
|
||||
|
||||
"""
|
||||
Total amount of favorites attached to streams owned by the user
|
||||
"""
|
||||
totalOwnedStreamsFavorites: Int!
|
||||
}
|
||||
|
||||
type StreamCollaborator {
|
||||
@@ -85,9 +101,12 @@ extend type Mutation {
|
||||
"""
|
||||
Revokes the permissions of a user on a given stream.
|
||||
"""
|
||||
streamRevokePermission(
|
||||
permissionParams: StreamRevokePermissionInput!
|
||||
): Boolean @hasRole(role: "server:user") @hasScope(scope: "streams:write")
|
||||
streamRevokePermission(permissionParams: StreamRevokePermissionInput!): Boolean
|
||||
@hasRole(role: "server:user")
|
||||
@hasScope(scope: "streams:write")
|
||||
|
||||
# Favorite/unfavorite the given stream
|
||||
streamFavorite(streamId: String!, favorited: Boolean!): Stream @hasRole(role: "server:user")
|
||||
}
|
||||
|
||||
extend type Subscription {
|
||||
@@ -100,17 +119,13 @@ extend type Subscription {
|
||||
Subscribes to new stream added event for your profile. Use this to display an up-to-date list of streams.
|
||||
**NOTE**: If someone shares a stream with you, this subscription will be triggered with an extra value of `sharedBy` in the payload.
|
||||
"""
|
||||
userStreamAdded: JSONObject
|
||||
@hasRole(role: "server:user")
|
||||
@hasScope(scope: "profile:read")
|
||||
userStreamAdded: JSONObject @hasRole(role: "server:user") @hasScope(scope: "profile:read")
|
||||
|
||||
"""
|
||||
Subscribes to stream removed event for your profile. Use this to display an up-to-date list of streams for your profile.
|
||||
**NOTE**: If someone revokes your permissions on a stream, this subscription will be triggered with an extra value of `revokedBy` in the payload.
|
||||
"""
|
||||
userStreamRemoved: JSONObject
|
||||
@hasRole(role: "server:user")
|
||||
@hasScope(scope: "profile:read")
|
||||
userStreamRemoved: JSONObject @hasRole(role: "server:user") @hasScope(scope: "profile:read")
|
||||
|
||||
#
|
||||
# Stream bound subscriptions that operate on the stream itself.
|
||||
|
||||
@@ -3,7 +3,7 @@ extend type Query {
|
||||
Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header).
|
||||
"""
|
||||
user(id: String): User
|
||||
|
||||
|
||||
"""
|
||||
Get users from the server in a paginated view. The query search for matches in name, company and email.
|
||||
"""
|
||||
@@ -33,10 +33,9 @@ type User {
|
||||
role: String
|
||||
}
|
||||
|
||||
|
||||
type UserCollection {
|
||||
totalCount: Int!
|
||||
items: [ User ]
|
||||
items: [User]
|
||||
}
|
||||
|
||||
type UserSearchResultCollection {
|
||||
@@ -58,7 +57,7 @@ extend type Mutation {
|
||||
Edits a user's profile.
|
||||
"""
|
||||
userUpdate(user: UserUpdateInput!): Boolean!
|
||||
|
||||
|
||||
"""
|
||||
Delete a user's account.
|
||||
"""
|
||||
@@ -66,11 +65,9 @@ extend type Mutation {
|
||||
@hasRole(role: "server:user")
|
||||
@hasScope(scope: "profile:delete")
|
||||
|
||||
adminDeleteUser(userConfirmation: UserDeleteInput!): Boolean!
|
||||
@hasRole(role: "server:admin")
|
||||
adminDeleteUser(userConfirmation: UserDeleteInput!): Boolean! @hasRole(role: "server:admin")
|
||||
|
||||
userRoleChange(userRoleInput: UserRoleInput!): Boolean!
|
||||
@hasRole(role: "server:admin")
|
||||
userRoleChange(userRoleInput: UserRoleInput!): Boolean! @hasRole(role: "server:admin")
|
||||
}
|
||||
|
||||
input UserRoleInput {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const _ = require('lodash')
|
||||
const VError = require('verror')
|
||||
|
||||
/**
|
||||
* Some VError implementation details that we want to remove from object representations
|
||||
* of VErrors once they're converted to them
|
||||
*/
|
||||
const VERROR_TRASH_PROPS = ['jse_shortmsg', 'jse_cause', 'jse_info']
|
||||
|
||||
/**
|
||||
* Builds apollo server error formatter
|
||||
* @param {boolean} debug
|
||||
* @returns {(e: import('graphql').GraphQLError) => import('graphql').GraphQLFormattedError}
|
||||
*/
|
||||
function buildErrorFormatter(debug) {
|
||||
return function (error) {
|
||||
const debugMode = debug
|
||||
const realError = error.originalError ? error.originalError : error
|
||||
|
||||
// If error isn't a VError child, don't do anything extra
|
||||
if (!(realError instanceof VError)) {
|
||||
return error
|
||||
}
|
||||
|
||||
// Converting VError based error to Apollo's format
|
||||
const extensions = {
|
||||
...(error.extensions || {}),
|
||||
...(VError.info(realError) || {})
|
||||
}
|
||||
|
||||
// Getting rid of redundant info
|
||||
delete extensions.originalError
|
||||
|
||||
// Updating exception metadata in extensions
|
||||
if (extensions.exception) {
|
||||
extensions.exception = _.omit(extensions.exception, VERROR_TRASH_PROPS)
|
||||
|
||||
if (debugMode) {
|
||||
extensions.exception.stacktrace = VError.fullStack(realError)
|
||||
} else {
|
||||
delete extensions.exception.stacktrace
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: error.message,
|
||||
locations: error.locations,
|
||||
path: error.path,
|
||||
extensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildErrorFormatter
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const _ = require('lodash')
|
||||
|
||||
/**
|
||||
* Speckle role constants
|
||||
*/
|
||||
const Roles = Object.freeze({
|
||||
Stream: {
|
||||
Owner: 'stream:owner',
|
||||
Contributor: 'stream:contributor',
|
||||
Reviewer: 'stream:reviewer'
|
||||
},
|
||||
Server: {
|
||||
Admin: 'server:admin',
|
||||
User: 'server:user',
|
||||
ArchivedUser: 'server:archived-user'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Speckle scope constants
|
||||
*/
|
||||
const Scopes = Object.freeze({
|
||||
Streams: {
|
||||
Read: 'streams:read',
|
||||
Write: 'streams:write'
|
||||
},
|
||||
Profile: {
|
||||
Read: 'profile:read',
|
||||
Email: 'profile:email',
|
||||
Delete: 'profile:delete'
|
||||
},
|
||||
Users: {
|
||||
Read: 'users:read',
|
||||
Email: 'users:email'
|
||||
},
|
||||
Server: {
|
||||
Stats: 'server:stats',
|
||||
Setup: 'server:setup'
|
||||
},
|
||||
Tokens: {
|
||||
Read: 'tokens:read',
|
||||
Write: 'tokens:write'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* All scopes
|
||||
* @type {string[]}
|
||||
*/
|
||||
const AllScopes = _.flatMap(Scopes, (v) => Object.values(v))
|
||||
|
||||
module.exports = {
|
||||
Roles,
|
||||
Scopes,
|
||||
AllScopes
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
const DataLoader = require('dataloader')
|
||||
const {
|
||||
getBatchUserFavoriteData,
|
||||
getBatchStreamFavoritesCounts,
|
||||
getOwnedFavoritesCountByUserIds
|
||||
} = require('@/modules/core/repositories/streams')
|
||||
|
||||
/**
|
||||
* All DataLoaders available on the GQL ctx object
|
||||
* @typedef {Object} RequestDataLoaders
|
||||
* @property {{
|
||||
* getUserFavoriteData: DataLoader<string, {}>,
|
||||
* getFavoritesCount: DataLoader<string, number>,
|
||||
* getOwnedFavoritesCount: DataLoader<string, number>
|
||||
* }} streams
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Build request-scoped dataloaders
|
||||
* @param {import('@/modules/shared/index').AuthContextPart} ctx GraphQL context w/o loaders
|
||||
* @returns {RequestDataLoaders}
|
||||
*/
|
||||
buildRequestLoaders(ctx) {
|
||||
const userId = ctx.userId
|
||||
|
||||
return {
|
||||
streams: {
|
||||
/**
|
||||
* Get favorite metadata for a specific stream and user
|
||||
*/
|
||||
getUserFavoriteData: new DataLoader(async (streamIds) => {
|
||||
if (!userId) {
|
||||
return streamIds.map(() => null)
|
||||
}
|
||||
|
||||
const results = await getBatchUserFavoriteData({ userId, streamIds })
|
||||
return streamIds.map((k) => results[k])
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get amount of favorites for a specific stream
|
||||
*/
|
||||
getFavoritesCount: new DataLoader(async (streamIds) => {
|
||||
const results = await getBatchStreamFavoritesCounts(streamIds)
|
||||
return streamIds.map((k) => results[k] || 0)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get total amount of favorites of owned streams
|
||||
*/
|
||||
getOwnedFavoritesCount: new DataLoader(async (userIds) => {
|
||||
const results = await getOwnedFavoritesCountByUserIds(userIds)
|
||||
return userIds.map((i) => results[i])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
const TABLE_NAME = 'stream_favorites'
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
await knex.schema.createTable(TABLE_NAME, (table) => {
|
||||
table.string('streamId', 10).references('id').inTable('streams').onDelete('cascade')
|
||||
table.string('userId', 10).references('id').inTable('users').onDelete('cascade')
|
||||
table.specificType('createdAt', 'TIMESTAMPTZ(3)').defaultTo(knex.fn.now())
|
||||
|
||||
// userId first, since that's the main one we're going to be filtering by
|
||||
table.primary(['userId', 'streamId'])
|
||||
})
|
||||
|
||||
// for some reason can't add the serial field in the main createTable call
|
||||
await knex.schema.alterTable(TABLE_NAME, (table) => {
|
||||
table.increments('cursor', { primaryKey: false })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = async function (knex) {
|
||||
await knex.schema.dropTableIfExists(TABLE_NAME)
|
||||
}
|
||||
@@ -1,34 +1,29 @@
|
||||
|
||||
## Migrations, and how to create them
|
||||
|
||||
First, make a new migration file:
|
||||
|
||||
- `cd ${migrations folder}`
|
||||
- `knex migrate:make ${your migration name}`
|
||||
First, make a new migration file in the appropriate migrations folder. To do this use `./bin/cli`.
|
||||
|
||||
Next, write your migration! Here's an example below that adds a new column to a table.
|
||||
|
||||
```js
|
||||
|
||||
/* istanbul ignore file */
|
||||
exports.up = async ( knex ) => {
|
||||
await knex.schema.alterTable( 'scopes', table => {
|
||||
table.boolean( 'public' ).defaultTo( true )
|
||||
} )
|
||||
exports.up = async (knex) => {
|
||||
await knex.schema.alterTable('scopes', (table) => {
|
||||
table.boolean('public').defaultTo(true)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = async ( knex ) => {
|
||||
let hasColumn = await knex.schema.hasColumn( 'scopes', 'public' )
|
||||
if ( hasColumn ) {
|
||||
await knex.schema.alterTable( 'scopes', table => {
|
||||
table.dropColumn( 'public' )
|
||||
} )
|
||||
exports.down = async (knex) => {
|
||||
let hasColumn = await knex.schema.hasColumn('scopes', 'public')
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable('scopes', (table) => {
|
||||
table.dropColumn('public')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Do not delete or edit existing migration files
|
||||
- To edit an existing table, use alter table in a new migration file.
|
||||
- Always prefix your migration file with the date that you authored it in.
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
const _ = require('lodash')
|
||||
const { Streams, StreamAcl, StreamFavorites, knex } = require('@/modules/core/dbSchema')
|
||||
const { InvalidArgumentError } = require('@/modules/core/errors/base')
|
||||
const { Roles } = require('@/modules/core/helpers/mainConstants')
|
||||
|
||||
/**
|
||||
* List of base columns to select when querying for user streams
|
||||
* (expects join to StreamAcl)
|
||||
*/
|
||||
const BASE_STREAM_COLUMNS = [
|
||||
Streams.col.id,
|
||||
Streams.col.name,
|
||||
Streams.col.description,
|
||||
Streams.col.isPublic,
|
||||
Streams.col.createdAt,
|
||||
Streams.col.updatedAt,
|
||||
StreamAcl.col.role
|
||||
]
|
||||
|
||||
/**
|
||||
* Get a single stream
|
||||
* @param {Object} p
|
||||
* @param {string} p.streamId
|
||||
* @param {string} [p.userId] Optionally resolve role for user
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getStream({ streamId, userId }) {
|
||||
if (!streamId) throw new InvalidArgumentError('Invalid stream ID')
|
||||
|
||||
let stream = await Streams.knex().where({ id: streamId }).select('*').first()
|
||||
if (!userId) return stream
|
||||
|
||||
let acl = await StreamAcl.knex()
|
||||
.where({ resourceId: streamId, userId: userId })
|
||||
.select('role')
|
||||
.first()
|
||||
if (acl) stream.role = acl.role
|
||||
return stream
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base query for finding or counting user favorited streams
|
||||
* @param {string} userId The user's ID
|
||||
*/
|
||||
function getFavoritedStreamsQueryBase(userId) {
|
||||
if (!userId)
|
||||
throw new InvalidArgumentError('User ID must be specified to retrieve favorited streams')
|
||||
|
||||
const query = StreamFavorites.knex()
|
||||
.where(StreamFavorites.col.userId, userId)
|
||||
.innerJoin(Streams.name, Streams.col.id, StreamFavorites.col.streamId)
|
||||
.leftJoin(StreamAcl.name, (q) =>
|
||||
q
|
||||
.on(StreamAcl.col.resourceId, '=', StreamFavorites.col.streamId)
|
||||
.andOnVal(StreamAcl.col.userId, userId)
|
||||
)
|
||||
.andWhere((q) => q.where(Streams.col.isPublic, true).orWhereNotNull(StreamAcl.col.resourceId))
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorited streams
|
||||
* @param {Object} p
|
||||
* @param {string} p.userId
|
||||
* @param {string} [p.cursor] ISO8601 timestamp after which to look for favoirtes
|
||||
* @param {number} [p.limit] Defaults to 25
|
||||
* @returns {Promise<{streams: Array, cursor: string | null}>}
|
||||
*/
|
||||
async function getFavoritedStreams({ userId, cursor, limit }) {
|
||||
const finalLimit = _.clamp(limit || 25, 1, 25)
|
||||
const query = getFavoritedStreamsQueryBase(userId)
|
||||
query
|
||||
.select()
|
||||
.columns([
|
||||
...BASE_STREAM_COLUMNS,
|
||||
{ favoritedDate: StreamFavorites.col.createdAt },
|
||||
{ favCursor: StreamFavorites.col.cursor }
|
||||
])
|
||||
.limit(finalLimit)
|
||||
.orderBy(StreamFavorites.col.cursor, 'desc')
|
||||
|
||||
if (cursor) query.andWhere(StreamFavorites.col.cursor, '<', cursor)
|
||||
|
||||
let rows = await query
|
||||
return {
|
||||
streams: rows,
|
||||
cursor: rows.length > 0 ? rows[rows.length - 1].favCursor : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total amount of streams favorited by user
|
||||
* @param {string} userId
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function getFavoritedStreamsCount(userId) {
|
||||
const query = getFavoritedStreamsQueryBase(userId)
|
||||
query.count()
|
||||
|
||||
let [res] = await query
|
||||
return parseInt(res.count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set stream as favorited/unfavorited for a specific user
|
||||
* @param {Object} p
|
||||
* @param {string} p.streamId
|
||||
* @param {string} p.userId
|
||||
* @param {boolean} [p.favorited] By default favorites the stream, but you can set this
|
||||
* to false to unfavorite it
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function setStreamFavorited({ streamId, userId, favorited = true }) {
|
||||
if (!userId || !streamId)
|
||||
throw new InvalidArgumentError('Invalid stream or user ID', {
|
||||
info: { userId, streamId }
|
||||
})
|
||||
|
||||
const favoriteQuery = StreamFavorites.knex().where({
|
||||
streamId,
|
||||
userId
|
||||
})
|
||||
|
||||
if (!favorited) {
|
||||
await favoriteQuery.del()
|
||||
return
|
||||
}
|
||||
|
||||
// Upserting the favorite
|
||||
await StreamFavorites.knex()
|
||||
.insert({
|
||||
userId,
|
||||
streamId
|
||||
})
|
||||
.onConflict(['streamId', 'userId'])
|
||||
.ignore()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorite metadata for specified user and all specified stream IDs
|
||||
* @param {Object} p
|
||||
* @param {string} p.userId
|
||||
* @param {string[]} p.streamIds
|
||||
* @returns {Promise<Object<string, Object>>} Favorite metadata keyed by stream ID
|
||||
*/
|
||||
async function getBatchUserFavoriteData({ userId, streamIds }) {
|
||||
if (!userId || !streamIds || !streamIds.length)
|
||||
throw new InvalidArgumentError('Invalid user ID or stream IDs', {
|
||||
info: { userId, streamIds }
|
||||
})
|
||||
|
||||
const query = StreamFavorites.knex()
|
||||
.select()
|
||||
.where(StreamFavorites.col.userId, userId)
|
||||
.whereIn(StreamFavorites.col.streamId, streamIds)
|
||||
|
||||
const rows = await query
|
||||
return _.keyBy(rows, 'streamId')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorites counts for all specified streams
|
||||
* @param {string[]} streamIds
|
||||
* @returns {Promise<Object<string, number>>} Favorite counts keyed by stream ids
|
||||
*/
|
||||
async function getBatchStreamFavoritesCounts(streamIds) {
|
||||
const query = StreamFavorites.knex()
|
||||
.select()
|
||||
.columns([StreamFavorites.col.streamId, knex.raw('COUNT(*) as count')])
|
||||
.whereIn(StreamFavorites.col.streamId, streamIds)
|
||||
.groupBy(StreamFavorites.col.streamId)
|
||||
|
||||
const rows = await query
|
||||
return _.mapValues(_.keyBy(rows, 'streamId'), (r) => r?.count || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can favorite a stream
|
||||
* @param {Object} p
|
||||
* @param {string} userId
|
||||
* @param {string} streamId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function canUserFavoriteStream({ userId, streamId }) {
|
||||
if (!userId || !streamId)
|
||||
throw new InvalidArgumentError('Invalid stream or user ID', {
|
||||
info: { userId, streamId }
|
||||
})
|
||||
|
||||
const query = Streams.knex()
|
||||
.select([Streams.col.id])
|
||||
.leftJoin(StreamAcl.name, function () {
|
||||
this.on(StreamAcl.col.resourceId, Streams.col.id).andOnVal(StreamAcl.col.userId, userId)
|
||||
})
|
||||
.where(Streams.col.id, streamId)
|
||||
.andWhere(function () {
|
||||
this.where(Streams.col.isPublic, true).orWhereNotNull(StreamAcl.col.resourceId)
|
||||
})
|
||||
.limit(1)
|
||||
|
||||
const result = await query
|
||||
return result?.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Find total favorites of owned streams for specified users
|
||||
* @param {string[]} userIds
|
||||
* @returns {Promise<Record<string, number>>}
|
||||
*/
|
||||
async function getOwnedFavoritesCountByUserIds(userIds) {
|
||||
const query = StreamAcl.knex()
|
||||
.select([StreamAcl.col.userId, knex.raw('COUNT(*)')])
|
||||
.join(StreamFavorites.name, function () {
|
||||
this.andOn(StreamFavorites.col.streamId, StreamAcl.col.resourceId)
|
||||
})
|
||||
.whereIn(StreamAcl.col.userId, userIds)
|
||||
.andWhere(StreamAcl.col.role, Roles.Stream.Owner)
|
||||
.groupBy(StreamAcl.col.userId)
|
||||
|
||||
const results = await query
|
||||
return _.mapValues(_.keyBy(results, 'userId'), (r) => r?.count || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStream,
|
||||
getFavoritedStreams,
|
||||
getFavoritedStreamsCount,
|
||||
setStreamFavorited,
|
||||
canUserFavoriteStream,
|
||||
getBatchUserFavoriteData,
|
||||
getBatchStreamFavoritesCounts,
|
||||
getOwnedFavoritesCountByUserIds,
|
||||
BASE_STREAM_COLUMNS
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
'use strict'
|
||||
const { Roles } = require('@/modules/core/helpers/mainConstants')
|
||||
|
||||
// Conventions:
|
||||
// "weight: 1000" => resource owner
|
||||
@@ -10,15 +11,16 @@ module.exports = [
|
||||
* Roles for "this" server.
|
||||
*/
|
||||
{
|
||||
name: 'server:admin',
|
||||
description: 'Holds supreme autocratic authority, not restricted by written laws, legislature, or customs.',
|
||||
name: Roles.Server.Admin,
|
||||
description:
|
||||
'Holds supreme autocratic authority, not restricted by written laws, legislature, or customs.',
|
||||
resourceTarget: 'server',
|
||||
aclTableName: 'server_acl',
|
||||
weight: 1000,
|
||||
public: false
|
||||
},
|
||||
{
|
||||
name: 'server:user',
|
||||
name: Roles.Server.User,
|
||||
description: 'Has normal access to the server.',
|
||||
resourceTarget: 'server',
|
||||
aclTableName: 'server_acl',
|
||||
@@ -26,7 +28,7 @@ module.exports = [
|
||||
public: false
|
||||
},
|
||||
{
|
||||
name: 'server:archived-user',
|
||||
name: Roles.Server.ArchivedUser,
|
||||
description: 'No longer has access to the server.',
|
||||
resourceTarget: 'server',
|
||||
aclTableName: 'server_acl',
|
||||
@@ -37,21 +39,24 @@ module.exports = [
|
||||
* Roles for streams.
|
||||
*/
|
||||
{
|
||||
name: 'stream:owner',
|
||||
name: Roles.Stream.Owner,
|
||||
description: 'Owners have full access, including deletion rights & access control.',
|
||||
resourceTarget: 'streams',
|
||||
aclTableName: 'stream_acl',
|
||||
weight: 1000,
|
||||
public: true
|
||||
}, {
|
||||
name: 'stream:contributor',
|
||||
description: 'Contributors can create new branches and commits, but they cannot edit stream details or manage collaborators.',
|
||||
},
|
||||
{
|
||||
name: Roles.Stream.Contributor,
|
||||
description:
|
||||
'Contributors can create new branches and commits, but they cannot edit stream details or manage collaborators.',
|
||||
resourceTarget: 'streams',
|
||||
aclTableName: 'stream_acl',
|
||||
weight: 500,
|
||||
public: true
|
||||
}, {
|
||||
name: 'stream:reviewer',
|
||||
},
|
||||
{
|
||||
name: Roles.Stream.Reviewer,
|
||||
description: 'Reviewers can only view (read) the data from this stream.',
|
||||
resourceTarget: 'streams',
|
||||
aclTableName: 'stream_acl',
|
||||
|
||||
@@ -1,58 +1,62 @@
|
||||
'use strict'
|
||||
const { Scopes } = require('@/modules/core/helpers/mainConstants')
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
name: 'streams:read',
|
||||
name: Scopes.Streams.Read,
|
||||
description: 'Read your streams, and any associated information (branches, commits, objects).',
|
||||
public: true
|
||||
},
|
||||
{
|
||||
name: 'streams:write',
|
||||
description: 'Create streams on your behalf, and any associated data (branches, commits, objects).',
|
||||
name: Scopes.Streams.Write,
|
||||
description:
|
||||
'Create streams on your behalf, and any associated data (branches, commits, objects).',
|
||||
public: true
|
||||
},
|
||||
{
|
||||
name: 'profile:read',
|
||||
name: Scopes.Profile.Read,
|
||||
description: 'Read your profile information (name, bio, company).',
|
||||
public: true
|
||||
},
|
||||
{
|
||||
name: 'profile:email',
|
||||
name: Scopes.Profile.Email,
|
||||
description: 'Grants access to the email address you registered with.',
|
||||
public: true
|
||||
},
|
||||
{
|
||||
name: 'profile:delete',
|
||||
name: Scopes.Profile.Delete,
|
||||
description: 'Allows a user to delete their account, with all associated data.',
|
||||
public: false
|
||||
},
|
||||
{
|
||||
name: 'users:read',
|
||||
description: 'Read other users\' profile on your behalf.',
|
||||
name: Scopes.Users.Read,
|
||||
description: "Read other users' profile on your behalf.",
|
||||
public: true
|
||||
},
|
||||
{
|
||||
name: 'server:stats',
|
||||
description: 'Request server stats from the api. Only works in conjunction with a "server:admin" role.',
|
||||
name: Scopes.Server.Stats,
|
||||
description:
|
||||
'Request server stats from the api. Only works in conjunction with a "server:admin" role.',
|
||||
public: true
|
||||
},
|
||||
{
|
||||
name: 'users:email',
|
||||
name: Scopes.Users.Email,
|
||||
description: 'Access the emails of other users on your behalf.',
|
||||
public: false
|
||||
},
|
||||
{
|
||||
name: 'server:setup',
|
||||
description: 'Edit server information. Note: only server admins will be able to use this token.',
|
||||
name: Scopes.Server.Setup,
|
||||
description:
|
||||
'Edit server information. Note: only server admins will be able to use this token.',
|
||||
public: false
|
||||
},
|
||||
{
|
||||
name: 'tokens:read',
|
||||
name: Scopes.Tokens.Read,
|
||||
description: 'Access your api tokens.',
|
||||
public: false
|
||||
},
|
||||
{
|
||||
name: 'tokens:write',
|
||||
name: Scopes.Tokens.Write,
|
||||
description: 'Create and delete api tokens on your behalf.',
|
||||
public: false
|
||||
}
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
'use strict'
|
||||
const bcrypt = require( 'bcrypt' )
|
||||
const crs = require( 'crypto-random-string' )
|
||||
const { performance } = require( 'perf_hooks' )
|
||||
const crypto = require( 'crypto' )
|
||||
const set = require( 'lodash.set' )
|
||||
const get = require( 'lodash.get' )
|
||||
const chunk = require( 'lodash.chunk' )
|
||||
const { performance } = require('perf_hooks')
|
||||
const crypto = require('crypto')
|
||||
const { set, get, chunk } = require('lodash')
|
||||
|
||||
let debug = require( 'debug' )( 'speckle:services' )
|
||||
let debug = require('debug')('speckle:services')
|
||||
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const knex = require( `${appRoot}/db/knex` )
|
||||
const knex = require(`@/db/knex`)
|
||||
|
||||
const Streams = ( ) => knex( 'streams' )
|
||||
const Objects = ( ) => knex( 'objects' )
|
||||
const Closures = ( ) => knex( 'object_children_closure' )
|
||||
const StreamCommits = ( ) => knex( 'stream_commits' )
|
||||
const Objects = () => knex('objects')
|
||||
const Closures = () => knex('object_children_closure')
|
||||
|
||||
module.exports = {
|
||||
async createObject(streamId, object) {
|
||||
let insertionObject = prepInsertionObject(streamId, object)
|
||||
|
||||
async createObject( streamId, object ) {
|
||||
let insertionObject = prepInsertionObject( streamId, object )
|
||||
|
||||
let closures = [ ]
|
||||
let closures = []
|
||||
let totalChildrenCountByDepth = {}
|
||||
if ( object.__closure !== null ) {
|
||||
for ( const prop in object.__closure ) {
|
||||
closures.push( { streamId: streamId, parent: insertionObject.id, child: prop, minDepth: object.__closure[ prop ] } )
|
||||
if (object.__closure !== null) {
|
||||
for (const prop in object.__closure) {
|
||||
closures.push({
|
||||
streamId: streamId,
|
||||
parent: insertionObject.id,
|
||||
child: prop,
|
||||
minDepth: object.__closure[prop]
|
||||
})
|
||||
|
||||
if ( totalChildrenCountByDepth[ object.__closure[ prop ].toString( ) ] )
|
||||
totalChildrenCountByDepth[ object.__closure[ prop ].toString( ) ]++
|
||||
else
|
||||
totalChildrenCountByDepth[ object.__closure[ prop ].toString( ) ] = 1
|
||||
if (totalChildrenCountByDepth[object.__closure[prop].toString()])
|
||||
totalChildrenCountByDepth[object.__closure[prop].toString()]++
|
||||
else totalChildrenCountByDepth[object.__closure[prop].toString()] = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,245 +35,289 @@ module.exports = {
|
||||
delete insertionObject.__closure
|
||||
|
||||
insertionObject.totalChildrenCount = closures.length
|
||||
insertionObject.totalChildrenCountByDepth = JSON.stringify( totalChildrenCountByDepth )
|
||||
insertionObject.totalChildrenCountByDepth = JSON.stringify(totalChildrenCountByDepth)
|
||||
|
||||
let q1 = Objects( ).insert( insertionObject ).toString( ) + ' on conflict do nothing'
|
||||
await knex.raw( q1 )
|
||||
let q1 = Objects().insert(insertionObject).toString() + ' on conflict do nothing'
|
||||
await knex.raw(q1)
|
||||
|
||||
if ( closures.length > 0 ) {
|
||||
let q2 = `${ Closures().insert( closures ).toString() } on conflict do nothing`
|
||||
await knex.raw( q2 )
|
||||
if (closures.length > 0) {
|
||||
let q2 = `${Closures().insert(closures).toString()} on conflict do nothing`
|
||||
await knex.raw(q2)
|
||||
}
|
||||
|
||||
return insertionObject.id
|
||||
},
|
||||
|
||||
async createObjectsBatched( streamId, objects ) {
|
||||
let closures = [ ]
|
||||
let objsToInsert = [ ]
|
||||
let ids = [ ]
|
||||
async createObjectsBatched(streamId, objects) {
|
||||
let closures = []
|
||||
let objsToInsert = []
|
||||
let ids = []
|
||||
|
||||
// Prep objects up
|
||||
objects.forEach( obj => {
|
||||
let insertionObject = prepInsertionObject( streamId, obj )
|
||||
objects.forEach((obj) => {
|
||||
let insertionObject = prepInsertionObject(streamId, obj)
|
||||
let totalChildrenCountGlobal = 0
|
||||
let totalChildrenCountByDepth = {}
|
||||
|
||||
if ( obj.__closure !== null ) {
|
||||
for ( const prop in obj.__closure ) {
|
||||
closures.push( { streamId: streamId, parent: insertionObject.id, child: prop, minDepth: obj.__closure[ prop ] } )
|
||||
if (obj.__closure !== null) {
|
||||
for (const prop in obj.__closure) {
|
||||
closures.push({
|
||||
streamId: streamId,
|
||||
parent: insertionObject.id,
|
||||
child: prop,
|
||||
minDepth: obj.__closure[prop]
|
||||
})
|
||||
totalChildrenCountGlobal++
|
||||
if ( totalChildrenCountByDepth[ obj.__closure[ prop ].toString( ) ] )
|
||||
totalChildrenCountByDepth[ obj.__closure[ prop ].toString( ) ]++
|
||||
else
|
||||
totalChildrenCountByDepth[ obj.__closure[ prop ].toString( ) ] = 1
|
||||
if (totalChildrenCountByDepth[obj.__closure[prop].toString()])
|
||||
totalChildrenCountByDepth[obj.__closure[prop].toString()]++
|
||||
else totalChildrenCountByDepth[obj.__closure[prop].toString()] = 1
|
||||
}
|
||||
}
|
||||
|
||||
insertionObject.totalChildrenCount = totalChildrenCountGlobal
|
||||
insertionObject.totalChildrenCountByDepth = JSON.stringify( totalChildrenCountByDepth )
|
||||
insertionObject.totalChildrenCountByDepth = JSON.stringify(totalChildrenCountByDepth)
|
||||
|
||||
delete insertionObject.__tree
|
||||
delete insertionObject.__closure
|
||||
|
||||
objsToInsert.push( insertionObject )
|
||||
ids.push( insertionObject.id )
|
||||
} )
|
||||
objsToInsert.push(insertionObject)
|
||||
ids.push(insertionObject.id)
|
||||
})
|
||||
|
||||
let closureBatchSize = 1000
|
||||
let objectsBatchSize = 500
|
||||
|
||||
// step 1: insert objects
|
||||
if ( objsToInsert.length > 0 ) {
|
||||
let batches = chunk( objsToInsert, objectsBatchSize )
|
||||
for ( const batch of batches ) {
|
||||
prepInsertionObjectBatch( batch )
|
||||
await knex.transaction( async trx => {
|
||||
let q = Objects( ).insert( batch ).toString( ) + ' on conflict do nothing'
|
||||
const inserts = await trx.raw( q )
|
||||
} )
|
||||
debug( `Inserted ${batch.length} objects` )
|
||||
if (objsToInsert.length > 0) {
|
||||
let batches = chunk(objsToInsert, objectsBatchSize)
|
||||
for (const batch of batches) {
|
||||
prepInsertionObjectBatch(batch)
|
||||
await knex.transaction(async (trx) => {
|
||||
let q = Objects().insert(batch).toString() + ' on conflict do nothing'
|
||||
const inserts = await trx.raw(q)
|
||||
})
|
||||
debug(`Inserted ${batch.length} objects`)
|
||||
}
|
||||
}
|
||||
|
||||
// step 2: insert closures
|
||||
if ( closures.length > 0 ) {
|
||||
let batches = chunk( closures, closureBatchSize )
|
||||
if (closures.length > 0) {
|
||||
let batches = chunk(closures, closureBatchSize)
|
||||
|
||||
for ( const batch of batches ) {
|
||||
prepInsertionClosureBatch( batch )
|
||||
await knex.transaction( async trx => {
|
||||
let q = Closures( ).insert( batch ).toString( ) + ' on conflict do nothing'
|
||||
const inserts = await trx.raw( q )
|
||||
} )
|
||||
debug( `Inserted ${batch.length} closures` )
|
||||
for (const batch of batches) {
|
||||
prepInsertionClosureBatch(batch)
|
||||
await knex.transaction(async (trx) => {
|
||||
let q = Closures().insert(batch).toString() + ' on conflict do nothing'
|
||||
const inserts = await trx.raw(q)
|
||||
})
|
||||
debug(`Inserted ${batch.length} closures`)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
async createObjects( streamId, objects ) {
|
||||
async createObjects(streamId, objects) {
|
||||
// TODO: Switch to knex batch inserting functionality
|
||||
// see http://knexjs.org/#Utility-BatchInsert
|
||||
let batches = [ ]
|
||||
let batches = []
|
||||
let maxBatchSize = process.env.MAX_BATCH_SIZE || 250
|
||||
objects = [ ...objects ]
|
||||
if ( objects.length > maxBatchSize ) {
|
||||
while ( objects.length > 0 )
|
||||
batches.push( objects.splice( 0, maxBatchSize ) )
|
||||
objects = [...objects]
|
||||
if (objects.length > maxBatchSize) {
|
||||
while (objects.length > 0) batches.push(objects.splice(0, maxBatchSize))
|
||||
} else {
|
||||
batches.push( objects )
|
||||
batches.push(objects)
|
||||
}
|
||||
|
||||
let ids = [ ]
|
||||
let ids = []
|
||||
|
||||
let promises = batches.map( async ( batch, index ) => new Promise( async ( resolve, reject ) => {
|
||||
let closures = [ ]
|
||||
let objsToInsert = [ ]
|
||||
let promises = batches.map(
|
||||
async (batch, index) =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
let closures = []
|
||||
let objsToInsert = []
|
||||
|
||||
let t0 = performance.now( )
|
||||
let t0 = performance.now()
|
||||
|
||||
batch.forEach( obj => {
|
||||
if( !obj ) return
|
||||
|
||||
let insertionObject = prepInsertionObject( streamId, obj )
|
||||
let totalChildrenCountByDepth = {}
|
||||
let totalChildrenCountGlobal = 0
|
||||
if ( obj.__closure !== null ) {
|
||||
for ( const prop in obj.__closure ) {
|
||||
closures.push( { streamId: streamId, parent: insertionObject.id, child: prop, minDepth: obj.__closure[ prop ] } )
|
||||
batch.forEach((obj) => {
|
||||
if (!obj) return
|
||||
|
||||
totalChildrenCountGlobal++
|
||||
let insertionObject = prepInsertionObject(streamId, obj)
|
||||
let totalChildrenCountByDepth = {}
|
||||
let totalChildrenCountGlobal = 0
|
||||
if (obj.__closure !== null) {
|
||||
for (const prop in obj.__closure) {
|
||||
closures.push({
|
||||
streamId: streamId,
|
||||
parent: insertionObject.id,
|
||||
child: prop,
|
||||
minDepth: obj.__closure[prop]
|
||||
})
|
||||
|
||||
if ( totalChildrenCountByDepth[ obj.__closure[ prop ].toString( ) ] )
|
||||
totalChildrenCountByDepth[ obj.__closure[ prop ].toString( ) ]++
|
||||
else
|
||||
totalChildrenCountByDepth[ obj.__closure[ prop ].toString( ) ] = 1
|
||||
totalChildrenCountGlobal++
|
||||
|
||||
if (totalChildrenCountByDepth[obj.__closure[prop].toString()])
|
||||
totalChildrenCountByDepth[obj.__closure[prop].toString()]++
|
||||
else totalChildrenCountByDepth[obj.__closure[prop].toString()] = 1
|
||||
}
|
||||
}
|
||||
|
||||
insertionObject.totalChildrenCount = totalChildrenCountGlobal
|
||||
insertionObject.totalChildrenCountByDepth = JSON.stringify(totalChildrenCountByDepth)
|
||||
|
||||
delete insertionObject.__tree
|
||||
delete insertionObject.__closure
|
||||
|
||||
objsToInsert.push(insertionObject)
|
||||
ids.push(insertionObject.id)
|
||||
})
|
||||
|
||||
if (objsToInsert.length > 0) {
|
||||
let queryObjs = Objects().insert(objsToInsert).toString() + ' on conflict do nothing'
|
||||
await knex.raw(queryObjs)
|
||||
}
|
||||
}
|
||||
|
||||
insertionObject.totalChildrenCount = totalChildrenCountGlobal
|
||||
insertionObject.totalChildrenCountByDepth = JSON.stringify( totalChildrenCountByDepth )
|
||||
if (closures.length > 0) {
|
||||
let q2 = `${Closures().insert(closures).toString()} on conflict do nothing`
|
||||
await knex.raw(q2)
|
||||
}
|
||||
|
||||
delete insertionObject.__tree
|
||||
delete insertionObject.__closure
|
||||
let t1 = performance.now()
|
||||
debug(
|
||||
`Batch ${index + 1}/${batches.length}: Stored ${
|
||||
closures.length + objsToInsert.length
|
||||
} objects in ${t1 - t0}ms.`
|
||||
)
|
||||
// console.log( `Batch ${index + 1}/${batches.length}: Stored ${closures.length + objsToInsert.length} objects in ${t1-t0}ms.` )
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
|
||||
objsToInsert.push( insertionObject )
|
||||
ids.push( insertionObject.id )
|
||||
} )
|
||||
|
||||
if ( objsToInsert.length > 0 ) {
|
||||
let queryObjs = Objects( ).insert( objsToInsert ).toString( ) + ' on conflict do nothing'
|
||||
await knex.raw( queryObjs )
|
||||
}
|
||||
|
||||
if ( closures.length > 0 ) {
|
||||
let q2 = `${ Closures().insert( closures ).toString() } on conflict do nothing`
|
||||
await knex.raw( q2 )
|
||||
}
|
||||
|
||||
let t1 = performance.now( )
|
||||
debug( `Batch ${index + 1}/${batches.length}: Stored ${closures.length + objsToInsert.length} objects in ${t1-t0}ms.` )
|
||||
// console.log( `Batch ${index + 1}/${batches.length}: Stored ${closures.length + objsToInsert.length} objects in ${t1-t0}ms.` )
|
||||
resolve( )
|
||||
} ) )
|
||||
|
||||
await Promise.all( promises )
|
||||
await Promise.all(promises)
|
||||
|
||||
return ids
|
||||
},
|
||||
|
||||
async getObject( { streamId, objectId } ) {
|
||||
let res = await Objects( ).where( { streamId: streamId, id: objectId } ).select( '*' ).first( )
|
||||
if ( !res ) return null
|
||||
async getObject({ streamId, objectId }) {
|
||||
let res = await Objects().where({ streamId: streamId, id: objectId }).select('*').first()
|
||||
if (!res) return null
|
||||
res.data.totalChildrenCount = res.totalChildrenCount // move this back
|
||||
delete res.streamId // backwards compatibility
|
||||
return res
|
||||
},
|
||||
|
||||
async getObjectChildrenStream( { streamId, objectId } ) {
|
||||
let q = Closures( )
|
||||
q.select( 'id' )
|
||||
q.select( knex.raw( 'data::text as "dataText"' ) )
|
||||
q.rightJoin( 'objects', function() {
|
||||
this.on( 'objects.streamId', '=', 'object_children_closure.streamId' )
|
||||
.andOn( 'objects.id', '=', 'object_children_closure.child' )
|
||||
} )
|
||||
.where( knex.raw( 'object_children_closure."streamId" = ? AND parent = ?', [ streamId, objectId ] ) )
|
||||
.orderBy( 'objects.id' )
|
||||
return q.stream( { highWaterMark: 500 } )
|
||||
async getObjectChildrenStream({ streamId, objectId }) {
|
||||
let q = Closures()
|
||||
q.select('id')
|
||||
q.select(knex.raw('data::text as "dataText"'))
|
||||
q.rightJoin('objects', function () {
|
||||
this.on('objects.streamId', '=', 'object_children_closure.streamId').andOn(
|
||||
'objects.id',
|
||||
'=',
|
||||
'object_children_closure.child'
|
||||
)
|
||||
})
|
||||
.where(
|
||||
knex.raw('object_children_closure."streamId" = ? AND parent = ?', [streamId, objectId])
|
||||
)
|
||||
.orderBy('objects.id')
|
||||
return q.stream({ highWaterMark: 500 })
|
||||
},
|
||||
|
||||
async getObjectChildren( { streamId, objectId, limit, depth, select, cursor } ) {
|
||||
limit = parseInt( limit ) || 50
|
||||
depth = parseInt( depth ) || 1000
|
||||
async getObjectChildren({ streamId, objectId, limit, depth, select, cursor }) {
|
||||
limit = parseInt(limit) || 50
|
||||
depth = parseInt(depth) || 1000
|
||||
|
||||
let fullObjectSelect = false
|
||||
let selectStatements = [ ]
|
||||
let selectStatements = []
|
||||
|
||||
let q = Closures( )
|
||||
q.select( 'id' )
|
||||
q.select( 'createdAt' )
|
||||
q.select( 'speckleType' )
|
||||
q.select( 'totalChildrenCount' )
|
||||
let q = Closures()
|
||||
q.select('id')
|
||||
q.select('createdAt')
|
||||
q.select('speckleType')
|
||||
q.select('totalChildrenCount')
|
||||
|
||||
if ( Array.isArray( select ) ) {
|
||||
select.forEach( ( field, index ) => {
|
||||
q.select( knex.raw( 'jsonb_path_query(data, :path) as :name:', { path: '$.' + field, name: '' + index } ) )
|
||||
} )
|
||||
if (Array.isArray(select)) {
|
||||
select.forEach((field, index) => {
|
||||
q.select(
|
||||
knex.raw('jsonb_path_query(data, :path) as :name:', {
|
||||
path: '$.' + field,
|
||||
name: '' + index
|
||||
})
|
||||
)
|
||||
})
|
||||
} else {
|
||||
fullObjectSelect = true
|
||||
q.select( 'data' )
|
||||
q.select('data')
|
||||
}
|
||||
|
||||
q.rightJoin( 'objects', function() {
|
||||
this.on( 'objects.streamId', '=', 'object_children_closure.streamId' )
|
||||
.andOn( 'objects.id', '=', 'object_children_closure.child' )
|
||||
} )
|
||||
.where( knex.raw( 'object_children_closure."streamId" = ? AND parent = ?', [ streamId, objectId ] ) )
|
||||
.andWhere( knex.raw( '"minDepth" < ?', [ depth ] ) )
|
||||
.andWhere( knex.raw( 'id > ?', [ cursor ? cursor : '0' ] ) )
|
||||
.orderBy( 'objects.id' )
|
||||
.limit( limit )
|
||||
q.rightJoin('objects', function () {
|
||||
this.on('objects.streamId', '=', 'object_children_closure.streamId').andOn(
|
||||
'objects.id',
|
||||
'=',
|
||||
'object_children_closure.child'
|
||||
)
|
||||
})
|
||||
.where(
|
||||
knex.raw('object_children_closure."streamId" = ? AND parent = ?', [streamId, objectId])
|
||||
)
|
||||
.andWhere(knex.raw('"minDepth" < ?', [depth]))
|
||||
.andWhere(knex.raw('id > ?', [cursor ? cursor : '0']))
|
||||
.orderBy('objects.id')
|
||||
.limit(limit)
|
||||
|
||||
let rows = await q
|
||||
|
||||
if ( rows.length === 0 ) {
|
||||
if (rows.length === 0) {
|
||||
return { objects: rows, cursor: null }
|
||||
}
|
||||
|
||||
if ( !fullObjectSelect )
|
||||
rows.forEach( ( o, i, arr ) => {
|
||||
let no = { id: o.id, createdAt: o.createdAt, speckleType: o.speckleType, totalChildrenCount: o.totalChildrenCount, data: {} }
|
||||
let k = 0
|
||||
for ( let field of select ) {
|
||||
set( no.data, field, o[ k++ ] )
|
||||
if (!fullObjectSelect)
|
||||
rows.forEach((o, i, arr) => {
|
||||
let no = {
|
||||
id: o.id,
|
||||
createdAt: o.createdAt,
|
||||
speckleType: o.speckleType,
|
||||
totalChildrenCount: o.totalChildrenCount,
|
||||
data: {}
|
||||
}
|
||||
arr[ i ] = no
|
||||
} )
|
||||
let k = 0
|
||||
for (let field of select) {
|
||||
set(no.data, field, o[k++])
|
||||
}
|
||||
arr[i] = no
|
||||
})
|
||||
|
||||
let lastId = rows[ rows.length - 1 ].id
|
||||
let lastId = rows[rows.length - 1].id
|
||||
return { objects: rows, cursor: lastId }
|
||||
},
|
||||
|
||||
// This query is inefficient on larger sets (n * 10k objects) as we need to return the total count on an arbitrarily (user) defined selection of objects.
|
||||
// A possible future optimisation route would be to cache the total count of a query (as objects are immutable, it will not change) on a first run, and, if found on a subsequent round, do a simpler query and merge the total count result.
|
||||
async getObjectChildrenQuery( { streamId, objectId, limit, depth, select, cursor, query, orderBy } ) {
|
||||
limit = parseInt( limit ) || 50
|
||||
depth = parseInt( depth ) || 1000
|
||||
async getObjectChildrenQuery({
|
||||
streamId,
|
||||
objectId,
|
||||
limit,
|
||||
depth,
|
||||
select,
|
||||
cursor,
|
||||
query,
|
||||
orderBy
|
||||
}) {
|
||||
limit = parseInt(limit) || 50
|
||||
depth = parseInt(depth) || 1000
|
||||
orderBy = orderBy || { field: 'id', direction: 'asc' }
|
||||
|
||||
// Cursors received by this service should be base64 encoded. They are generated on first entry query by this service; They should never be client-side generated.
|
||||
if ( cursor ) {
|
||||
cursor = JSON.parse( Buffer.from( cursor, 'base64' ).toString( 'binary' ) )
|
||||
if (cursor) {
|
||||
cursor = JSON.parse(Buffer.from(cursor, 'base64').toString('binary'))
|
||||
}
|
||||
|
||||
// Flag that keeps track of whether we select the whole "data" part of an object or not
|
||||
let fullObjectSelect = false
|
||||
if ( Array.isArray( select ) ) {
|
||||
if (Array.isArray(select)) {
|
||||
// if we order by a field that we do not select, select it!
|
||||
if ( orderBy && select.indexOf( orderBy.field ) === -1 ) {
|
||||
select.push( orderBy.field )
|
||||
if (orderBy && select.indexOf(orderBy.field) === -1) {
|
||||
select.push(orderBy.field)
|
||||
}
|
||||
// // always add the id!
|
||||
// if ( select.indexOf( 'id' ) === -1 ) select.unshift( 'id' )
|
||||
@@ -287,205 +327,261 @@ module.exports = {
|
||||
|
||||
let additionalIdOrderBy = orderBy.field !== 'id'
|
||||
|
||||
let operatorsWhitelist = [ '=', '>', '>=', '<', '<=', '!=' ]
|
||||
let operatorsWhitelist = ['=', '>', '>=', '<', '<=', '!=']
|
||||
|
||||
let mainQuery = knex.with( 'objs', cteInnerQuery => {
|
||||
// always select the id
|
||||
cteInnerQuery.select( 'id' ).from( 'object_children_closure' )
|
||||
cteInnerQuery.select( 'createdAt' )
|
||||
cteInnerQuery.select( 'speckleType' )
|
||||
cteInnerQuery.select( 'totalChildrenCount' )
|
||||
let mainQuery = knex
|
||||
.with('objs', (cteInnerQuery) => {
|
||||
// always select the id
|
||||
cteInnerQuery.select('id').from('object_children_closure')
|
||||
cteInnerQuery.select('createdAt')
|
||||
cteInnerQuery.select('speckleType')
|
||||
cteInnerQuery.select('totalChildrenCount')
|
||||
|
||||
// if there are any select fields, add them
|
||||
if ( Array.isArray( select ) ) {
|
||||
select.forEach( ( field, index ) => {
|
||||
cteInnerQuery.select( knex.raw( 'jsonb_path_query(data, :path) as :name:', { path: '$.' + field, name: '' + index } ) )
|
||||
} )
|
||||
// otherwise, get the whole object, as stored in the jsonb column
|
||||
} else {
|
||||
cteInnerQuery.select( 'data' )
|
||||
}
|
||||
// if there are any select fields, add them
|
||||
if (Array.isArray(select)) {
|
||||
select.forEach((field, index) => {
|
||||
cteInnerQuery.select(
|
||||
knex.raw('jsonb_path_query(data, :path) as :name:', {
|
||||
path: '$.' + field,
|
||||
name: '' + index
|
||||
})
|
||||
)
|
||||
})
|
||||
// otherwise, get the whole object, as stored in the jsonb column
|
||||
} else {
|
||||
cteInnerQuery.select('data')
|
||||
}
|
||||
|
||||
// join on objects table
|
||||
cteInnerQuery.join( 'objects', function() {
|
||||
this.on( 'objects.streamId', '=', 'object_children_closure.streamId' )
|
||||
.andOn( 'objects.id', '=', 'object_children_closure.child' )
|
||||
} )
|
||||
.where( 'object_children_closure.streamId', streamId )
|
||||
.andWhere( 'parent', objectId )
|
||||
.andWhere( 'minDepth', '<', depth )
|
||||
// join on objects table
|
||||
cteInnerQuery
|
||||
.join('objects', function () {
|
||||
this.on('objects.streamId', '=', 'object_children_closure.streamId').andOn(
|
||||
'objects.id',
|
||||
'=',
|
||||
'object_children_closure.child'
|
||||
)
|
||||
})
|
||||
.where('object_children_closure.streamId', streamId)
|
||||
.andWhere('parent', objectId)
|
||||
.andWhere('minDepth', '<', depth)
|
||||
|
||||
// Add user provided filters/queries.
|
||||
if ( Array.isArray( query ) && query.length > 0 ) {
|
||||
cteInnerQuery.andWhere( nestedWhereQuery => {
|
||||
query.forEach( ( statement, index ) => {
|
||||
let castType = 'text'
|
||||
if ( typeof statement.value === 'string' ) castType = 'text'
|
||||
if ( typeof statement.value === 'boolean' ) castType = 'boolean'
|
||||
if ( typeof statement.value === 'number' ) castType = 'numeric'
|
||||
// Add user provided filters/queries.
|
||||
if (Array.isArray(query) && query.length > 0) {
|
||||
cteInnerQuery.andWhere((nestedWhereQuery) => {
|
||||
query.forEach((statement, index) => {
|
||||
let castType = 'text'
|
||||
if (typeof statement.value === 'string') castType = 'text'
|
||||
if (typeof statement.value === 'boolean') castType = 'boolean'
|
||||
if (typeof statement.value === 'number') castType = 'numeric'
|
||||
|
||||
if ( operatorsWhitelist.indexOf( statement.operator ) == -1 )
|
||||
throw new Error( 'Invalid operator for query' )
|
||||
if (operatorsWhitelist.indexOf(statement.operator) == -1)
|
||||
throw new Error('Invalid operator for query')
|
||||
|
||||
// Determine the correct where clause (where, and where, or where)
|
||||
let whereClause
|
||||
if ( index === 0 ) whereClause = 'where'
|
||||
else if ( statement.verb && statement.verb.toLowerCase( ) === 'or' ) whereClause = 'orWhere'
|
||||
else whereClause = 'andWhere'
|
||||
// Determine the correct where clause (where, and where, or where)
|
||||
let whereClause
|
||||
if (index === 0) whereClause = 'where'
|
||||
else if (statement.verb && statement.verb.toLowerCase() === 'or')
|
||||
whereClause = 'orWhere'
|
||||
else whereClause = 'andWhere'
|
||||
|
||||
// Note: castType is generated from the statement's value and operators are matched against a whitelist.
|
||||
// If comparing with strings, the jsonb_path_query(_first) func returns json encoded strings (ie, `bar` is actually `"bar"`), hence we need to add the quotes manually to the raw provided comparison value.
|
||||
nestedWhereQuery[ whereClause ]( knex.raw( `jsonb_path_query_first( data, ? )::${castType} ${statement.operator} ? `, [ '$.' + statement.field, castType === 'text' ? `"${statement.value}"` : statement.value ] ) )
|
||||
} )
|
||||
} )
|
||||
}
|
||||
// Note: castType is generated from the statement's value and operators are matched against a whitelist.
|
||||
// If comparing with strings, the jsonb_path_query(_first) func returns json encoded strings (ie, `bar` is actually `"bar"`), hence we need to add the quotes manually to the raw provided comparison value.
|
||||
nestedWhereQuery[whereClause](
|
||||
knex.raw(
|
||||
`jsonb_path_query_first( data, ? )::${castType} ${statement.operator} ? `,
|
||||
[
|
||||
'$.' + statement.field,
|
||||
castType === 'text' ? `"${statement.value}"` : statement.value
|
||||
]
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Order by clause; validate direction!
|
||||
let direction = orderBy.direction && orderBy.direction.toLowerCase( ) === 'desc' ? 'desc' : 'asc'
|
||||
if ( orderBy.field === 'id' ) {
|
||||
cteInnerQuery.orderBy( 'id', direction )
|
||||
} else {
|
||||
cteInnerQuery.orderByRaw( knex.raw( `jsonb_path_query_first( data, ? ) ${direction}, id asc`, [ '$.' + orderBy.field ] ) )
|
||||
}
|
||||
} )
|
||||
.select( '*' ).from( 'objs' )
|
||||
.joinRaw( 'RIGHT JOIN ( SELECT count(*) FROM "objs" ) c(total_count) ON TRUE' )
|
||||
// Order by clause; validate direction!
|
||||
let direction =
|
||||
orderBy.direction && orderBy.direction.toLowerCase() === 'desc' ? 'desc' : 'asc'
|
||||
if (orderBy.field === 'id') {
|
||||
cteInnerQuery.orderBy('id', direction)
|
||||
} else {
|
||||
cteInnerQuery.orderByRaw(
|
||||
knex.raw(`jsonb_path_query_first( data, ? ) ${direction}, id asc`, [
|
||||
'$.' + orderBy.field
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
.select('*')
|
||||
.from('objs')
|
||||
.joinRaw('RIGHT JOIN ( SELECT count(*) FROM "objs" ) c(total_count) ON TRUE')
|
||||
|
||||
// Set cursor clause, if present. If it's not present, it's an entry query; this method will return a cursor based on its given query.
|
||||
// We have implemented keyset pagination for more efficient searches on larger sets. This approach depends on an order by value provided by the user and a (hidden) primary key.
|
||||
// console.log( cursor )
|
||||
if ( cursor ) {
|
||||
if (cursor) {
|
||||
let castType = 'text'
|
||||
if ( typeof cursor.value === 'string' ) castType = 'text'
|
||||
if ( typeof cursor.value === 'boolean' ) castType = 'boolean'
|
||||
if ( typeof cursor.value === 'number' ) castType = 'numeric'
|
||||
if (typeof cursor.value === 'string') castType = 'text'
|
||||
if (typeof cursor.value === 'boolean') castType = 'boolean'
|
||||
if (typeof cursor.value === 'number') castType = 'numeric'
|
||||
|
||||
// When strings are used inside an order clause, as mentioned above, we need to add quotes around the comparison value, as the jsonb_path_query funcs return json encoded strings (`{"test":"foo"}` => test is returned as `"foo"`)
|
||||
if ( castType === 'text' )
|
||||
cursor.value = `"${cursor.value}"`
|
||||
if (castType === 'text') cursor.value = `"${cursor.value}"`
|
||||
|
||||
if ( operatorsWhitelist.indexOf( cursor.operator ) == -1 )
|
||||
throw new Error( 'Invalid operator for cursor' )
|
||||
if (operatorsWhitelist.indexOf(cursor.operator) == -1)
|
||||
throw new Error('Invalid operator for cursor')
|
||||
|
||||
// Unwrapping the tuple comparison of ( userOrderByField, id ) > ( lastValueOfUserOrderBy, lastSeenId )
|
||||
if ( fullObjectSelect ) {
|
||||
if ( cursor.field === 'id' ) {
|
||||
mainQuery.where( knex.raw( `id ${cursor.operator} ? `, [ cursor.value ] ) )
|
||||
if (fullObjectSelect) {
|
||||
if (cursor.field === 'id') {
|
||||
mainQuery.where(knex.raw(`id ${cursor.operator} ? `, [cursor.value]))
|
||||
} else {
|
||||
mainQuery.where( knex.raw( `jsonb_path_query_first( data, ? )::${castType} ${cursor.operator}= ? `, [ '$.' + cursor.field, cursor.value ] ) )
|
||||
mainQuery.where(
|
||||
knex.raw(`jsonb_path_query_first( data, ? )::${castType} ${cursor.operator}= ? `, [
|
||||
'$.' + cursor.field,
|
||||
cursor.value
|
||||
])
|
||||
)
|
||||
}
|
||||
} else {
|
||||
mainQuery.where( knex.raw( `??::${castType} ${cursor.operator}= ? `, [ select.indexOf( cursor.field ).toString( ), cursor.value ] ) )
|
||||
mainQuery.where(
|
||||
knex.raw(`??::${castType} ${cursor.operator}= ? `, [
|
||||
select.indexOf(cursor.field).toString(),
|
||||
cursor.value
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
if ( cursor.lastSeenId ) {
|
||||
mainQuery.andWhere( qb => {
|
||||
qb.where( 'id', '>', cursor.lastSeenId )
|
||||
if ( fullObjectSelect )
|
||||
qb.orWhere( knex.raw( `jsonb_path_query_first( data, ? )::${castType} ${cursor.operator} ? `, [ '$.' + cursor.field, cursor.value ] ) )
|
||||
if (cursor.lastSeenId) {
|
||||
mainQuery.andWhere((qb) => {
|
||||
qb.where('id', '>', cursor.lastSeenId)
|
||||
if (fullObjectSelect)
|
||||
qb.orWhere(
|
||||
knex.raw(`jsonb_path_query_first( data, ? )::${castType} ${cursor.operator} ? `, [
|
||||
'$.' + cursor.field,
|
||||
cursor.value
|
||||
])
|
||||
)
|
||||
else
|
||||
qb.orWhere( knex.raw( `??::${castType} ${cursor.operator} ? `, [ select.indexOf( cursor.field ).toString( ), cursor.value ] ) )
|
||||
} )
|
||||
qb.orWhere(
|
||||
knex.raw(`??::${castType} ${cursor.operator} ? `, [
|
||||
select.indexOf(cursor.field).toString(),
|
||||
cursor.value
|
||||
])
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mainQuery.limit( limit )
|
||||
mainQuery.limit(limit)
|
||||
// console.log( mainQuery.toString() )
|
||||
// Finally, execute the query
|
||||
let rows = await mainQuery
|
||||
let totalCount = rows && rows.length > 0 ? parseInt( rows[ 0 ].total_count ) : 0
|
||||
let totalCount = rows && rows.length > 0 ? parseInt(rows[0].total_count) : 0
|
||||
|
||||
// Return early
|
||||
if ( totalCount === 0 )
|
||||
return { totalCount, objects: [ ], cursor: null }
|
||||
|
||||
if (totalCount === 0) return { totalCount, objects: [], cursor: null }
|
||||
|
||||
// Reconstruct the object based on the provided select paths.
|
||||
if ( !fullObjectSelect ) {
|
||||
rows.forEach( ( o, i, arr ) => {
|
||||
let no = { id: o.id, createdAt: o.createdAt, speckleType: o.speckleType, totalChildrenCount: o.totalChildrenCount, data: {} }
|
||||
let k = 0
|
||||
for ( let field of select ) {
|
||||
set( no.data, field, o[ k++ ] )
|
||||
if (!fullObjectSelect) {
|
||||
rows.forEach((o, i, arr) => {
|
||||
let no = {
|
||||
id: o.id,
|
||||
createdAt: o.createdAt,
|
||||
speckleType: o.speckleType,
|
||||
totalChildrenCount: o.totalChildrenCount,
|
||||
data: {}
|
||||
}
|
||||
arr[ i ] = no
|
||||
} )
|
||||
let k = 0
|
||||
for (let field of select) {
|
||||
set(no.data, field, o[k++])
|
||||
}
|
||||
arr[i] = no
|
||||
})
|
||||
}
|
||||
|
||||
// Assemble the cursor for an eventual next call
|
||||
cursor = cursor || {}
|
||||
let cursorObj = {
|
||||
field: cursor.field || orderBy.field,
|
||||
operator: cursor.operator || ( orderBy.direction && orderBy.direction.toLowerCase( ) === 'desc' ? '<' : '>' ),
|
||||
value: get( rows[ rows.length - 1 ], `data.${orderBy.field}` )
|
||||
operator:
|
||||
cursor.operator ||
|
||||
(orderBy.direction && orderBy.direction.toLowerCase() === 'desc' ? '<' : '>'),
|
||||
value: get(rows[rows.length - 1], `data.${orderBy.field}`)
|
||||
}
|
||||
|
||||
// If we're not ordering by id (default case, where no order by argument is provided), we need to add the last seen id of this query in order to enable keyset pagination.
|
||||
if ( additionalIdOrderBy ) {
|
||||
cursorObj.lastSeenId = rows[ rows.length - 1 ].id
|
||||
if (additionalIdOrderBy) {
|
||||
cursorObj.lastSeenId = rows[rows.length - 1].id
|
||||
}
|
||||
|
||||
// Cursor objects should be client-side opaque, hence we encode them to base64.
|
||||
let cursorEncoded = Buffer.from( JSON.stringify( cursorObj ), 'binary' ).toString( 'base64' )
|
||||
let cursorEncoded = Buffer.from(JSON.stringify(cursorObj), 'binary').toString('base64')
|
||||
return { totalCount, objects: rows, cursor: rows.length === limit ? cursorEncoded : null }
|
||||
},
|
||||
|
||||
async getObjects( streamId, objectIds ) {
|
||||
let res = await Objects( )
|
||||
.whereIn( 'id', objectIds )
|
||||
.andWhere( 'streamId', streamId )
|
||||
.select( 'id', 'speckleType', 'totalChildrenCount', 'totalChildrenCountByDepth', 'createdAt', 'data' )
|
||||
async getObjects(streamId, objectIds) {
|
||||
let res = await Objects()
|
||||
.whereIn('id', objectIds)
|
||||
.andWhere('streamId', streamId)
|
||||
.select(
|
||||
'id',
|
||||
'speckleType',
|
||||
'totalChildrenCount',
|
||||
'totalChildrenCountByDepth',
|
||||
'createdAt',
|
||||
'data'
|
||||
)
|
||||
return res
|
||||
},
|
||||
|
||||
async getObjectsStream( { streamId, objectIds } ) {
|
||||
let res = Objects( )
|
||||
.whereIn( 'id', objectIds )
|
||||
.andWhere( 'streamId', streamId )
|
||||
.orderBy( 'id' )
|
||||
.select( knex.raw( '"id", "speckleType", "totalChildrenCount", "totalChildrenCountByDepth", "createdAt", data::text as "dataText"' ) )
|
||||
return res.stream( { highWaterMark: 500 } )
|
||||
async getObjectsStream({ streamId, objectIds }) {
|
||||
let res = Objects()
|
||||
.whereIn('id', objectIds)
|
||||
.andWhere('streamId', streamId)
|
||||
.orderBy('id')
|
||||
.select(
|
||||
knex.raw(
|
||||
'"id", "speckleType", "totalChildrenCount", "totalChildrenCountByDepth", "createdAt", data::text as "dataText"'
|
||||
)
|
||||
)
|
||||
return res.stream({ highWaterMark: 500 })
|
||||
},
|
||||
|
||||
async hasObjects( { streamId, objectIds } ) {
|
||||
let dbRes = await Objects( )
|
||||
.whereIn( 'id', objectIds )
|
||||
.andWhere( 'streamId', streamId )
|
||||
.select( 'id' )
|
||||
async hasObjects({ streamId, objectIds }) {
|
||||
let dbRes = await Objects().whereIn('id', objectIds).andWhere('streamId', streamId).select('id')
|
||||
|
||||
let res = {}
|
||||
for ( let i in objectIds ) {
|
||||
res[ objectIds[ i ] ] = false
|
||||
for (let i in objectIds) {
|
||||
res[objectIds[i]] = false
|
||||
}
|
||||
for ( let i in dbRes ) {
|
||||
res [ dbRes[ i ].id ] = true
|
||||
for (let i in dbRes) {
|
||||
res[dbRes[i].id] = true
|
||||
}
|
||||
return res
|
||||
},
|
||||
|
||||
|
||||
// NOTE: Derive Object
|
||||
async updateObject( ) {
|
||||
throw new Error( 'not implemeneted' )
|
||||
async updateObject() {
|
||||
throw new Error('not implemeneted')
|
||||
}
|
||||
}
|
||||
|
||||
// Note: we're generating the hash here, rather than on the db side, as there are
|
||||
// limitations when doing upserts - ignored fields are not always returned, hence
|
||||
// we cannot provide a full response back including all object hashes.
|
||||
function prepInsertionObject( streamId, obj ) {
|
||||
let memNow = process.memoryUsage( ).heapUsed / 1024 / 1024
|
||||
function prepInsertionObject(streamId, obj) {
|
||||
let memNow = process.memoryUsage().heapUsed / 1024 / 1024
|
||||
const MAX_OBJECT_SIZE = 10 * 1024 * 1024
|
||||
|
||||
if ( obj.hash )
|
||||
obj.id = obj.hash
|
||||
else
|
||||
obj.id = obj.id || crypto.createHash( 'md5' ).update( JSON.stringify( obj ) ).digest( 'hex' ) // generate a hash if none is present
|
||||
if (obj.hash) obj.id = obj.hash
|
||||
else obj.id = obj.id || crypto.createHash('md5').update(JSON.stringify(obj)).digest('hex') // generate a hash if none is present
|
||||
|
||||
let stringifiedObj = JSON.stringify( obj )
|
||||
if ( stringifiedObj.length > MAX_OBJECT_SIZE ) {
|
||||
throw new Error( `Object too large (${stringifiedObj.length} > ${MAX_OBJECT_SIZE})` )
|
||||
let stringifiedObj = JSON.stringify(obj)
|
||||
if (stringifiedObj.length > MAX_OBJECT_SIZE) {
|
||||
throw new Error(`Object too large (${stringifiedObj.length} > ${MAX_OBJECT_SIZE})`)
|
||||
}
|
||||
let memAfter = process.memoryUsage( ).heapUsed / 1024 / 1024
|
||||
let memAfter = process.memoryUsage().heapUsed / 1024 / 1024
|
||||
|
||||
return {
|
||||
data: stringifiedObj, // stored in jsonb column
|
||||
@@ -496,10 +592,12 @@ function prepInsertionObject( streamId, obj ) {
|
||||
}
|
||||
|
||||
// Batches need to be inserted ordered by id to avoid deadlocks
|
||||
function prepInsertionObjectBatch( batch ) {
|
||||
batch.sort( ( a, b ) => ( a.id > b.id ) ? 1 : -1 )
|
||||
function prepInsertionObjectBatch(batch) {
|
||||
batch.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||
}
|
||||
|
||||
function prepInsertionClosureBatch( batch ) {
|
||||
batch.sort( ( a, b ) => ( a.parent > b.parent ) ? 1 : ( a.parent === b.parent ) ? ( ( a.child > b.child ) ? 1 : -1 ) : -1 )
|
||||
function prepInsertionClosureBatch(batch) {
|
||||
batch.sort((a, b) =>
|
||||
a.parent > b.parent ? 1 : a.parent === b.parent ? (a.child > b.child ? 1 : -1) : -1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
'use strict'
|
||||
const _ = require('lodash')
|
||||
const crs = require('crypto-random-string')
|
||||
const appRoot = require('app-root-path')
|
||||
const knex = require(`${appRoot}/db/knex`)
|
||||
|
||||
const Streams = () => knex('streams')
|
||||
const Acl = () => knex('stream_acl')
|
||||
|
||||
const debug = require('debug')
|
||||
const { createBranch } = require('./branches')
|
||||
|
||||
const { createBranch } = require('@/modules/core/services/branches')
|
||||
const { Streams, StreamAcl, knex } = require('@/modules/core/dbSchema')
|
||||
const {
|
||||
BASE_STREAM_COLUMNS,
|
||||
getStream,
|
||||
getFavoritedStreams,
|
||||
getFavoritedStreamsCount,
|
||||
setStreamFavorited,
|
||||
canUserFavoriteStream
|
||||
} = require('@/modules/core/repositories/streams')
|
||||
const { UnauthorizedAccessError, InvalidArgumentError } = require('@/modules/core/errors/base')
|
||||
|
||||
/**
|
||||
* Get base query for finding or counting user streams
|
||||
* @param {object} p
|
||||
* @param {string} [p.searchQuery] Filter by name/description/id
|
||||
* @param {boolean} [p.publicOnly] Whether to only look for public streams
|
||||
* @param {string} p.userId The user's ID
|
||||
*/
|
||||
function getUserStreamsQueryBase({ userId, publicOnly, searchQuery }) {
|
||||
const query = StreamAcl.knex()
|
||||
.where(StreamAcl.col.userId, userId)
|
||||
.join(Streams.name, StreamAcl.col.resourceId, Streams.col.id)
|
||||
|
||||
if (publicOnly) query.andWhere(Streams.col.isPublic, true)
|
||||
|
||||
if (searchQuery)
|
||||
query.andWhere(function () {
|
||||
this.where(Streams.col.name, 'ILIKE', `%${searchQuery}%`)
|
||||
.orWhere(Streams.col.description, 'ILIKE', `%${searchQuery}%`)
|
||||
.orWhere(Streams.col.id, 'ILIKE', `%${searchQuery}%`) //potentially useless?
|
||||
})
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
async createStream({ name, description, isPublic, ownerId }) {
|
||||
@@ -20,8 +50,8 @@ module.exports = {
|
||||
}
|
||||
|
||||
// Create the stream & set up permissions
|
||||
let [{ id: streamId }] = await Streams().returning('id').insert(stream)
|
||||
await Acl().insert({ userId: ownerId, resourceId: streamId, role: 'stream:owner' })
|
||||
let [{ id: streamId }] = await Streams.knex().returning('id').insert(stream)
|
||||
await StreamAcl.knex().insert({ userId: ownerId, resourceId: streamId, role: 'stream:owner' })
|
||||
|
||||
// Create a default main branch
|
||||
await createBranch({
|
||||
@@ -33,39 +63,34 @@ module.exports = {
|
||||
return streamId
|
||||
},
|
||||
|
||||
async getStream({ streamId, userId }) {
|
||||
let stream = await Streams().where({ id: streamId }).select('*').first()
|
||||
if (!userId) return stream
|
||||
|
||||
let acl = await Acl().where({ resourceId: streamId, userId: userId }).select('role').first()
|
||||
if (acl) stream.role = acl.role
|
||||
return stream
|
||||
},
|
||||
getStream,
|
||||
|
||||
async updateStream({ streamId, name, description, isPublic }) {
|
||||
let [{ id }] = await Streams()
|
||||
let [{ id }] = await Streams.knex()
|
||||
.returning('id')
|
||||
.where({ id: streamId })
|
||||
.update({ name, description, isPublic, updatedAt: knex.fn.now() })
|
||||
return id
|
||||
},
|
||||
|
||||
setStreamFavorited,
|
||||
|
||||
async grantPermissionsStream({ streamId, userId, role }) {
|
||||
// upserts the existing role (sets a new one!)
|
||||
// TODO: check if we're removing the last owner (ie, does the stream still have an owner after this operation)?
|
||||
let query =
|
||||
Acl().insert({ userId: userId, resourceId: streamId, role: role }).toString() +
|
||||
StreamAcl.knex().insert({ userId: userId, resourceId: streamId, role: role }).toString() +
|
||||
' on conflict on constraint stream_acl_pkey do update set role=excluded.role'
|
||||
|
||||
await knex.raw(query)
|
||||
|
||||
// update stream updated at
|
||||
await Streams().where({ id: streamId }).update({ updatedAt: knex.fn.now() })
|
||||
await Streams.knex().where({ id: streamId }).update({ updatedAt: knex.fn.now() })
|
||||
return true
|
||||
},
|
||||
|
||||
async revokePermissionsStream({ streamId, userId }) {
|
||||
let streamAclEntriesCount = Acl().count({ resourceId: streamId })
|
||||
let streamAclEntriesCount = StreamAcl.knex().count({ resourceId: streamId })
|
||||
// TODO: check if streamAclEntriesCount === 1 then throw big boo-boo (can't delete last ownership link)
|
||||
|
||||
if (streamAclEntriesCount === 1)
|
||||
@@ -75,23 +100,26 @@ module.exports = {
|
||||
// Count owners
|
||||
// If owner count > 1, then proceed to delete, otherwise throw an error (can't delete last owner - delete stream)
|
||||
|
||||
let aclEntry = await Acl().where({ resourceId: streamId, userId: userId }).select('*').first()
|
||||
let aclEntry = await StreamAcl.knex()
|
||||
.where({ resourceId: streamId, userId: userId })
|
||||
.select('*')
|
||||
.first()
|
||||
|
||||
if (aclEntry.role === 'stream:owner') {
|
||||
let ownersCount = Acl().count({ resourceId: streamId, role: 'stream:owner' })
|
||||
let ownersCount = StreamAcl.knex().count({ resourceId: streamId, role: 'stream:owner' })
|
||||
if (ownersCount === 1) throw new Error('Could not revoke permissions for user')
|
||||
else {
|
||||
await Acl().where({ resourceId: streamId, userId: userId }).del()
|
||||
await StreamAcl.knex().where({ resourceId: streamId, userId: userId }).del()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let delCount = await Acl().where({ resourceId: streamId, userId: userId }).del()
|
||||
let delCount = await StreamAcl.knex().where({ resourceId: streamId, userId: userId }).del()
|
||||
|
||||
if (delCount === 0) throw new Error('Could not revoke permissions for user')
|
||||
|
||||
// update stream updated at
|
||||
await Streams().where({ id: streamId }).update({ updatedAt: knex.fn.now() })
|
||||
await Streams.knex().where({ id: streamId }).update({ updatedAt: knex.fn.now() })
|
||||
|
||||
return true
|
||||
},
|
||||
@@ -110,39 +138,29 @@ module.exports = {
|
||||
`,
|
||||
[streamId]
|
||||
)
|
||||
return await Streams().where({ id: streamId }).del()
|
||||
return await Streams.knex().where({ id: streamId }).del()
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the streams the user has access to
|
||||
* @param {object} p
|
||||
* @param {string} p.searchQuery Filter by name/description/id
|
||||
* @param {boolean} p.publicOnly Whether to only look for public streams
|
||||
* @param {string} p.userId The user's ID
|
||||
* @param {number} p.limit Max amount of items to return
|
||||
* @param {string} p.cursor Timestamp after which to look for items
|
||||
* @returns {{streams: Array, cursor: string|null}}
|
||||
*/
|
||||
async getUserStreams({ userId, limit, cursor, publicOnly, searchQuery }) {
|
||||
limit = limit || 25
|
||||
publicOnly = publicOnly !== false //defaults to true if not provided
|
||||
const finalLimit = limit || 25
|
||||
const isPublicOnly = publicOnly !== false //defaults to true if not provided
|
||||
|
||||
let query = Acl()
|
||||
.columns([
|
||||
{ id: 'streams.id' },
|
||||
'name',
|
||||
'description',
|
||||
'isPublic',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'role'
|
||||
])
|
||||
.select()
|
||||
.join('streams', 'stream_acl.resourceId', 'streams.id')
|
||||
.where('stream_acl.userId', userId)
|
||||
const query = getUserStreamsQueryBase({ userId, publicOnly: isPublicOnly, searchQuery })
|
||||
query.columns(BASE_STREAM_COLUMNS).select()
|
||||
|
||||
if (cursor) query.andWhere('streams.updatedAt', '<', cursor)
|
||||
if (cursor) query.andWhere(Streams.col.updatedAt, '<', cursor)
|
||||
|
||||
if (publicOnly) query.andWhere('streams.isPublic', true)
|
||||
|
||||
if (searchQuery)
|
||||
query.andWhere(function () {
|
||||
this.where('name', 'ILIKE', `%${searchQuery}%`)
|
||||
.orWhere('description', 'ILIKE', `%${searchQuery}%`)
|
||||
.orWhere('id', 'ILIKE', `%${searchQuery}%`) //potentially useless?
|
||||
})
|
||||
|
||||
query.orderBy('streams.updatedAt', 'desc').limit(limit)
|
||||
query.orderBy(Streams.col.updatedAt, 'desc').limit(finalLimit)
|
||||
|
||||
let rows = await query
|
||||
return {
|
||||
@@ -159,7 +177,7 @@ module.exports = {
|
||||
.leftJoin('objects', 'streams.id', 'objects.streamId')
|
||||
.groupBy('streams.id')
|
||||
|
||||
let countQuery = Streams()
|
||||
let countQuery = Streams.knex()
|
||||
|
||||
if (searchQuery) {
|
||||
const whereFunc = function () {
|
||||
@@ -196,29 +214,25 @@ module.exports = {
|
||||
return { streams: rows, totalCount: count }
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the total amount of streams the user has access to
|
||||
* @param {object} p
|
||||
* @param {string} p.searchQuery Filter by name/description/id
|
||||
* @param {boolean} p.publicOnly Whether to only look for public streams
|
||||
* @param {string} p.userId The user's ID
|
||||
*/
|
||||
async getUserStreamsCount({ userId, publicOnly, searchQuery }) {
|
||||
publicOnly = publicOnly !== false //defaults to true if not provided
|
||||
const isPublicOnly = publicOnly !== false //defaults to true if not provided
|
||||
|
||||
let query = Acl()
|
||||
.count()
|
||||
.join('streams', 'stream_acl.resourceId', 'streams.id')
|
||||
.where({ userId: userId })
|
||||
|
||||
if (publicOnly) query.andWhere('streams.isPublic', true)
|
||||
|
||||
if (searchQuery)
|
||||
query.andWhere(function () {
|
||||
this.where('name', 'ILIKE', `%${searchQuery}%`)
|
||||
.orWhere('description', 'ILIKE', `%${searchQuery}%`)
|
||||
.orWhere('id', 'ILIKE', `%${searchQuery}%`) //potentially useless?
|
||||
})
|
||||
const query = getUserStreamsQueryBase({ userId, publicOnly: isPublicOnly, searchQuery })
|
||||
query.count()
|
||||
|
||||
let [res] = await query
|
||||
return parseInt(res.count)
|
||||
},
|
||||
|
||||
async getStreamUsers({ streamId }) {
|
||||
let query = Acl()
|
||||
let query = StreamAcl.knex()
|
||||
.columns({ role: 'stream_acl.role' }, 'id', 'name', 'company', 'avatar')
|
||||
.select()
|
||||
.where({ resourceId: streamId })
|
||||
@@ -227,6 +241,101 @@ module.exports = {
|
||||
.orderBy('stream_acl.role')
|
||||
|
||||
return await query
|
||||
},
|
||||
|
||||
/**
|
||||
* Favorite or unfavorite a stream
|
||||
* @param {Object} p
|
||||
* @param {string} p.userId
|
||||
* @param {string} p.streamId
|
||||
* @param {boolean} [p.favorited] Whether to favorite or unfavorite (true by default)
|
||||
* @returns {Promise<Object>} Updated stream
|
||||
*/
|
||||
async favoriteStream({ userId, streamId, favorited }) {
|
||||
// Check if user has access to stream
|
||||
if (!(await canUserFavoriteStream({ userId, streamId }))) {
|
||||
throw new UnauthorizedAccessError("User doesn't have access to the specified stream", {
|
||||
info: { userId, streamId }
|
||||
})
|
||||
}
|
||||
|
||||
// Favorite/unfavorite the stream
|
||||
await setStreamFavorited({ streamId, userId, favorited })
|
||||
|
||||
// Get updated stream info
|
||||
return await getStream({ streamId, userId })
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user favorited streams & metadata
|
||||
* @param {Object} p
|
||||
* @param {string} p.userId
|
||||
* @param {number} [p.limit] Defaults to 25
|
||||
* @param {string} [p.cursor] Optionally specify date after which to look for favorites
|
||||
* @returns
|
||||
*/
|
||||
async getFavoriteStreamsCollection({ userId, limit, cursor }) {
|
||||
limit = _.clamp(limit || 25, 1, 25)
|
||||
|
||||
// Get total count of favorited streams
|
||||
const totalCount = await getFavoritedStreamsCount(userId)
|
||||
|
||||
// Get paginated streams
|
||||
const { cursor: finalCursor, streams } = await getFavoritedStreams({
|
||||
userId,
|
||||
cursor,
|
||||
limit
|
||||
})
|
||||
|
||||
return { totalCount, cursor: finalCursor, items: streams }
|
||||
},
|
||||
|
||||
/**
|
||||
* Get active user stream favorite date (using dataloader)
|
||||
* @param {Object} p
|
||||
* @param {import('@/modules/shared/index').GraphQLContext} p.ctx
|
||||
* @param {string} p.streamId
|
||||
* @param {Promise<string| null>}
|
||||
*/
|
||||
async getActiveUserStreamFavoriteDate({ ctx, streamId }) {
|
||||
if (!ctx.userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!streamId) {
|
||||
throw new InvalidArgumentError('Invalid stream ID')
|
||||
}
|
||||
|
||||
return (await ctx.loaders.streams.getUserFavoriteData.load(streamId))?.createdAt || null
|
||||
},
|
||||
|
||||
/**
|
||||
* Get stream favorites count (using dataloader)
|
||||
* @param {Object} p
|
||||
* @param {import('@/modules/shared/index').GraphQLContext} p.ctx
|
||||
* @param {string} p.streamId
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async getStreamFavoritesCount({ ctx, streamId }) {
|
||||
if (!streamId) {
|
||||
throw new InvalidArgumentError('Invalid stream ID')
|
||||
}
|
||||
|
||||
return (await ctx.loaders.streams.getFavoritesCount.load(streamId)) || 0
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} p
|
||||
* @param {import('@/modules/shared/index').GraphQLContext} p.ctx
|
||||
* @param {string} p.userId
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async getOwnedFavoritesCount({ ctx, userId }) {
|
||||
if (!userId) {
|
||||
throw new InvalidArgumentError('Invalid user ID')
|
||||
}
|
||||
|
||||
return (await ctx.loaders.streams.getOwnedFavoritesCount.load(userId)) || 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/* instanbul ignore file */
|
||||
const expect = require('chai').expect
|
||||
|
||||
const { buildApolloServer } = require('@/app')
|
||||
const { StreamFavorites, Streams, Users, knex } = require('@/modules/core/dbSchema')
|
||||
const { Roles, AllScopes } = require('@/modules/core/helpers/mainConstants')
|
||||
const { createStream } = require('@/modules/core/services/streams')
|
||||
const { createUser } = require('@/modules/core/services/users')
|
||||
const { addLoadersToCtx } = require('@/modules/shared')
|
||||
const { truncateTables } = require('@/test/hooks')
|
||||
const { gql } = require('apollo-server-express')
|
||||
|
||||
/**
|
||||
* Cleaning up relevant tables
|
||||
*/
|
||||
async function cleanup() {
|
||||
await truncateTables([StreamFavorites.name, Streams.name, Users.name])
|
||||
}
|
||||
|
||||
const favoriteMutationGql = gql`
|
||||
mutation ($sid: String!, $favorited: Boolean!) {
|
||||
streamFavorite(streamId: $sid, favorited: $favorited) {
|
||||
id
|
||||
favoritedDate
|
||||
favoritesCount
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const favoriteStreamsQueryGql = gql`
|
||||
query ($cursor: String, $limit: Int! = 10) {
|
||||
user {
|
||||
id
|
||||
favoriteStreams(cursor: $cursor, limit: $limit) {
|
||||
totalCount
|
||||
cursor
|
||||
items {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const anotherUserFavoriteStreamsQueryGql = gql`
|
||||
query ($cursor: String, $limit: Int! = 10, $uid: String!) {
|
||||
user(id: $uid) {
|
||||
id
|
||||
favoriteStreams(cursor: $cursor, limit: $limit) {
|
||||
totalCount
|
||||
cursor
|
||||
items {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const totalOwnedStreamsFavorites = gql`
|
||||
query ($uid: String!) {
|
||||
user(id: $uid) {
|
||||
id
|
||||
totalOwnedStreamsFavorites
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe('Favorite streams', () => {
|
||||
const myPubStream = {
|
||||
name: 'My Stream 1',
|
||||
isPublic: false
|
||||
}
|
||||
const myStream = {
|
||||
name: 'My Stream 2',
|
||||
isPublic: true
|
||||
}
|
||||
const notMyStream = {
|
||||
name: 'Not My Stream 1',
|
||||
isPublic: false
|
||||
}
|
||||
const notMyPubStream = {
|
||||
name: 'Not My Stream 2',
|
||||
isPublic: true
|
||||
}
|
||||
let me = {
|
||||
name: 'Itsa Me',
|
||||
email: 'me@gmail.com',
|
||||
password: 'sn3aky-1337-b1m'
|
||||
}
|
||||
let otherGuy = {
|
||||
name: 'Some Other DUde',
|
||||
email: 'otherguy@gmail.com',
|
||||
password: 'sn3aky-1337-b1m'
|
||||
}
|
||||
|
||||
before(async function () {
|
||||
await cleanup()
|
||||
|
||||
// Seeding
|
||||
await Promise.all([
|
||||
createUser(me).then((id) => (me.id = id)),
|
||||
createUser(otherGuy).then((id) => (otherGuy.id = id))
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
createStream({ ...myPubStream, ownerId: me.id }).then((id) => (myPubStream.id = id)),
|
||||
createStream({ ...myStream, ownerId: me.id }).then((id) => (myStream.id = id)),
|
||||
createStream({ ...notMyStream, ownerId: otherGuy.id }).then((id) => (notMyStream.id = id)),
|
||||
createStream({ ...notMyPubStream, ownerId: otherGuy.id }).then(
|
||||
(id) => (notMyPubStream.id = id)
|
||||
)
|
||||
])
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await cleanup()
|
||||
})
|
||||
|
||||
describe('when authenticated', () => {
|
||||
/** @type {import('apollo-server-express').ApolloServer} */
|
||||
let apollo
|
||||
|
||||
const favoriteStream = async (sid, favorited) =>
|
||||
await apollo.executeOperation({
|
||||
query: favoriteMutationGql,
|
||||
variables: { sid, favorited }
|
||||
})
|
||||
|
||||
before(async () => {
|
||||
apollo = buildApolloServer({
|
||||
context: () =>
|
||||
addLoadersToCtx({
|
||||
auth: true,
|
||||
userId: me.id,
|
||||
role: Roles.Server.User,
|
||||
token: 'asd',
|
||||
scopes: AllScopes
|
||||
})
|
||||
})
|
||||
|
||||
// Drop all favorites to ensure we don't favorite already favorited streams
|
||||
await StreamFavorites.knex().truncate()
|
||||
})
|
||||
|
||||
const accessibleStreamIds = [
|
||||
[() => myPubStream.id, 'owned and public'],
|
||||
[() => myStream.id, 'owned and not public'],
|
||||
[() => notMyPubStream.id, 'not owned, but public']
|
||||
]
|
||||
|
||||
accessibleStreamIds.forEach(([id, msgSuffix]) => {
|
||||
it(`can be favorited if ${msgSuffix}`, async () => {
|
||||
const streamId = id()
|
||||
const beforeTime = Date.now()
|
||||
const result = await favoriteStream(streamId, true)
|
||||
const afterTime = Date.now()
|
||||
|
||||
expect(result.errors).to.not.be.ok
|
||||
expect(result.data?.streamFavorite?.favoritedDate).to.be.a('date')
|
||||
expect(result.data?.streamFavorite?.favoritedDate.getTime()).to.satisfy(
|
||||
(t) => t > beforeTime && t < afterTime
|
||||
)
|
||||
expect(result.data?.streamFavorite?.id).to.equal(streamId)
|
||||
expect(result.data?.streamFavorite?.favoritesCount).to.equal(1)
|
||||
})
|
||||
})
|
||||
|
||||
it("can't be favorited if not owned and not public", async () => {
|
||||
const result = await favoriteStream(notMyStream.id, true)
|
||||
|
||||
expect(result.data.streamFavorite).to.not.be.ok
|
||||
expect(result.errors).to.have.lengthOf(1)
|
||||
expect(result.errors.at(0).message).to.contain("doesn't have access")
|
||||
})
|
||||
|
||||
describe('and favorited', () => {
|
||||
let favoritedStream = {
|
||||
name: 'Favorited Stream',
|
||||
isPublic: true
|
||||
}
|
||||
|
||||
/** @type {{favoritedDate: Date, favoritesCount: number, id: string}} */
|
||||
let favoritingResults
|
||||
|
||||
before(async () => {
|
||||
favoritedStream.id = await createStream({ ...favoritedStream, ownerId: me.id })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
const favoritingResult = await favoriteStream(favoritedStream.id, true)
|
||||
favoritingResults = favoritingResult.data?.streamFavorite
|
||||
})
|
||||
|
||||
it('can be favorited again without changing anything', async () => {
|
||||
const result = await favoriteStream(favoritedStream.id, true)
|
||||
|
||||
expect(result.errors).to.not.be.ok
|
||||
expect(result.data?.streamFavorite).to.deep.equalInAnyOrder(favoritingResults)
|
||||
})
|
||||
|
||||
it('can be unfavorited', async () => {
|
||||
const result = await favoriteStream(favoritedStream.id, false)
|
||||
expect(result.errors).to.not.be.ok
|
||||
expect(result.data?.streamFavorite).to.deep.equalInAnyOrder({
|
||||
id: favoritedStream.id,
|
||||
favoritedDate: null,
|
||||
favoritesCount: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('and being queried', () => {
|
||||
const favoritableStreams = [
|
||||
{ name: 'Random 1', isPublic: true },
|
||||
{ name: 'Random 2', isPublic: true },
|
||||
{ name: 'Random 2', isPublic: true }
|
||||
]
|
||||
|
||||
const getFavorites = async (cursor, limit = 10) =>
|
||||
await apollo.executeOperation({
|
||||
query: favoriteStreamsQueryGql,
|
||||
variables: { cursor, limit }
|
||||
})
|
||||
|
||||
const favoritedStreamIds = () => favoritableStreams.map((s) => s.id)
|
||||
|
||||
before(async () => {
|
||||
// Drop all favorites to ensure we're working with a clean slate
|
||||
await StreamFavorites.knex().truncate()
|
||||
|
||||
// Create new ones
|
||||
await Promise.all(
|
||||
favoritableStreams.map((s) =>
|
||||
createStream({ ...s, ownerId: me.id }).then((id) => (s.id = id))
|
||||
)
|
||||
)
|
||||
|
||||
// Pre-favorite all streams
|
||||
await Promise.all(favoritedStreamIds().map(async (id) => favoriteStream(id, true)))
|
||||
})
|
||||
|
||||
it("throw error if trying to get another user's favorite stream collection", async () => {
|
||||
const { data, errors } = await apollo.executeOperation({
|
||||
query: anotherUserFavoriteStreamsQueryGql,
|
||||
variables: { limit: 10, uid: otherGuy.id }
|
||||
})
|
||||
|
||||
expect(data).to.be.ok
|
||||
expect(data.user?.favoriteStreams).to.not.be.ok
|
||||
expect((errors || []).map((e) => e.message).join()).to.match(
|
||||
/cannot view another user's favorite streams/i
|
||||
)
|
||||
})
|
||||
|
||||
it('return valid stream collection', async () => {
|
||||
const results = await getFavorites(null, 10)
|
||||
const ids = favoritedStreamIds()
|
||||
|
||||
expect(results.errors).to.not.be.ok
|
||||
expect(results.data?.user?.favoriteStreams?.items).to.have.lengthOf(ids.length)
|
||||
expect(results.data.user.favoriteStreams.totalCount).to.equal(ids.length)
|
||||
expect(results.data.user.favoriteStreams.cursor).to.be.a('string')
|
||||
})
|
||||
|
||||
it('are paginated correctly', async () => {
|
||||
let nextCursor = null
|
||||
let returnedStreamIds = []
|
||||
|
||||
const getPaginatedAndAssert = async (nextCursor) => {
|
||||
const results = await getFavorites(nextCursor, 1)
|
||||
expect(results.errors).to.not.be.ok
|
||||
expect(results.data?.user?.favoriteStreams).to.be.ok
|
||||
|
||||
return {
|
||||
cursor: results.data.user.favoriteStreams.cursor,
|
||||
sids: results.data.user.favoriteStreams.items.map((i) => i.id)
|
||||
}
|
||||
}
|
||||
|
||||
let failsafe = 3
|
||||
while (failsafe > 0) {
|
||||
let res = await getPaginatedAndAssert(nextCursor)
|
||||
returnedStreamIds = returnedStreamIds.concat(res.sids)
|
||||
nextCursor = res.cursor
|
||||
|
||||
failsafe--
|
||||
if (returnedStreamIds.length < 0) break
|
||||
}
|
||||
|
||||
expect(returnedStreamIds).to.deep.equalInAnyOrder(favoritedStreamIds())
|
||||
})
|
||||
})
|
||||
|
||||
it('return total favorites count for user', async () => {
|
||||
// "Log in" with other user
|
||||
const apollo = buildApolloServer({
|
||||
context: () =>
|
||||
addLoadersToCtx({
|
||||
auth: true,
|
||||
userId: otherGuy.id,
|
||||
role: Roles.Server.User,
|
||||
token: 'asd',
|
||||
scopes: AllScopes
|
||||
})
|
||||
})
|
||||
|
||||
const favoriteStream = async (sid, favorited) =>
|
||||
await apollo.executeOperation({
|
||||
query: favoriteMutationGql,
|
||||
variables: { sid, favorited }
|
||||
})
|
||||
|
||||
// Create favoritable streams
|
||||
const favoriteStreams = [
|
||||
{
|
||||
name: 'OtherStream1',
|
||||
isPublic: true,
|
||||
ownerId: otherGuy.id
|
||||
},
|
||||
{
|
||||
name: 'OtherStream2',
|
||||
isPublic: true,
|
||||
ownerId: otherGuy.id
|
||||
}
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
favoriteStreams.map((s) =>
|
||||
createStream(s).then((res) => {
|
||||
s.id = res
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Favorite all of them
|
||||
await Promise.all(favoriteStreams.map((s) => favoriteStream(s.id, true)))
|
||||
|
||||
const { data, errors } = await apollo.executeOperation({
|
||||
query: totalOwnedStreamsFavorites,
|
||||
variables: { uid: otherGuy.id }
|
||||
})
|
||||
|
||||
expect(errors).to.not.be.ok
|
||||
expect(data?.user?.id).to.equal(otherGuy.id)
|
||||
expect(data?.user?.totalOwnedStreamsFavorites).to.equal(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when not authenticated', () => {
|
||||
/** @type {import('apollo-server-express').ApolloServer} */
|
||||
let apollo
|
||||
|
||||
before(() => {
|
||||
apollo = buildApolloServer({
|
||||
context: () => ({})
|
||||
})
|
||||
})
|
||||
|
||||
it("can't be favorited", async () => {
|
||||
const result = await apollo.executeOperation({
|
||||
query: favoriteMutationGql,
|
||||
variables: { sid: myPubStream.id, favorited: true }
|
||||
})
|
||||
|
||||
expect(result.data.streamFavorite).to.not.be.ok
|
||||
expect(result.errors).to.have.lengthOf(1)
|
||||
expect(result.errors.at(0).message).to.contain('must provide an auth token')
|
||||
})
|
||||
|
||||
it("can't be retrieved", async () => {
|
||||
const result = await apollo.executeOperation({
|
||||
query: favoriteStreamsQueryGql
|
||||
})
|
||||
|
||||
expect(result.data.user).to.be.null
|
||||
expect(result.errors).to.not.be.ok
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +1,86 @@
|
||||
/* istanbul ignore file */
|
||||
const expect = require( 'chai' ).expect
|
||||
const assert = require( 'assert' )
|
||||
const expect = require('chai').expect
|
||||
const assert = require('assert')
|
||||
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const { beforeEachContext } = require( `${appRoot}/test/hooks` )
|
||||
const appRoot = require('app-root-path')
|
||||
const { beforeEachContext } = require(`${appRoot}/test/hooks`)
|
||||
|
||||
const { validateServerRole, contextApiTokenHelper, validateScopes, authorizeResolver } = require( '../../shared' )
|
||||
const {
|
||||
validateServerRole,
|
||||
buildContext,
|
||||
validateScopes,
|
||||
authorizeResolver
|
||||
} = require('../../shared')
|
||||
|
||||
describe( 'Generic AuthN & AuthZ controller tests', ( ) => {
|
||||
before( async ( ) => {
|
||||
await beforeEachContext( )
|
||||
} )
|
||||
describe('Generic AuthN & AuthZ controller tests', () => {
|
||||
before(async () => {
|
||||
await beforeEachContext()
|
||||
})
|
||||
|
||||
it( 'Validate scopes', async ( ) => {
|
||||
it('Validate scopes', async () => {
|
||||
try {
|
||||
await validateScopes( )
|
||||
assert.fail( 'Should have thrown an error with invalid input' )
|
||||
} catch ( e ) {
|
||||
await validateScopes()
|
||||
assert.fail('Should have thrown an error with invalid input')
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
|
||||
try {
|
||||
await validateScopes( [ 'a' ], 'b' )
|
||||
assert.fail( 'Should have thrown an error' )
|
||||
} catch ( e ) {
|
||||
await validateScopes(['a'], 'b')
|
||||
assert.fail('Should have thrown an error')
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
|
||||
await validateScopes( [ 'a', 'b' ], 'b' ) // should pass
|
||||
} )
|
||||
await validateScopes(['a', 'b'], 'b') // should pass
|
||||
})
|
||||
|
||||
it( 'Should create proper context', async ( ) => {
|
||||
let res = await contextApiTokenHelper( { req: { headers: { authorization: 'Bearer BS' } } } )
|
||||
expect( res.auth ).to.equal( false )
|
||||
it('Should create proper context', async () => {
|
||||
let res = await buildContext({ req: { headers: { authorization: 'Bearer BS' } } })
|
||||
expect(res.auth).to.equal(false)
|
||||
|
||||
let res2 = await contextApiTokenHelper( { req: { headers: { authorization: null } } } )
|
||||
expect( res2.auth ).to.equal( false )
|
||||
let res2 = await buildContext({ req: { headers: { authorization: null } } })
|
||||
expect(res2.auth).to.equal(false)
|
||||
|
||||
let res3 = await contextApiTokenHelper( { req: { headers: { authorization: undefined } } } )
|
||||
expect( res3.auth ).to.equal( false )
|
||||
} )
|
||||
let res3 = await buildContext({ req: { headers: { authorization: undefined } } })
|
||||
expect(res3.auth).to.equal(false)
|
||||
})
|
||||
|
||||
it( 'Should validate server role', async ( ) => {
|
||||
it('Should validate server role', async () => {
|
||||
try {
|
||||
let test = await validateServerRole( { auth: true, role: 'server:user' }, 'server:admin' )
|
||||
assert.fail( )
|
||||
} catch ( e ) {
|
||||
assert.equal( 'the void', 'the void' )
|
||||
let test = await validateServerRole({ auth: true, role: 'server:user' }, 'server:admin')
|
||||
assert.fail()
|
||||
} catch (e) {
|
||||
assert.equal('the void', 'the void')
|
||||
}
|
||||
|
||||
try {
|
||||
let test = await validateServerRole( { auth: true, role: 'HACZOR' }, '133TCR3w' )
|
||||
assert.fail( 'Invalid roles should be refused' )
|
||||
} catch ( e ) {
|
||||
assert.equal( 'stares', 'stares' )
|
||||
let test = await validateServerRole({ auth: true, role: 'HACZOR' }, '133TCR3w')
|
||||
assert.fail('Invalid roles should be refused')
|
||||
} catch (e) {
|
||||
assert.equal('stares', 'stares')
|
||||
}
|
||||
|
||||
try {
|
||||
let test = await validateServerRole( { auth: true, role: 'server:admin' }, '133TCR3w' )
|
||||
assert.fail( 'Invalid roles should be refused' )
|
||||
} catch ( e ) {
|
||||
assert.equal( 'and waits dreaming', 'and waits dreaming' )
|
||||
let test = await validateServerRole({ auth: true, role: 'server:admin' }, '133TCR3w')
|
||||
assert.fail('Invalid roles should be refused')
|
||||
} catch (e) {
|
||||
assert.equal('and waits dreaming', 'and waits dreaming')
|
||||
}
|
||||
|
||||
let test = await validateServerRole( { auth: true, role: 'server:admin' }, 'server:user' )
|
||||
expect( test ).to.equal( true )
|
||||
} )
|
||||
let test = await validateServerRole({ auth: true, role: 'server:admin' }, 'server:user')
|
||||
expect(test).to.equal(true)
|
||||
})
|
||||
|
||||
it( 'Resolver Authorization Should fail nicely when roles & resources are wanky', async ( ) => {
|
||||
it('Resolver Authorization Should fail nicely when roles & resources are wanky', async () => {
|
||||
try {
|
||||
let res = await authorizeResolver( null, 'foo', 'bar' )
|
||||
assert.fail( 'resolver authorization should have thrown' )
|
||||
} catch ( e ) {
|
||||
|
||||
}
|
||||
let res = await authorizeResolver(null, 'foo', 'bar')
|
||||
assert.fail('resolver authorization should have thrown')
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
let res = await authorizeResolver( 'foo', 'bar', 'streams:read' )
|
||||
assert.fail( 'resolver authorization should have thrown' )
|
||||
} catch ( e ) {
|
||||
|
||||
}
|
||||
} )
|
||||
} )
|
||||
let res = await authorizeResolver('foo', 'bar', 'streams:read')
|
||||
assert.fail('resolver authorization should have thrown')
|
||||
} catch (e) {}
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,40 @@
|
||||
'use strict'
|
||||
const fs = require( 'fs' )
|
||||
const path = require( 'path' )
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const autoload = require( 'auto-load' )
|
||||
const values = require( 'lodash.values' )
|
||||
const merge = require( 'lodash.merge' )
|
||||
const debug = require( 'debug' )( 'speckle:modules' )
|
||||
const { scalarResolvers, scalarSchemas } = require( './core/graph/scalars' )
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const appRoot = require('app-root-path')
|
||||
const autoload = require('auto-load')
|
||||
const { values, merge } = require('lodash')
|
||||
const { scalarResolvers, scalarSchemas } = require('./core/graph/scalars')
|
||||
|
||||
exports.init = async ( app ) => {
|
||||
let dirs = fs.readdirSync( `${appRoot}/modules` )
|
||||
|
||||
let moduleDirs = [ './core', './auth', './apiexplorer', './emails', './pwdreset', './serverinvites', './previews', './fileuploads', './comments' ] // TODO: add './invites'
|
||||
exports.init = async (app) => {
|
||||
let moduleDirs = [
|
||||
'./core',
|
||||
'./auth',
|
||||
'./apiexplorer',
|
||||
'./emails',
|
||||
'./pwdreset',
|
||||
'./serverinvites',
|
||||
'./previews',
|
||||
'./fileuploads',
|
||||
'./comments'
|
||||
]
|
||||
|
||||
// Stage 1: initialise all modules
|
||||
for ( let dir of moduleDirs ) {
|
||||
await require( dir ).init( app )
|
||||
for (let dir of moduleDirs) {
|
||||
await require(dir).init(app)
|
||||
}
|
||||
|
||||
// Stage 2: finalize init all modules
|
||||
for ( let dir of moduleDirs ) {
|
||||
await require( dir ).finalize( app )
|
||||
for (let dir of moduleDirs) {
|
||||
await require(dir).finalize(app)
|
||||
}
|
||||
}
|
||||
|
||||
exports.graph = ( ) => {
|
||||
let dirs = fs.readdirSync( `${appRoot}/modules` )
|
||||
exports.graph = () => {
|
||||
let dirs = fs.readdirSync(`${appRoot}/modules`)
|
||||
// Base query and mutation to allow for type extension by modules.
|
||||
let typeDefs = [ `
|
||||
let typeDefs = [
|
||||
`
|
||||
${scalarSchemas}
|
||||
directive @hasScope(scope: String!) on FIELD_DEFINITION
|
||||
directive @hasScopes(scopes: [String]!) on FIELD_DEFINITION
|
||||
@@ -50,37 +57,43 @@ exports.graph = ( ) => {
|
||||
It's lonely in the void.
|
||||
"""
|
||||
_: String
|
||||
}` ]
|
||||
}`
|
||||
]
|
||||
|
||||
let resolverObjs = [ ]
|
||||
let schemaDirectives = { }
|
||||
let resolverObjs = []
|
||||
let schemaDirectives = {}
|
||||
|
||||
dirs.forEach( file => {
|
||||
let fullPath = path.join( `${appRoot}/modules`, file )
|
||||
dirs.forEach((file) => {
|
||||
let fullPath = path.join(`${appRoot}/modules`, file)
|
||||
|
||||
// load and merge the type definitions
|
||||
if ( fs.existsSync( path.join( fullPath, 'graph', 'schemas' ) ) ) {
|
||||
let moduleSchemas = fs.readdirSync( path.join( fullPath, 'graph', 'schemas' ) )
|
||||
moduleSchemas.forEach( schema => {
|
||||
typeDefs.push( fs.readFileSync( path.join( fullPath, 'graph', 'schemas', schema ), 'utf8' ) )
|
||||
} )
|
||||
if (fs.existsSync(path.join(fullPath, 'graph', 'schemas'))) {
|
||||
let moduleSchemas = fs.readdirSync(path.join(fullPath, 'graph', 'schemas'))
|
||||
moduleSchemas.forEach((schema) => {
|
||||
typeDefs.push(fs.readFileSync(path.join(fullPath, 'graph', 'schemas', schema), 'utf8'))
|
||||
})
|
||||
}
|
||||
|
||||
// first pass load of resolvers
|
||||
if ( fs.existsSync( path.join( fullPath, 'graph', 'resolvers' ) ) ) {
|
||||
resolverObjs = [ ...resolverObjs, ...values( autoload( path.join( fullPath, 'graph', 'resolvers' ) ) ) ]
|
||||
if (fs.existsSync(path.join(fullPath, 'graph', 'resolvers'))) {
|
||||
resolverObjs = [
|
||||
...resolverObjs,
|
||||
...values(autoload(path.join(fullPath, 'graph', 'resolvers')))
|
||||
]
|
||||
}
|
||||
|
||||
// load directives
|
||||
if ( fs.existsSync( path.join( fullPath, 'graph', 'directives' ) ) ) {
|
||||
schemaDirectives = Object.assign( ...values( autoload( path.join( fullPath, 'graph', 'directives' ) ) ) )
|
||||
if (fs.existsSync(path.join(fullPath, 'graph', 'directives'))) {
|
||||
schemaDirectives = Object.assign(
|
||||
...values(autoload(path.join(fullPath, 'graph', 'directives')))
|
||||
)
|
||||
}
|
||||
} )
|
||||
})
|
||||
|
||||
let resolvers = { ...scalarResolvers }
|
||||
resolverObjs.forEach( o => {
|
||||
merge( resolvers, o )
|
||||
} )
|
||||
resolverObjs.forEach((o) => {
|
||||
merge(resolvers, o)
|
||||
})
|
||||
|
||||
return { resolvers, typeDefs, schemaDirectives }
|
||||
}
|
||||
|
||||
@@ -1,46 +1,81 @@
|
||||
'use strict'
|
||||
const Redis = require( 'ioredis' )
|
||||
const debug = require( 'debug' )( 'speckle:middleware' )
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const knex = require( `${appRoot}/db/knex` )
|
||||
const { ForbiddenError, ApolloError } = require( 'apollo-server-express' )
|
||||
const { RedisPubSub } = require( 'graphql-redis-subscriptions' )
|
||||
const { validateToken } = require( `${appRoot}/modules/core/services/tokens` )
|
||||
const Redis = require('ioredis')
|
||||
const knex = require(`@/db/knex`)
|
||||
const { ForbiddenError, ApolloError } = require('apollo-server-express')
|
||||
const { RedisPubSub } = require('graphql-redis-subscriptions')
|
||||
const { buildRequestLoaders } = require('@/modules/core/loaders')
|
||||
const { validateToken } = require(`@/modules/core/services/tokens`)
|
||||
|
||||
let pubsub = new RedisPubSub({
|
||||
publisher: new Redis(process.env.REDIS_URL),
|
||||
subscriber: new Redis(process.env.REDIS_URL)
|
||||
})
|
||||
|
||||
let pubsub = new RedisPubSub( {
|
||||
publisher: new Redis( process.env.REDIS_URL ),
|
||||
subscriber: new Redis( process.env.REDIS_URL ),
|
||||
} )
|
||||
/**
|
||||
* @typedef {Object} AuthContextPart
|
||||
* @property {boolean} auth Whether or not user is logged in
|
||||
* @property {string | undefined} userId User ID, if user is logged in
|
||||
* @property {string | undefined} role User role, if logged in
|
||||
* @property {string | undefined} token User token, if logged in
|
||||
* @property {string[] | undefined} scopes Token scopes, if logged in
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {AuthContextPart & {loaders: import('@/modules/core/loaders').RequestDataLoaders}} GraphQLContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add data loaders to auth ctx
|
||||
* @param {AuthContextPart} ctx
|
||||
* @returns {GraphQLContext}
|
||||
*/
|
||||
async function addLoadersToCtx(ctx) {
|
||||
const loaders = buildRequestLoaders(ctx)
|
||||
ctx.loaders = loaders
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context for GQL operations
|
||||
* @returns {GraphQLContext}
|
||||
*/
|
||||
async function buildContext({ req, connection }) {
|
||||
// Parsing auth info
|
||||
const ctx = await contextApiTokenHelper({ req, connection })
|
||||
|
||||
// Adding request data loaders
|
||||
return addLoadersToCtx(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphql server context helper: sets req.context to have an auth prop (true/false), userId and server role.
|
||||
* @returns {AuthContextPart}
|
||||
*/
|
||||
async function contextApiTokenHelper( { req, res, connection } ) {
|
||||
async function contextApiTokenHelper({ req, connection }) {
|
||||
let token = null
|
||||
|
||||
if ( connection && connection.context.token ) { // Websockets (subscriptions)
|
||||
if (connection && connection.context.token) {
|
||||
// Websockets (subscriptions)
|
||||
token = connection.context.token
|
||||
} else if ( req && req.headers.authorization ) { // Standard http post
|
||||
} else if (req && req.headers.authorization) {
|
||||
// Standard http post
|
||||
token = req.headers.authorization
|
||||
}
|
||||
if ( token && token.includes( 'Bearer ' ) ) {
|
||||
token = token.split( ' ' )[ 1 ]
|
||||
}
|
||||
if (token && token.includes('Bearer ')) {
|
||||
token = token.split(' ')[1]
|
||||
}
|
||||
|
||||
if ( token === null )
|
||||
return { auth: false }
|
||||
|
||||
if (token === null) return { auth: false }
|
||||
|
||||
try {
|
||||
let { valid, scopes, userId, role } = await validateToken( token )
|
||||
let { valid, scopes, userId, role } = await validateToken(token)
|
||||
|
||||
if ( !valid ) {
|
||||
if (!valid) {
|
||||
return { auth: false }
|
||||
}
|
||||
|
||||
return { auth: true, userId, role, token, scopes }
|
||||
} catch ( e ) {
|
||||
} catch (e) {
|
||||
// TODO: Think whether perhaps it's better to throw the error
|
||||
return { auth: false, err: e }
|
||||
}
|
||||
@@ -49,15 +84,14 @@ async function contextApiTokenHelper( { req, res, connection } ) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware wrapper around the contextApiTokenHelper function. sets req.context to have an auth prop (true/false), userId and server role.
|
||||
* Express middleware wrapper around the buildContext function. sets req.context to have an auth prop (true/false), userId and server role.
|
||||
*/
|
||||
async function contextMiddleware( req, res, next ) {
|
||||
let result = await contextApiTokenHelper( { req, res } )
|
||||
async function contextMiddleware(req, res, next) {
|
||||
let result = await buildContext({ req, res })
|
||||
req.context = result
|
||||
next( )
|
||||
next()
|
||||
}
|
||||
|
||||
|
||||
let roles
|
||||
|
||||
/**
|
||||
@@ -66,21 +100,20 @@ let roles
|
||||
* @param {[type]} requiredRole [description]
|
||||
* @return {[type]} [description]
|
||||
*/
|
||||
async function validateServerRole( context, requiredRole ) {
|
||||
if ( !roles )
|
||||
roles = await knex( 'user_roles' ).select( '*' )
|
||||
async function validateServerRole(context, requiredRole) {
|
||||
if (!roles) roles = await knex('user_roles').select('*')
|
||||
|
||||
if ( !context.auth ) throw new ForbiddenError( 'You must provide an auth token.' )
|
||||
if ( context.role === 'server:admin' ) return true
|
||||
if (!context.auth) throw new ForbiddenError('You must provide an auth token.')
|
||||
if (context.role === 'server:admin') return true
|
||||
|
||||
let role = roles.find( r => r.name === requiredRole )
|
||||
let myRole = roles.find( r => r.name === context.role )
|
||||
let role = roles.find((r) => r.name === requiredRole)
|
||||
let myRole = roles.find((r) => r.name === context.role)
|
||||
|
||||
if ( role === null ) new ApolloError( 'Invalid server role specified' )
|
||||
if ( myRole === null ) new ForbiddenError( 'You do not have the required server role (null)' )
|
||||
if ( myRole.weight >= role.weight ) return true
|
||||
if (role === null) new ApolloError('Invalid server role specified')
|
||||
if (myRole === null) new ForbiddenError('You do not have the required server role (null)')
|
||||
if (myRole.weight >= role.weight) return true
|
||||
|
||||
throw new ForbiddenError( 'You do not have the required server role' )
|
||||
throw new ForbiddenError('You do not have the required server role')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,11 +122,10 @@ async function validateServerRole( context, requiredRole ) {
|
||||
* @param {[type]} scope [description]
|
||||
* @return {[type]} [description]
|
||||
*/
|
||||
async function validateScopes( scopes, scope ) {
|
||||
if ( !scopes )
|
||||
throw new ForbiddenError( 'You do not have the required privileges.' )
|
||||
if ( scopes.indexOf( scope ) === -1 && scopes.indexOf( '*' ) === -1 )
|
||||
throw new ForbiddenError( 'You do not have the required privileges.' )
|
||||
async function validateScopes(scopes, scope) {
|
||||
if (!scopes) throw new ForbiddenError('You do not have the required privileges.')
|
||||
if (scopes.indexOf(scope) === -1 && scopes.indexOf('*') === -1)
|
||||
throw new ForbiddenError('You do not have the required privileges.')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,52 +135,66 @@ async function validateScopes( scopes, scope ) {
|
||||
* @param {[type]} requiredRole [description]
|
||||
* @return {[type]} [description]
|
||||
*/
|
||||
async function authorizeResolver( userId, resourceId, requiredRole ) {
|
||||
if ( !roles )
|
||||
roles = await knex( 'user_roles' ).select( '*' )
|
||||
async function authorizeResolver(userId, resourceId, requiredRole) {
|
||||
if (!roles) roles = await knex('user_roles').select('*')
|
||||
|
||||
// TODO: Cache these results with a TTL of 1 mins or so, it's pointless to query the db every time we get a ping.
|
||||
|
||||
let role = roles.find( r => r.name === requiredRole )
|
||||
let role = roles.find((r) => r.name === requiredRole)
|
||||
|
||||
if ( role === undefined || role === null ) throw new ApolloError( 'Unknown role: ' + requiredRole )
|
||||
if (role === undefined || role === null) throw new ApolloError('Unknown role: ' + requiredRole)
|
||||
|
||||
try {
|
||||
let { isPublic } = await knex( role.resourceTarget ).select( 'isPublic' ).where( { id: resourceId } ).first( )
|
||||
if ( isPublic && roles[ requiredRole ] < 200 ) return true
|
||||
} catch ( e ) {
|
||||
throw new ApolloError( `Resource of type ${role.resourceTarget} with ${resourceId} not found` )
|
||||
let { isPublic } = await knex(role.resourceTarget)
|
||||
.select('isPublic')
|
||||
.where({ id: resourceId })
|
||||
.first()
|
||||
if (isPublic && roles[requiredRole] < 200) return true
|
||||
} catch (e) {
|
||||
throw new ApolloError(`Resource of type ${role.resourceTarget} with ${resourceId} not found`)
|
||||
}
|
||||
|
||||
let userAclEntry = await knex( role.aclTableName ).select( '*' ).where( { resourceId: resourceId, userId: userId } ).first( )
|
||||
let userAclEntry = await knex(role.aclTableName)
|
||||
.select('*')
|
||||
.where({ resourceId: resourceId, userId: userId })
|
||||
.first()
|
||||
|
||||
if ( !userAclEntry ) throw new ForbiddenError( 'You do not have access to this resource.' )
|
||||
if (!userAclEntry) throw new ForbiddenError('You do not have access to this resource.')
|
||||
|
||||
userAclEntry.role = roles.find( r => r.name === userAclEntry.role )
|
||||
userAclEntry.role = roles.find((r) => r.name === userAclEntry.role)
|
||||
|
||||
if ( userAclEntry.role.weight >= role.weight )
|
||||
return userAclEntry.role.name
|
||||
else
|
||||
throw new ForbiddenError( 'You are not authorized.' )
|
||||
if (userAclEntry.role.weight >= role.weight) return userAclEntry.role.name
|
||||
else throw new ForbiddenError('You are not authorized.')
|
||||
}
|
||||
|
||||
const Scopes = () => knex( 'scopes' )
|
||||
const Scopes = () => knex('scopes')
|
||||
|
||||
async function registerOrUpdateScope( scope ) {
|
||||
await knex.raw( `${Scopes().insert( scope ).toString()} on conflict (name) do update set public = ?, description = ? `, [ scope.public, scope.description ] )
|
||||
async function registerOrUpdateScope(scope) {
|
||||
await knex.raw(
|
||||
`${Scopes()
|
||||
.insert(scope)
|
||||
.toString()} on conflict (name) do update set public = ?, description = ? `,
|
||||
[scope.public, scope.description]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const Roles = () => knex( 'user_roles' )
|
||||
async function registerOrUpdateRole( role ) {
|
||||
await knex.raw( `${Roles().insert( role ).toString()} on conflict (name) do update set weight = ?, description = ?, "resourceTarget" = ? `, [ role.weight, role.description, role.resourceTarget ] )
|
||||
const Roles = () => knex('user_roles')
|
||||
async function registerOrUpdateRole(role) {
|
||||
await knex.raw(
|
||||
`${Roles()
|
||||
.insert(role)
|
||||
.toString()} on conflict (name) do update set weight = ?, description = ?, "resourceTarget" = ? `,
|
||||
[role.weight, role.description, role.resourceTarget]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerOrUpdateScope,
|
||||
registerOrUpdateRole,
|
||||
contextApiTokenHelper,
|
||||
buildContext,
|
||||
addLoadersToCtx,
|
||||
contextMiddleware,
|
||||
validateServerRole,
|
||||
validateScopes,
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/* istanbul ignore file */
|
||||
const expect = require('chai').expect
|
||||
|
||||
const appRoot = require('app-root-path')
|
||||
const { createUser } = require(`@/modules/core/services/users`)
|
||||
const { createPersonalAccessToken } = require(`@/modules/core/services/tokens`)
|
||||
const { createStream } = require(`@/modules/core/services/streams`)
|
||||
const { createObjects } = require(`@/modules/core/services/objects`)
|
||||
const { createCommitByBranchName } = require(`@/modules/core/services/commits`)
|
||||
|
||||
const { createUser } = require(`${appRoot}/modules/core/services/users`)
|
||||
const { createPersonalAccessToken } = require(`${appRoot}/modules/core/services/tokens`)
|
||||
const { createStream } = require(`${appRoot}/modules/core/services/streams`)
|
||||
const { createObjects } = require(`${appRoot}/modules/core/services/objects`)
|
||||
const { createCommitByBranchName } = require(`${appRoot}/modules/core/services/commits`)
|
||||
|
||||
const { beforeEachContext, initializeTestServer } = require(`${appRoot}/test/hooks`)
|
||||
const { createManyObjects } = require(`${appRoot}/test/helpers`)
|
||||
const { beforeEachContext, initializeTestServer } = require(`@/test/hooks`)
|
||||
const { createManyObjects } = require(`@/test/helpers`)
|
||||
|
||||
const {
|
||||
getStreamHistory,
|
||||
@@ -27,8 +25,7 @@ const params = { numUsers: 25, numStreams: 30, numObjects: 100, numCommits: 100
|
||||
|
||||
describe('Server stats services @stats-services', function () {
|
||||
before(async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
this.timeout(15000)
|
||||
await beforeEachContext()
|
||||
await seedDb(params)
|
||||
})
|
||||
@@ -121,7 +118,7 @@ describe('Server stats api @stats-api', function () {
|
||||
`
|
||||
|
||||
before(async function () {
|
||||
this.timeout(10000)
|
||||
this.timeout(15000)
|
||||
|
||||
let { app } = await beforeEachContext()
|
||||
;({ server, sendRequest } = await initializeTestServer(app))
|
||||
@@ -202,41 +199,45 @@ describe('Server stats api @stats-api', function () {
|
||||
})
|
||||
|
||||
async function seedDb({ numUsers = 10, numStreams = 10, numObjects = 10, numCommits = 10 } = {}) {
|
||||
let users = []
|
||||
let streams = []
|
||||
|
||||
// create users
|
||||
const userPromises = []
|
||||
for (let i = 0; i < numUsers; i++) {
|
||||
let id = await createUser({
|
||||
const promise = createUser({
|
||||
name: `User ${i}`,
|
||||
password: `SuperSecure${i}${i * 3.14}`,
|
||||
email: `user${i}@speckle.systems`
|
||||
})
|
||||
users.push(id)
|
||||
userPromises.push(promise)
|
||||
}
|
||||
|
||||
const userIds = await Promise.all(userPromises)
|
||||
|
||||
// create streams
|
||||
const streamPromises = []
|
||||
for (let i = 0; i < numStreams; i++) {
|
||||
let id = await createStream({
|
||||
const promise = createStream({
|
||||
name: `Stream ${i}`,
|
||||
ownerId: users[i >= users.length ? users.length - 1 : i]
|
||||
ownerId: userIds[i >= userIds.length ? userIds.length - 1 : i]
|
||||
})
|
||||
streams.push(id)
|
||||
streamPromises.push(promise)
|
||||
}
|
||||
|
||||
const streamIds = await Promise.all(streamPromises)
|
||||
|
||||
// create a objects
|
||||
let mockObjects = createManyObjects(numObjects - 1)
|
||||
let objs = await createObjects(streams[0], mockObjects)
|
||||
let commits = []
|
||||
const objs = await createObjects(streamIds[0], createManyObjects(numObjects - 1))
|
||||
|
||||
// create commits referencing those objects
|
||||
const commitPromises = []
|
||||
for (let i = 0; i < numCommits; i++) {
|
||||
let id = await createCommitByBranchName({
|
||||
streamId: streams[0],
|
||||
const promise = createCommitByBranchName({
|
||||
streamId: streamIds[0],
|
||||
branchName: 'main',
|
||||
sourceApplication: 'tests',
|
||||
objectId: objs[i >= objs.length ? objs.length - 1 : i]
|
||||
})
|
||||
commits.push(id)
|
||||
commitPromises.push(promise)
|
||||
}
|
||||
|
||||
await Promise.all(commitPromises)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const testFileExtensions = ['ts', 'js']
|
||||
|
||||
module.exports = {
|
||||
exclude: [
|
||||
`**/migrations/*.{${testFileExtensions}}`,
|
||||
|
||||
// Default exclusions: https://github.com/istanbuljs/schema/blob/master/default-exclude.js
|
||||
'coverage/**',
|
||||
'packages/*/test{,s}/**',
|
||||
'**/*.d.ts',
|
||||
'test{,s}/**',
|
||||
`test{,-*}.{${testFileExtensions}}`,
|
||||
`**/*{.,-}test.{${testFileExtensions}}`,
|
||||
'**/__tests__/**',
|
||||
'**/{ava,babel,nyc}.config.{js,cjs,mjs}',
|
||||
'**/jest.config.{js,cjs,mjs,ts}',
|
||||
'**/{karma,rollup,webpack}.config.js',
|
||||
'**/.{eslint,mocha}rc.{js,cjs}'
|
||||
]
|
||||
}
|
||||
Generated
+296
-63
@@ -23,6 +23,7 @@
|
||||
"compression": "^1.7.4",
|
||||
"connect-redis": "^6.1.1",
|
||||
"crypto-random-string": "^3.2.0",
|
||||
"dataloader": "^2.0.0",
|
||||
"debug": "^4.3.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.3",
|
||||
@@ -35,13 +36,9 @@
|
||||
"graphql-tools": "^4.0.7",
|
||||
"ioredis": "^4.19.4",
|
||||
"knex": "^1.0.3",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.set": "^4.3.2",
|
||||
"lodash.values": "^4.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"matomo-tracker": "^2.2.4",
|
||||
"module-alias": "^2.2.2",
|
||||
"morgan": "^1.10.0",
|
||||
"morgan-debug": "^2.0.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
@@ -58,12 +55,16 @@
|
||||
"sanitize-html": "^2.4.0",
|
||||
"sharp": "^0.29.3",
|
||||
"string-pixel-width": "^1.10.0",
|
||||
"verror": "^1.10.1",
|
||||
"xml-escape": "^1.1.0",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apollo/client": "^3.5.9",
|
||||
"@types/lodash": "^4.14.180",
|
||||
"@types/mocha": "^7.0.2",
|
||||
"@types/module-alias": "^2.0.1",
|
||||
"@types/yargs": "^17.0.10",
|
||||
"apollo-cache-inmemory": "^1.6.6",
|
||||
"apollo-client": "^2.6.10",
|
||||
"apollo-link": "^1.2.14",
|
||||
@@ -74,6 +75,7 @@
|
||||
"chai-http": "^4.3.0",
|
||||
"concurrently": "^7.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"deep-equal-in-any-order": "^1.1.15",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"http-proxy-middleware": "^1.0.6",
|
||||
@@ -84,7 +86,8 @@
|
||||
"nyc": "^15.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
"supertest": "^4.0.2",
|
||||
"ws": "^7.5.7"
|
||||
"ws": "^7.5.7",
|
||||
"yargs": "^17.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -7119,6 +7122,12 @@
|
||||
"@types/koa": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.14.180",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.180.tgz",
|
||||
"integrity": "sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/long": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
|
||||
@@ -7147,6 +7156,12 @@
|
||||
"integrity": "sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/module-alias": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/module-alias/-/module-alias-2.0.1.tgz",
|
||||
"integrity": "sha512-DN/CCT1HQG6HquBNJdLkvV+4v5l/oEuwOHUPLxI+Eub0NED+lk0YUfba04WGH90EINiUrNgClkNnwGmbICeWMQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "17.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.23.tgz",
|
||||
@@ -7195,6 +7210,21 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz",
|
||||
"integrity": "sha512-gmEaFwpj/7f/ROdtIlci1R1VYU1J4j95m8T+Tj3iBgiBFKg1foE/PSl93bBd5T9LDXNPo8UlNN6W0qwD8O5OaA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs-parser": {
|
||||
"version": "21.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz",
|
||||
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/zen-observable": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz",
|
||||
@@ -9483,6 +9513,56 @@
|
||||
"node": "^12.20.0 || ^14.13.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/yargs": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
|
||||
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cliui": "^7.0.2",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^20.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
@@ -10122,6 +10202,11 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dataloader": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.0.0.tgz",
|
||||
"integrity": "sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ=="
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz",
|
||||
@@ -10241,6 +10326,16 @@
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-equal-in-any-order": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal-in-any-order/-/deep-equal-in-any-order-1.1.15.tgz",
|
||||
"integrity": "sha512-/W36YW15Z+AymTkswOoCOX++gWLd0XBy1lFlxmML/m/PW0/0GuNkE65UTA2qiuPrKJTxXssKKrvtIZkOh+yEHA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lodash.mapvalues": "^4.6.0",
|
||||
"sort-any": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
@@ -14791,6 +14886,19 @@
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsprim/node_modules/verror": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||
"integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
|
||||
"engines": [
|
||||
"node >=0.6.0"
|
||||
],
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"core-util-is": "1.0.2",
|
||||
"extsprintf": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||
@@ -15128,21 +15236,11 @@
|
||||
"integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.chunk": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz",
|
||||
"integrity": "sha1-ZuXOH3btJ7QwPYxlEujRIW6BBrw="
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
|
||||
},
|
||||
"node_modules/lodash.deburr": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz",
|
||||
@@ -15167,7 +15265,8 @@
|
||||
"node_modules/lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
@@ -15180,15 +15279,23 @@
|
||||
"integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.mapvalues": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz",
|
||||
"integrity": "sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.set": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
|
||||
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM="
|
||||
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.sortby": {
|
||||
"version": "4.7.0",
|
||||
@@ -15220,11 +15327,6 @@
|
||||
"integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.values": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz",
|
||||
"integrity": "sha1-o6bCsOvsxcLLocF+bmIP6BtT00c="
|
||||
},
|
||||
"node_modules/log-symbols": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz",
|
||||
@@ -16370,6 +16472,11 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/module-alias": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.2.2.tgz",
|
||||
"integrity": "sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q=="
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
||||
@@ -20214,6 +20321,15 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-any": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sort-any/-/sort-any-2.0.0.tgz",
|
||||
"integrity": "sha512-T9JoiDewQEmWcnmPn/s9h/PH9t3d/LSWi0RgVmXSuDYeZXTZOZ1/wrK2PHaptuR1VXe3clLLt0pD6sgVOwjNEA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz",
|
||||
@@ -21688,16 +21804,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/verror": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||
"integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
|
||||
"engines": [
|
||||
"node >=0.6.0"
|
||||
],
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
|
||||
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"core-util-is": "1.0.2",
|
||||
"extsprintf": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
@@ -22078,21 +22194,21 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
|
||||
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.4.0.tgz",
|
||||
"integrity": "sha512-WJudfrk81yWFSOkZYpAZx4Nt7V4xp7S/uJkX0CnxovMCt1wCE8LNftPpNuF9X/u9gN5nsD7ycYtRcDf2pL3UiA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cliui": "^7.0.2",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.0",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^20.2.2"
|
||||
"yargs-parser": "^21.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
@@ -22271,6 +22387,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/yargs-parser": {
|
||||
"version": "21.0.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz",
|
||||
"integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/zen-observable": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
|
||||
@@ -28484,6 +28609,12 @@
|
||||
"@types/koa": "*"
|
||||
}
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.180",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.180.tgz",
|
||||
"integrity": "sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/long": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
|
||||
@@ -28512,6 +28643,12 @@
|
||||
"integrity": "sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/module-alias": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/module-alias/-/module-alias-2.0.1.tgz",
|
||||
"integrity": "sha512-DN/CCT1HQG6HquBNJdLkvV+4v5l/oEuwOHUPLxI+Eub0NED+lk0YUfba04WGH90EINiUrNgClkNnwGmbICeWMQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "17.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.23.tgz",
|
||||
@@ -28560,6 +28697,21 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz",
|
||||
"integrity": "sha512-gmEaFwpj/7f/ROdtIlci1R1VYU1J4j95m8T+Tj3iBgiBFKg1foE/PSl93bBd5T9LDXNPo8UlNN6W0qwD8O5OaA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"@types/yargs-parser": {
|
||||
"version": "21.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz",
|
||||
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/zen-observable": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz",
|
||||
@@ -30382,6 +30534,46 @@
|
||||
"supports-color": "^8.1.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
"yargs": "^16.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true
|
||||
},
|
||||
"string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true
|
||||
},
|
||||
"yargs": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
|
||||
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cliui": "^7.0.2",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^20.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config-chain": {
|
||||
@@ -30883,6 +31075,11 @@
|
||||
"assert-plus": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"dataloader": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.0.0.tgz",
|
||||
"integrity": "sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ=="
|
||||
},
|
||||
"date-fns": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz",
|
||||
@@ -30962,6 +31159,16 @@
|
||||
"type-detect": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"deep-equal-in-any-order": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal-in-any-order/-/deep-equal-in-any-order-1.1.15.tgz",
|
||||
"integrity": "sha512-/W36YW15Z+AymTkswOoCOX++gWLd0XBy1lFlxmML/m/PW0/0GuNkE65UTA2qiuPrKJTxXssKKrvtIZkOh+yEHA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lodash.mapvalues": "^4.6.0",
|
||||
"sort-any": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
@@ -34470,6 +34677,18 @@
|
||||
"extsprintf": "1.3.0",
|
||||
"json-schema": "0.4.0",
|
||||
"verror": "1.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"verror": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||
"integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
|
||||
"requires": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"core-util-is": "1.0.2",
|
||||
"extsprintf": "^1.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"jwa": {
|
||||
@@ -34739,21 +34958,11 @@
|
||||
"integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.chunk": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz",
|
||||
"integrity": "sha1-ZuXOH3btJ7QwPYxlEujRIW6BBrw="
|
||||
},
|
||||
"lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
|
||||
},
|
||||
"lodash.deburr": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz",
|
||||
@@ -34778,7 +34987,8 @@
|
||||
"lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
@@ -34791,15 +35001,23 @@
|
||||
"integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.mapvalues": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz",
|
||||
"integrity": "sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.set": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
|
||||
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM="
|
||||
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.sortby": {
|
||||
"version": "4.7.0",
|
||||
@@ -34831,11 +35049,6 @@
|
||||
"integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.values": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz",
|
||||
"integrity": "sha1-o6bCsOvsxcLLocF+bmIP6BtT00c="
|
||||
},
|
||||
"log-symbols": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz",
|
||||
@@ -35751,6 +35964,11 @@
|
||||
"integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==",
|
||||
"dev": true
|
||||
},
|
||||
"module-alias": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.2.2.tgz",
|
||||
"integrity": "sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q=="
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.29.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
||||
@@ -38803,6 +39021,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sort-any": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sort-any/-/sort-any-2.0.0.tgz",
|
||||
"integrity": "sha512-T9JoiDewQEmWcnmPn/s9h/PH9t3d/LSWi0RgVmXSuDYeZXTZOZ1/wrK2PHaptuR1VXe3clLLt0pD6sgVOwjNEA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"sort-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz",
|
||||
@@ -39975,9 +40202,9 @@
|
||||
"integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
|
||||
},
|
||||
"verror": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||
"integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
|
||||
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
|
||||
"requires": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"core-util-is": "1.0.2",
|
||||
@@ -40283,18 +40510,18 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"yargs": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
|
||||
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.4.0.tgz",
|
||||
"integrity": "sha512-WJudfrk81yWFSOkZYpAZx4Nt7V4xp7S/uJkX0CnxovMCt1wCE8LNftPpNuF9X/u9gN5nsD7ycYtRcDf2pL3UiA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cliui": "^7.0.2",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.0",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^20.2.2"
|
||||
"yargs-parser": "^21.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-fullwidth-code-point": {
|
||||
@@ -40319,6 +40546,12 @@
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true
|
||||
},
|
||||
"yargs-parser": {
|
||||
"version": "21.0.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz",
|
||||
"integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"compression": "^1.7.4",
|
||||
"connect-redis": "^6.1.1",
|
||||
"crypto-random-string": "^3.2.0",
|
||||
"dataloader": "^2.0.0",
|
||||
"debug": "^4.3.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.3",
|
||||
@@ -46,13 +47,9 @@
|
||||
"graphql-tools": "^4.0.7",
|
||||
"ioredis": "^4.19.4",
|
||||
"knex": "^1.0.3",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.set": "^4.3.2",
|
||||
"lodash.values": "^4.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"matomo-tracker": "^2.2.4",
|
||||
"module-alias": "^2.2.2",
|
||||
"morgan": "^1.10.0",
|
||||
"morgan-debug": "^2.0.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
@@ -69,12 +66,16 @@
|
||||
"sanitize-html": "^2.4.0",
|
||||
"sharp": "^0.29.3",
|
||||
"string-pixel-width": "^1.10.0",
|
||||
"verror": "^1.10.1",
|
||||
"xml-escape": "^1.1.0",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apollo/client": "^3.5.9",
|
||||
"@types/lodash": "^4.14.180",
|
||||
"@types/mocha": "^7.0.2",
|
||||
"@types/module-alias": "^2.0.1",
|
||||
"@types/yargs": "^17.0.10",
|
||||
"apollo-cache-inmemory": "^1.6.6",
|
||||
"apollo-client": "^2.6.10",
|
||||
"apollo-link": "^1.2.14",
|
||||
@@ -85,6 +86,7 @@
|
||||
"chai-http": "^4.3.0",
|
||||
"concurrently": "^7.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"deep-equal-in-any-order": "^1.1.15",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"http-proxy-middleware": "^1.0.6",
|
||||
@@ -95,11 +97,15 @@
|
||||
"nyc": "^15.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
"supertest": "^4.0.2",
|
||||
"ws": "^7.5.7"
|
||||
"ws": "^7.5.7",
|
||||
"yargs": "^17.3.1"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"_moduleAliases": {
|
||||
"@": "."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,79 @@
|
||||
/* istanbul ignore file */
|
||||
const chai = require( 'chai' )
|
||||
const chaiHttp = require( 'chai-http' )
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const knex = require( `${appRoot}/db/knex` )
|
||||
const { init, startHttp } = require( `${appRoot}/app` )
|
||||
|
||||
chai.use( chaiHttp )
|
||||
require('../bootstrap')
|
||||
const chai = require('chai')
|
||||
const chaiHttp = require('chai-http')
|
||||
const deepEqualInAnyOrder = require('deep-equal-in-any-order')
|
||||
const knex = require(`@/db/knex`)
|
||||
const { init, startHttp } = require(`@/app`)
|
||||
|
||||
chai.use(chaiHttp)
|
||||
chai.use(deepEqualInAnyOrder)
|
||||
|
||||
const unlock = async () => {
|
||||
const exists = await knex.schema.hasTable( 'knex_migrations_lock' )
|
||||
if ( exists ) {
|
||||
await knex( 'knex_migrations_lock' )
|
||||
.update( 'is_locked', '0' )
|
||||
const exists = await knex.schema.hasTable('knex_migrations_lock')
|
||||
if (exists) {
|
||||
await knex('knex_migrations_lock').update('is_locked', '0')
|
||||
}
|
||||
}
|
||||
|
||||
const truncateTables = async () => {
|
||||
//why is server config only created once!????
|
||||
const protectedTables = [ 'server_config' ]
|
||||
// const protectedTables = [ 'server_config', 'user_roles', 'scopes', 'server_acl' ]
|
||||
const tables = (
|
||||
await knex( 'pg_tables' )
|
||||
.select( 'tablename' )
|
||||
.where( { schemaname: 'public' } )
|
||||
.whereRaw( 'tablename not like \'%knex%\'' )
|
||||
.whereNotIn( 'tablename', protectedTables )
|
||||
).map( table => table.tablename )
|
||||
await knex.raw( `truncate table ${tables.join( ',' )} cascade` )
|
||||
exports.truncateTables = async (tableNames) => {
|
||||
if (!tableNames?.length) {
|
||||
//why is server config only created once!????
|
||||
const protectedTables = ['server_config']
|
||||
// const protectedTables = [ 'server_config', 'user_roles', 'scopes', 'server_acl' ]
|
||||
tableNames = (
|
||||
await knex('pg_tables')
|
||||
.select('tablename')
|
||||
.where({ schemaname: 'public' })
|
||||
.whereRaw("tablename not like '%knex%'")
|
||||
.whereNotIn('tablename', protectedTables)
|
||||
).map((table) => table.tablename)
|
||||
}
|
||||
|
||||
await knex.raw(`truncate table ${tableNames.join(',')} cascade`)
|
||||
}
|
||||
|
||||
const initializeTestServer = async ( app ) => {
|
||||
const initializeTestServer = async (app) => {
|
||||
let serverAddress
|
||||
let wsAddress
|
||||
const { server } = await startHttp( app, 0 )
|
||||
const { server } = await startHttp(app, 0)
|
||||
|
||||
app.on( 'appStarted', () => {
|
||||
app.on('appStarted', () => {
|
||||
const port = server.address().port
|
||||
serverAddress = `http://localhost:${port}`
|
||||
wsAddress = `ws://localhost:${port}`
|
||||
} )
|
||||
while ( !serverAddress ) {
|
||||
await new Promise( resolve => setTimeout( resolve, 100 ) )
|
||||
})
|
||||
while (!serverAddress) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
return {
|
||||
return {
|
||||
server,
|
||||
serverAddress,
|
||||
wsAddress,
|
||||
sendRequest( auth, obj ) {
|
||||
return chai.request( serverAddress ).post( '/graphql' ).set( 'Authorization', auth ).send( obj )
|
||||
sendRequest(auth, obj) {
|
||||
return chai.request(serverAddress).post('/graphql').set('Authorization', auth).send(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.mochaHooks = {
|
||||
beforeAll: async () => {
|
||||
console.log('running before all')
|
||||
await unlock()
|
||||
await knex.migrate.rollback()
|
||||
await knex.migrate.latest()
|
||||
console.log( 'running before all' )
|
||||
await init()
|
||||
},
|
||||
afterAll: async () => {
|
||||
console.log('running after all')
|
||||
await unlock()
|
||||
await knex.migrate.rollback()
|
||||
console.log( 'running after all' )
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
exports.beforeEachContext = async () => {
|
||||
await truncateTables()
|
||||
const { app } = await init( )
|
||||
return { app }
|
||||
}
|
||||
await exports.truncateTables()
|
||||
const { app, graphqlServer } = await init()
|
||||
return { app, graphqlServer }
|
||||
}
|
||||
|
||||
exports.initializeTestServer = initializeTestServer
|
||||
|
||||
|
||||
Reference in New Issue
Block a user