feat: favoriting streams #620 (#633)

This commit is contained in:
Kristaps Fabians Geikins
2022-03-29 16:30:49 +03:00
committed by GitHub
parent 5871339a18
commit aeeb88340d
94 changed files with 44076 additions and 41405 deletions
-1
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,6 @@
{
"javascript.suggest.autoImports": true,
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifier": "non-relative"
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../../jsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
+38996 -38977
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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"
}
}
-17
View File
@@ -1,17 +0,0 @@
query {
serverInfo {
name
company
description
adminContact
canonicalUrl
inviteOnly
version
termsOfService
roles {
name
description
resourceTarget
}
}
}
+60
View File
@@ -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}
`
+3 -1
View File
@@ -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
}
}
}
+44
View File
@@ -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}
`
-25
View File
@@ -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
}
}
}
}
+76
View File
@@ -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 -11
View File
@@ -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
}
}
}
+9 -7
View File
@@ -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>
+2 -13
View File
@@ -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() {
@@ -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
}
+2 -2
View File
@@ -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>
@@ -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'),
@@ -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: {},
+11 -3
View File
@@ -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 }
}
+38 -38
View File
@@ -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)
}
+74 -75
View File
@@ -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 )
}
+1 -1
View File
@@ -2,7 +2,7 @@
module.exports = {
configureWebpack: {
devtool: 'source-map'
devtool: 'eval-source-map'
},
productionSourceMap: false,
pages: {
+5 -1
View File
@@ -6,7 +6,11 @@
/** @type {import("eslint").Linter.Config} */
const config = {
env: {
browser: true
browser: true,
es2022: true
},
parserOptions: {
ecmaVersion: 13
},
overrides: [
{
+6 -1
View File
@@ -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
View File
@@ -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
+30
View File
@@ -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)
})
+8 -2
View File
@@ -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` })
}
+12 -4
View File
@@ -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
+6
View File
@@ -1,4 +1,10 @@
{
"extends": "../../jsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["bin/www", "db", "logging", "scripts", "modules", "test"]
}
+5 -2
View File
@@ -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
+30
View File
@@ -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`.
+59
View File
@@ -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
}
+59
View File
@@ -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
}
+15 -10
View File
@@ -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',
+19 -15
View File
@@ -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
}
+402 -304
View File
@@ -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
)
}
+178 -69
View File
@@ -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
+50 -37
View File
@@ -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 }
}
+113 -67
View File
@@ -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)
}
+20
View File
@@ -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}'
]
}
+296 -63
View File
@@ -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
}
}
},
+13 -7
View File
@@ -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": {
"@": "."
}
}
+43 -41
View File
@@ -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