640 lines
19 KiB
JavaScript
640 lines
19 KiB
JavaScript
const { pubsub } = require('@/modules/shared/utils/subscriptions')
|
|
const { ForbiddenError: ApolloForbiddenError } = require('apollo-server-express')
|
|
const { ForbiddenError } = require('@/modules/shared/errors')
|
|
const { getStream } = require('@/modules/core/services/streams')
|
|
const { Roles } = require('@/modules/core/helpers/mainConstants')
|
|
|
|
const {
|
|
getComment,
|
|
getComments,
|
|
getResourceCommentCount,
|
|
createComment,
|
|
createCommentReply,
|
|
viewComment,
|
|
archiveComment,
|
|
editComment,
|
|
streamResourceCheck
|
|
} = require('@/modules/comments/services/index')
|
|
const {
|
|
ensureCommentSchema
|
|
} = require('@/modules/comments/services/commentTextService')
|
|
const { withFilter } = require('graphql-subscriptions')
|
|
const { has } = require('lodash')
|
|
const {
|
|
documentToBasicString
|
|
} = require('@/modules/core/services/richTextEditorService')
|
|
const {
|
|
getPaginatedCommitComments,
|
|
getPaginatedBranchComments,
|
|
getPaginatedProjectComments
|
|
} = require('@/modules/comments/services/retrieval')
|
|
const {
|
|
publish,
|
|
ViewerSubscriptions,
|
|
CommentSubscriptions,
|
|
filteredSubscribe,
|
|
ProjectSubscriptions
|
|
} = require('@/modules/shared/utils/subscriptions')
|
|
const {
|
|
addCommentCreatedActivity,
|
|
addCommentArchivedActivity,
|
|
addReplyAddedActivity
|
|
} = require('@/modules/activitystream/services/commentActivity')
|
|
const {
|
|
getViewerResourceItemsUngrouped,
|
|
getViewerResourcesForComment,
|
|
doViewerResourcesFit
|
|
} = require('@/modules/core/services/commit/viewerResources')
|
|
const {
|
|
authorizeProjectCommentsAccess,
|
|
authorizeCommentAccess,
|
|
markViewed,
|
|
createCommentThreadAndNotify,
|
|
createCommentReplyAndNotify,
|
|
editCommentAndNotify,
|
|
archiveCommentAndNotify
|
|
} = require('@/modules/comments/services/management')
|
|
const {
|
|
isLegacyData,
|
|
isDataStruct,
|
|
formatSerializedViewerState,
|
|
convertStateToLegacyData,
|
|
convertLegacyDataToState
|
|
} = require('@/modules/comments/services/data')
|
|
|
|
/** @type {import('@/modules/core/graph/generated/graphql').Resolvers} */
|
|
module.exports = {
|
|
Query: {
|
|
async comment(parent, args, context) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: args.streamId,
|
|
authCtx: context
|
|
})
|
|
|
|
const comment = await getComment({ id: args.id, userId: context.userId })
|
|
if (comment.streamId !== args.streamId)
|
|
throw new ApolloForbiddenError('You do not have access to this comment.')
|
|
return comment
|
|
},
|
|
|
|
async comments(parent, args, context) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: args.streamId,
|
|
authCtx: context
|
|
})
|
|
return { ...(await getComments({ ...args, userId: context.userId })) }
|
|
}
|
|
},
|
|
Comment: {
|
|
async replies(parent, args) {
|
|
const resources = [{ resourceId: parent.id, resourceType: 'comment' }]
|
|
return await getComments({
|
|
resources,
|
|
replies: true,
|
|
limit: args.limit,
|
|
cursor: args.cursor
|
|
})
|
|
},
|
|
/**
|
|
* Format comment.text for output, since it can have multiple formats
|
|
*/
|
|
text(parent) {
|
|
const commentText = parent?.text || ''
|
|
return ensureCommentSchema(commentText)
|
|
},
|
|
|
|
rawText(parent) {
|
|
const { doc } = ensureCommentSchema(parent.text || '')
|
|
return documentToBasicString(doc)
|
|
},
|
|
async hasParent(parent) {
|
|
return !!parent.parentComment
|
|
},
|
|
async parent(parent, _args, ctx) {
|
|
return ctx.loaders.comments.getReplyParent.load(parent.id)
|
|
},
|
|
/**
|
|
* Resolve resources, if they weren't already preloaded
|
|
*/
|
|
async resources(parent, _args, ctx) {
|
|
if (has(parent, 'resources')) return parent.resources
|
|
return await ctx.loaders.comments.getResources.load(parent.id)
|
|
},
|
|
async viewedAt(parent, _args, ctx) {
|
|
if (has(parent, 'viewedAt')) return parent.viewedAt
|
|
return await ctx.loaders.comments.getViewedAt.load(parent.id)
|
|
},
|
|
async author(parent, _args, ctx) {
|
|
return ctx.loaders.users.getUser.load(parent.authorId)
|
|
},
|
|
async replyAuthors(parent, args, ctx) {
|
|
const authorIds = await ctx.loaders.comments.getReplyAuthorIds.load(parent.id)
|
|
return {
|
|
totalCount: authorIds.length,
|
|
authorIds: authorIds.slice(0, args.limit || 25)
|
|
}
|
|
},
|
|
async viewerResources(parent) {
|
|
return await getViewerResourcesForComment(parent.streamId, parent.id)
|
|
},
|
|
/**
|
|
* Until recently 'data' was just a JSONObject so theoretically it was possible to return all kinds of object
|
|
* structures. So we need to guard against this and ensure we always return the correct thing.
|
|
*/
|
|
async data(parent) {
|
|
const parentData = parent.data
|
|
if (!parentData) return null
|
|
|
|
if (isLegacyData(parentData)) {
|
|
return {
|
|
location: parentData.location || {},
|
|
camPos: parentData.camPos || [],
|
|
sectionBox: parentData.sectionBox || null,
|
|
selection: parentData.selection || null,
|
|
filters: parentData.filters || {}
|
|
}
|
|
}
|
|
|
|
if (isDataStruct(parentData)) {
|
|
const formattedState = formatSerializedViewerState(parentData.state)
|
|
return convertStateToLegacyData(formattedState)
|
|
}
|
|
|
|
return null
|
|
},
|
|
/**
|
|
* SerializedViewerState
|
|
*/
|
|
async viewerState(parent) {
|
|
const parentData = parent.data
|
|
if (!parentData) return null
|
|
|
|
if (isDataStruct(parentData)) {
|
|
const formattedState = formatSerializedViewerState(parentData.state)
|
|
return formattedState
|
|
}
|
|
|
|
if (isLegacyData(parentData)) {
|
|
return convertLegacyDataToState(parentData, parent)
|
|
}
|
|
|
|
return null
|
|
}
|
|
},
|
|
CommentReplyAuthorCollection: {
|
|
async items(parent, _args, ctx) {
|
|
return await ctx.loaders.users.getUser.loadMany(parent.authorIds)
|
|
}
|
|
},
|
|
Project: {
|
|
async commentThreads(parent, args, context) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: parent.id,
|
|
authCtx: context
|
|
})
|
|
return await getPaginatedProjectComments({
|
|
...args,
|
|
projectId: parent.id,
|
|
filter: {
|
|
...(args.filter || {}),
|
|
allModelVersions: !args.filter?.loadedVersionsOnly,
|
|
threadsOnly: true
|
|
}
|
|
})
|
|
}
|
|
},
|
|
Version: {
|
|
async commentThreads(parent, args, context) {
|
|
const stream = await context.loaders.commits.getCommitStream.load(parent.id)
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: stream.id,
|
|
authCtx: context
|
|
})
|
|
return await getPaginatedCommitComments({
|
|
...args,
|
|
commitId: parent.id,
|
|
filter: {
|
|
...(args.filter || {}),
|
|
threadsOnly: true
|
|
}
|
|
})
|
|
}
|
|
},
|
|
Model: {
|
|
async commentThreads(parent, args, context) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: parent.streamId,
|
|
authCtx: context
|
|
})
|
|
return await getPaginatedBranchComments({
|
|
...args,
|
|
branchId: parent.id,
|
|
filter: {
|
|
...(args.filter || {}),
|
|
threadsOnly: true
|
|
}
|
|
})
|
|
}
|
|
},
|
|
ViewerUserActivityMessage: {
|
|
async user(parent, args, context) {
|
|
const { userId } = parent
|
|
return context.loaders.users.getUser.load(userId)
|
|
}
|
|
},
|
|
Stream: {
|
|
async commentCount(parent, _args, context) {
|
|
if (context.role === Roles.Server.ArchivedUser)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
return await context.loaders.streams.getCommentThreadCount.load(parent.id)
|
|
}
|
|
},
|
|
Commit: {
|
|
async commentCount(parent, args, context) {
|
|
if (context.role === Roles.Server.ArchivedUser)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
return await getResourceCommentCount({ resourceId: parent.id })
|
|
}
|
|
},
|
|
Object: {
|
|
async commentCount(parent, args, context) {
|
|
if (context.role === Roles.Server.ArchivedUser)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
return await getResourceCommentCount({ resourceId: parent.id })
|
|
}
|
|
},
|
|
CommentMutations: {
|
|
async markViewed(_parent, args, ctx) {
|
|
await authorizeCommentAccess({
|
|
authCtx: ctx,
|
|
commentId: args.commentId
|
|
})
|
|
await markViewed(args.commentId, ctx.userId)
|
|
return true
|
|
},
|
|
async create(_parent, args, ctx) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: args.input.projectId,
|
|
authCtx: ctx,
|
|
requireProjectRole: true
|
|
})
|
|
return await createCommentThreadAndNotify(args.input, ctx.userId)
|
|
},
|
|
async reply(_parent, args, ctx) {
|
|
await authorizeCommentAccess({
|
|
commentId: args.input.threadId,
|
|
authCtx: ctx,
|
|
requireProjectRole: true
|
|
})
|
|
return await createCommentReplyAndNotify(args.input, ctx.userId)
|
|
},
|
|
async edit(_parent, args, ctx) {
|
|
await authorizeCommentAccess({
|
|
authCtx: ctx,
|
|
commentId: args.input.commentId,
|
|
requireProjectRole: true
|
|
})
|
|
return await editCommentAndNotify(args.input, ctx.userId)
|
|
},
|
|
async archive(_parent, args, ctx) {
|
|
await authorizeCommentAccess({
|
|
authCtx: ctx,
|
|
commentId: args.commentId,
|
|
requireProjectRole: true
|
|
})
|
|
await archiveCommentAndNotify(args.commentId, ctx.userId, args.archived)
|
|
return true
|
|
}
|
|
},
|
|
Mutation: {
|
|
commentMutations: () => ({}),
|
|
async broadcastViewerUserActivity(_parent, args, context) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: args.projectId,
|
|
authCtx: context
|
|
})
|
|
|
|
await publish(ViewerSubscriptions.UserActivityBroadcasted, {
|
|
projectId: args.projectId,
|
|
resourceItems: await getViewerResourceItemsUngrouped(args),
|
|
viewerUserActivityBroadcasted: args.message,
|
|
userId: context.userId
|
|
})
|
|
return true
|
|
},
|
|
|
|
async userViewerActivityBroadcast(parent, args, context) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: args.streamId,
|
|
authCtx: context
|
|
})
|
|
// const stream = await getStream({
|
|
// streamId: args.streamId,
|
|
// userId: context.userId
|
|
// })
|
|
// if (!stream) {
|
|
// throw new ApolloError('Stream not found')
|
|
// }
|
|
|
|
// if (!stream.isPublic && !context.auth) {
|
|
// return false
|
|
// }
|
|
await pubsub.publish(CommentSubscriptions.ViewerActivity, {
|
|
userViewerActivity: args.data,
|
|
streamId: args.streamId,
|
|
resourceId: args.resourceId,
|
|
authorId: context.userId
|
|
})
|
|
return true
|
|
},
|
|
async userCommentThreadActivityBroadcast(parent, args, context) {
|
|
if (!context.userId) return false
|
|
|
|
const stream = await getStream({
|
|
streamId: args.streamId,
|
|
userId: context.userId
|
|
})
|
|
|
|
if (!stream.allowPublicComments && !stream.role)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
await pubsub.publish(CommentSubscriptions.CommentThreadActivity, {
|
|
commentThreadActivity: { type: 'reply-typing-status', data: args.data },
|
|
streamId: args.streamId,
|
|
commentId: args.commentId
|
|
})
|
|
return true
|
|
},
|
|
|
|
async commentCreate(parent, args, context) {
|
|
if (!context.userId)
|
|
throw new ApolloForbiddenError('Only registered users can comment.')
|
|
|
|
const stream = await getStream({
|
|
streamId: args.input.streamId,
|
|
userId: context.userId
|
|
})
|
|
|
|
if (!stream.allowPublicComments && !stream.role)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
const comment = await createComment({
|
|
userId: context.userId,
|
|
input: args.input
|
|
})
|
|
|
|
await addCommentCreatedActivity({
|
|
streamId: args.input.streamId,
|
|
userId: context.userId,
|
|
input: args.input,
|
|
comment
|
|
})
|
|
|
|
return comment.id
|
|
},
|
|
|
|
async commentEdit(parent, args, context) {
|
|
// NOTE: This is NOT in use anywhere
|
|
const stream = await authorizeProjectCommentsAccess({
|
|
projectId: args.input.streamId,
|
|
authCtx: context,
|
|
requireProjectRole: true
|
|
})
|
|
const matchUser = !stream.role
|
|
try {
|
|
await editComment({ userId: context.userId, input: args.input, matchUser })
|
|
return true
|
|
} catch (err) {
|
|
if (err instanceof ForbiddenError) throw new ApolloForbiddenError(err.message)
|
|
throw err
|
|
}
|
|
},
|
|
|
|
// used for flagging a comment as viewed
|
|
async commentView(parent, args, context) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: args.streamId,
|
|
authCtx: context
|
|
})
|
|
await viewComment({ userId: context.userId, commentId: args.commentId })
|
|
return true
|
|
},
|
|
|
|
async commentArchive(parent, args, context) {
|
|
await authorizeProjectCommentsAccess({
|
|
projectId: args.streamId,
|
|
authCtx: context,
|
|
requireProjectRole: true
|
|
})
|
|
|
|
let updatedComment
|
|
try {
|
|
updatedComment = await archiveComment({ ...args, userId: context.userId }) // NOTE: permissions check inside service
|
|
} catch (err) {
|
|
if (err instanceof ForbiddenError) throw new ApolloForbiddenError(err.message)
|
|
throw err
|
|
}
|
|
|
|
await addCommentArchivedActivity({
|
|
streamId: args.streamId,
|
|
commentId: args.commentId,
|
|
userId: context.userId,
|
|
input: args,
|
|
comment: updatedComment
|
|
})
|
|
|
|
return true
|
|
},
|
|
|
|
async commentReply(parent, args, context) {
|
|
if (!context.userId)
|
|
throw new ApolloForbiddenError('Only registered users can comment.')
|
|
|
|
const stream = await getStream({
|
|
streamId: args.input.streamId,
|
|
userId: context.userId
|
|
})
|
|
|
|
if (!stream.allowPublicComments && !stream.role)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
const reply = await createCommentReply({
|
|
authorId: context.userId,
|
|
parentCommentId: args.input.parentComment,
|
|
streamId: args.input.streamId,
|
|
text: args.input.text,
|
|
data: args.input.data,
|
|
blobIds: args.input.blobIds
|
|
})
|
|
|
|
await addReplyAddedActivity({
|
|
streamId: args.input.streamId,
|
|
input: args.input,
|
|
reply,
|
|
userId: context.userId
|
|
})
|
|
|
|
return reply.id
|
|
}
|
|
},
|
|
Subscription: {
|
|
userViewerActivity: {
|
|
subscribe: withFilter(
|
|
() => pubsub.asyncIterator([CommentSubscriptions.ViewerActivity]),
|
|
async (payload, variables, context) => {
|
|
const stream = await getStream({
|
|
streamId: payload.streamId,
|
|
userId: context.userId
|
|
})
|
|
|
|
if (!stream.allowPublicComments && !stream.role)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
// dont report users activity to himself
|
|
if (context.userId && context.userId === payload.authorId) {
|
|
return false
|
|
}
|
|
|
|
return (
|
|
payload.streamId === variables.streamId &&
|
|
payload.resourceId === variables.resourceId
|
|
)
|
|
}
|
|
)
|
|
},
|
|
commentActivity: {
|
|
subscribe: withFilter(
|
|
() => pubsub.asyncIterator([CommentSubscriptions.CommentActivity]),
|
|
async (payload, variables, context) => {
|
|
const stream = await getStream({
|
|
streamId: payload.streamId,
|
|
userId: context.userId
|
|
})
|
|
|
|
if (!stream.allowPublicComments && !stream.role)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
// if we're listening for a stream's root comments events
|
|
if (!variables.resourceIds) {
|
|
return payload.streamId === variables.streamId
|
|
}
|
|
|
|
// otherwise perform a deeper check
|
|
try {
|
|
// prevents comment exfiltration by listening in to a auth'ed stream, but different commit ("stream hopping" for subscriptions)
|
|
await streamResourceCheck({
|
|
streamId: variables.streamId,
|
|
resources: variables.resourceIds.map((resId) => {
|
|
return {
|
|
resourceId: resId,
|
|
resourceType: resId.length === 10 ? 'commit' : 'object'
|
|
}
|
|
})
|
|
})
|
|
for (const res of variables.resourceIds) {
|
|
if (
|
|
payload.resourceIds.includes(res) &&
|
|
payload.streamId === variables.streamId
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
}
|
|
)
|
|
},
|
|
commentThreadActivity: {
|
|
subscribe: withFilter(
|
|
() => pubsub.asyncIterator([CommentSubscriptions.CommentThreadActivity]),
|
|
async (payload, variables, context) => {
|
|
const stream = await getStream({
|
|
streamId: payload.streamId,
|
|
userId: context.userId
|
|
})
|
|
|
|
if (!stream.allowPublicComments && !stream.role)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
return (
|
|
payload.streamId === variables.streamId &&
|
|
payload.commentId === variables.commentId
|
|
)
|
|
}
|
|
)
|
|
},
|
|
// new subscriptions:
|
|
viewerUserActivityBroadcasted: {
|
|
subscribe: filteredSubscribe(
|
|
ViewerSubscriptions.UserActivityBroadcasted,
|
|
async (payload, variables, context) => {
|
|
const target = variables.target
|
|
const sessionId = variables.sessionId
|
|
|
|
if (!target.resourceIdString.trim().length) return false
|
|
if (payload.projectId !== target.projectId) return false
|
|
|
|
const [stream, requestedResourceItems] = await Promise.all([
|
|
getStream({
|
|
streamId: payload.projectId,
|
|
userId: context.userId
|
|
}),
|
|
getViewerResourceItemsUngrouped(target)
|
|
])
|
|
|
|
if (!stream.isPublic && !stream.role)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
// dont report users activity to himself
|
|
if (
|
|
sessionId &&
|
|
sessionId === payload.viewerUserActivityBroadcasted.sessionId
|
|
) {
|
|
return false
|
|
}
|
|
|
|
// Check if resources fit
|
|
if (doViewerResourcesFit(requestedResourceItems, payload.resourceItems)) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
)
|
|
},
|
|
projectCommentsUpdated: {
|
|
subscribe: filteredSubscribe(
|
|
ProjectSubscriptions.ProjectCommentsUpdated,
|
|
async (payload, variables, context) => {
|
|
const target = variables.target
|
|
if (payload.projectId !== target.projectId) return false
|
|
|
|
const [stream, requestedResourceItems] = await Promise.all([
|
|
getStream({
|
|
streamId: payload.projectId,
|
|
userId: context.userId
|
|
}),
|
|
getViewerResourceItemsUngrouped(target)
|
|
])
|
|
|
|
if (!(stream.isDiscoverable || stream.isPublic) && !stream.role)
|
|
throw new ApolloForbiddenError('You are not authorized.')
|
|
|
|
if (!target.resourceIdString) {
|
|
return true
|
|
}
|
|
|
|
// Check if resources fit
|
|
if (doViewerResourcesFit(requestedResourceItems, payload.resourceItems)) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|