diff --git a/config/permissions.js b/config/permissions.js new file mode 100644 index 0000000000000000000000000000000000000000..75124f38731da9f19dc308868e3de154b2daf7b3 --- /dev/null +++ b/config/permissions.js @@ -0,0 +1,39 @@ +const { rule, shield, and, or, not, allow, deny } = require('graphql-shield') + +const isAdmin = rule({ cache: 'contextual' })( + async (parent, args, ctx, info) => ctx.user.admin, +) + +const isEditor = rule({ cache: 'contextual' })( + async (parent, args, ctx, info) => { + const rows = ctx.user + .$relatedQuery('teams') + .where({ role: 'seniorEditor' }) + .orWhere({ role: 'handlingEditor' }) + .resultSize() + return rows !== 0 + }, +) + +const isAuthenticated = rule({ cache: 'contextual' })( + async (parent, args, ctx, info) => !!ctx.user, +) + +const permissions = shield( + { + Query: { + paginatedManuscripts: isAdmin, + }, + Mutation: { + createManuscript: isAuthenticated, + }, + // Fruit: isAuthenticated, + // Customer: isAdmin, + }, + { + allowExternalErrors: true, + fallbackRule: or(isAdmin, isEditor), + }, +) + +module.exports = permissions diff --git a/server/app.js b/server/app.js index 69912c4e7b66dff7217b4501c1b0654423bc8f39..ffe8cf9e1e2bc6b1415099d8a469cb0aacba626c 100644 --- a/server/app.js +++ b/server/app.js @@ -10,7 +10,7 @@ const helmet = require('helmet') const cookieParser = require('cookie-parser') const bodyParser = require('body-parser') const passport = require('passport') -const gqlApi = require('pubsweet-server/src/graphql/api') // TODO: Fix import +const gqlApi = require('./graphql') // const index = require('./routes/index') // const api = require('./routes/api') const logger = require('@pubsweet/logger') @@ -56,6 +56,8 @@ const configureApp = app => { app.use(helmet()) app.use(express.static(path.resolve('.', '_build'))) + app.use('/public', express.static(path.resolve(__dirname, '../public'))) + if (config.has('pubsweet-server.uploads')) { app.use( '/uploads', @@ -82,18 +84,6 @@ const configureApp = app => { // GraphQL API gqlApi(app) - // SSE update stream - // if (_.get('pubsweet-server.sse', config)) { - // sse.setAuthsome(authsome) - // app.get( - // '/updates', - // passport.authenticate('bearer', { session: false }), - // sse.connect, - // ) - - // app.locals.sse = sse - // } - // Serve the index page for front end // app.use('/', index) app.use('/healthcheck', (req, res) => res.send('All good!')) diff --git a/server/auth-orcid/orcid.js b/server/auth-orcid/orcid.js index d0ef6cb5d2e49ae10bd076a9af84ac2489b02a6c..01f7dee59295d0f9319d31d4b431abf736394e87 100644 --- a/server/auth-orcid/orcid.js +++ b/server/auth-orcid/orcid.js @@ -35,6 +35,7 @@ module.exports = app => { } } + // TODO: Update the user details on every login, asynchronously try { if (!user) { user = await new User({ diff --git a/server/graphql.js b/server/graphql.js new file mode 100644 index 0000000000000000000000000000000000000000..0edd731f92ac7155400e72a181431e5d0868da11 --- /dev/null +++ b/server/graphql.js @@ -0,0 +1,74 @@ +const passport = require('passport') +const { ApolloServer } = require('apollo-server-express') +const isEmpty = require('lodash/isEmpty') +const logger = require('@pubsweet/logger') +const errors = require('@pubsweet/errors') +const config = require('config') +const { applyMiddleware } = require('graphql-middleware') + +const schema = require('pubsweet-server/src/graphql/schema') // TODO: Fix import +const loaders = require('pubsweet-server/src/graphql/loaders') // TODO: Fix import + +const authBearerAndPublic = passport.authenticate(['bearer', 'anonymous'], { + session: false, +}) + +const helpers = require('pubsweet-server/src/helpers/authorization') + +const hostname = config.has('pubsweet-server.hostname') + ? config.get('pubsweet-server.hostname') + : 'localhost' + +const extraApolloConfig = config.has('pubsweet-server.apollo') + ? config.get('pubsweet-server.apollo') + : {} + +const getUser = async userId => { + const { User } = require('@pubsweet/models') + return userId ? User.query().findById(userId) : undefined +} + +const permissions = require('../config/permissions') + +const api = app => { + app.use('/graphql', authBearerAndPublic) + const server = new ApolloServer({ + schema: applyMiddleware(schema, permissions), + context: async ({ req, res }) => ({ + helpers, + user: await getUser(req.user), + loaders: loaders(), + models: require('@pubsweet/models'), + }), + formatError: err => { + const error = isEmpty(err.originalError) ? err : err.originalError + + logger.error(error.message, { error }) + + const isPubsweetDefinedError = Object.values(errors).some( + pubsweetError => error instanceof pubsweetError, + ) + // err is always a GraphQLError which should be passed to the client + if (!isEmpty(err.originalError) && !isPubsweetDefinedError) + return { + name: 'Server Error', + message: 'Something went wrong! Please contact your administrator', + } + + return { + name: error.name || 'GraphQLError', + message: error.message, + extensions: { + code: err.extensions.code, + }, + } + }, + playground: { + subscriptionEndpoint: `ws://${hostname}:3000/subscriptions`, + }, + ...extraApolloConfig, + }) + server.applyMiddleware({ app }) +} + +module.exports = api diff --git a/server/model-manuscript/src/graphql.js b/server/model-manuscript/src/graphql.js index 9ee042fe075a094368d5c92e8fdccec58bec7e60..e615f43bea6c281801fcd8f1e8f7ae9cd7eadf0a 100644 --- a/server/model-manuscript/src/graphql.js +++ b/server/model-manuscript/src/graphql.js @@ -30,22 +30,22 @@ const resolvers = { }), status: 'new', submission, - submitterId: ctx.user, + submitterId: ctx.user.id, } // eslint-disable-next-line - const manuscript = await new ctx.connectors.Manuscript.model( + const manuscript = await new ctx.models.Manuscript( emptyManuscript, ).saveGraph() // Create two channels: 1. free for all involved, 2. editorial - const allChannel = new ctx.connectors.Channel.model({ + const allChannel = new ctx.models.Channel({ manuscriptId: manuscript.id, topic: 'Manuscript discussion', type: 'all', }).save() - const editorialChannel = new ctx.connectors.Channel.model({ + const editorialChannel = new ctx.models.Channel({ manuscriptId: manuscript.id, topic: 'Editorial discussion', type: 'editorial', @@ -61,7 +61,7 @@ const resolvers = { }) manuscript.files.push( // eslint-disable-next-line - await new ctx.connectors.File.model(newFile).save(), + await new ctx.models.File(newFile).save(), ) }) @@ -73,7 +73,7 @@ const resolvers = { name: 'Author', objectId: manuscript.id, objectType: 'Manuscript', - members: [{ user: { id: ctx.user } }], + members: [{ user: { id: ctx.user.id } }], }, { relate: true }, ) @@ -84,11 +84,11 @@ const resolvers = { }, async deleteManuscript(_, { id }, ctx) { const deleteManuscript = [] - const manuscript = await ctx.connectors.Manuscript.model.find(id) + const manuscript = await ctx.models.Manuscript.find(id) deleteManuscript.push(manuscript.id) if (manuscript.parentId) { - const parentManuscripts = await ctx.connectors.Manuscript.model.findByField( + const parentManuscripts = await ctx.models.Manuscript.findByField( 'parent_id', manuscript.parentId, ) @@ -101,7 +101,7 @@ const resolvers = { // Delete Manuscript if (deleteManuscript.length > 0) { deleteManuscript.forEach(async manuscript => { - await ctx.connectors.Manuscript.delete(manuscript, ctx) + await ctx.models.Manuscript.query().deleteById(manuscript) }) } return id @@ -143,12 +143,12 @@ const resolvers = { }, async updateManuscript(_, { id, input }, ctx) { const data = JSON.parse(input) - const manuscript = await ctx.connectors.Manuscript.fetchOne(id, ctx) + const manuscript = await ctx.models.Manuscript.findById(id) const update = merge({}, manuscript, data) - return ctx.connectors.Manuscript.update(id, update, ctx) + return ctx.models.Manuscript.update(id, update, ctx) }, async makeDecision(_, { id, decision }, ctx) { - const manuscript = await ctx.connectors.Manuscript.fetchOne(id, ctx) + const manuscript = await ctx.models.Manuscript.findById(id) manuscript.decision = decision manuscript.status = decision @@ -178,7 +178,7 @@ const resolvers = { }, ] manuscript.decision = '' - manuscript.files = await ctx.connectors.File.model.findByObject({ + manuscript.files = await ctx.models.File.findByObject({ object: 'Manuscript', object_id: manuscript.id, }) @@ -190,12 +190,10 @@ const resolvers = { return manuscript }, async manuscripts(_, { where }, ctx) { - return ctx.connectors.Manuscript.fetchAll(where, ctx, { - eager: '[teams, reviews]', - }) + return ctx.models.Manuscript.query().eager('[teams, reviews]') }, async paginatedManuscripts(_, { sort, offset, limit, filter }, ctx) { - const query = ctx.connectors.Manuscript.model.query().eager('submitter') + const query = ctx.models.Manuscript.query().eager('submitter') if (filter && filter.status) { query.where({ status: filter.status }) @@ -391,24 +389,6 @@ const typeDefs = ` notesType: String content: String } - - # type reviewerStatus { - # user: ID! - # status: String - # } - - # input reviewerStatusUpdate { - # user: ID! - # status: String - # } - - # extend type Team { - # status: [reviewerStatus] - # } - - # extend input TeamInput { - # status: [reviewerStatusUpdate] - # } ` module.exports = { diff --git a/server/model-message/src/graphql.js b/server/model-message/src/graphql.js index c5cb8146aa27c575cbbe774774bfd67bbd8a291b..6b6a362311e49d3659170d09767f540efd7333fc 100644 --- a/server/model-message/src/graphql.js +++ b/server/model-message/src/graphql.js @@ -43,7 +43,8 @@ const resolvers = { Mutation: { createMessage: async (_, { content, channelId }, context) => { const pubsub = await getPubsub() - const userId = context.user + const userId = context.user.id + console.log(userId, context.user) const savedMessage = await new Message({ content, userId, diff --git a/server/model-message/src/migrations/1585344885-add-messages.sql b/server/model-message/src/migrations/1585344885-add-messages.sql index caad7a3ba769d1e8cf3f9988b9e675b75923317a..3ac17266a2fb24ae2d51b92a20a4fecbc292d3dd 100644 --- a/server/model-message/src/migrations/1585344885-add-messages.sql +++ b/server/model-message/src/migrations/1585344885-add-messages.sql @@ -1,7 +1,7 @@ CREATE TABLE messages ( id UUID PRIMARY KEY, - user_id uuid NOT NULL REFERENCES users(id), - channel_id uuid NOT NULL REFERENCES channels(id), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE, created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT current_timestamp, updated TIMESTAMP WITH TIME ZONE, content TEXT diff --git a/server/model-review/src/resolvers.js b/server/model-review/src/resolvers.js index ab90a6ecc39cf9fcf15ea91d2aadeaafa155e49c..90b611e5b4d76d6fc766d058016f5618cd776adb 100644 --- a/server/model-review/src/resolvers.js +++ b/server/model-review/src/resolvers.js @@ -14,7 +14,7 @@ const resolvers = { return rvw } - input.userId = ctx.user + input.userId = ctx.user.id const review = await new Review(input) await review.save() review.comments = await review.getComments() diff --git a/server/model-team/src/graphql.js b/server/model-team/src/graphql.js index 830133ce8eec65a5bd53b772952c011a632f1a20..0f1b08e636c84e1152f854635976f1877c67c873 100644 --- a/server/model-team/src/graphql.js +++ b/server/model-team/src/graphql.js @@ -3,28 +3,32 @@ const eager = '[members.[user, alias]]' const resolvers = { Query: { team(_, { id }, ctx) { - return ctx.connectors.Team.fetchOne(id, ctx, { eager }) + return ctx.models.Team.query() + .findById(id) + .eager(eager) }, teams(_, { where }, ctx) { where = where || {} - if (where.users) { - const { users } = where - delete where.users - where._relations = [{ relation: 'users', ids: users }] - } - - if (where.alias) { - const { alias } = where - delete where.alias - where._relations = [{ relation: 'aliases', object: alias }] - } - - return ctx.connectors.Team.fetchAll(where, ctx, { eager }) + // if (where.users) { + // const { users } = where + // delete where.users + // where._relations = [{ relation: 'users', ids: users }] + // } + + // if (where.alias) { + // const { alias } = where + // delete where.alias + // where._relations = [{ relation: 'aliases', object: alias }] + // } + + return ctx.models.Team.query() + .where(where) + .eager(eager) }, }, Mutation: { deleteTeam(_, { id }, ctx) { - return ctx.connectors.Team.delete(id, ctx) + return ctx.models.Team.query().deleteById(id) }, createTeam(_, { input }, ctx) { const options = { @@ -33,22 +37,29 @@ const resolvers = { allowUpsert: '[members, members.alias]', eager: '[members.[user.teams, alias]]', } - return ctx.connectors.Team.create(input, ctx, options) + return ctx.models.Team.query().insertGraphAndFetch(input, options) }, updateTeam(_, { id, input }, ctx) { - return ctx.connectors.Team.update(id, input, ctx, { - unrelate: false, - eager: 'members.user.teams', - }) + return ctx.models.Team.query().upsertGraphAndFetch( + { + id, + ...input, + }, + { + unrelate: false, + eager: 'members.user.teams', + }, + ) }, }, User: { teams: (parent, _, ctx) => - ctx.connectors.User.fetchRelated(parent.id, 'teams', undefined, ctx), + ctx.models.User.relatedQuery('teams').for(parent.id), }, Team: { - members(team, { where }, ctx) { - return ctx.connectors.Team.fetchRelated(team.id, 'members', where, ctx) + async members(team, { where }, ctx) { + const t = await ctx.models.Team.query().findById(team.id) + return t.$relatedQuery('members') }, object(team, vars, ctx) { const { objectId, objectType } = team @@ -56,21 +67,13 @@ const resolvers = { }, }, TeamMember: { - user(teamMember, vars, ctx) { - return ctx.connectors.TeamMember.fetchRelated( - teamMember.id, - 'user', - undefined, - ctx, - ) + async user(teamMember, vars, ctx) { + const member = await ctx.models.TeamMember.query().findById(teamMember.id) + return member.$relatedQuery('user') }, - alias(teamMember, vars, ctx) { - return ctx.connectors.TeamMember.fetchRelated( - teamMember.id, - 'alias', - undefined, - ctx, - ) + async alias(teamMember, vars, ctx) { + const member = await ctx.models.TeamMember.query().findById(teamMember.id) + return member.$relatedQuery('alias') }, }, } diff --git a/server/model-user/src/graphql.js b/server/model-user/src/graphql.js index 2cc0698de92cfbc0edc5032069d04eae2d7138f8..56565dd4a4645070bf9d86913cd82d012e87b102 100644 --- a/server/model-user/src/graphql.js +++ b/server/model-user/src/graphql.js @@ -1,18 +1,16 @@ const logger = require('@pubsweet/logger') const { AuthorizationError, ConflictError } = require('@pubsweet/errors') -const eager = undefined - const resolvers = { Query: { user(_, { id }, ctx) { - return ctx.connectors.User.fetchOne(id, ctx, { eager }) + return ctx.models.User.query().findById(id) }, async users(_, vars, ctx) { - return ctx.connectors.User.model.query() + return ctx.models.User.query() }, async paginatedUsers(_, { sort, offset, limit, filter }, ctx) { - const query = ctx.connectors.User.model.query() + const query = ctx.models.User.query() if (filter && filter.admin) { query.where({ admin: true }) @@ -39,21 +37,23 @@ const resolvers = { users, } - // return ctx.connectors.User.fetchAll(where, ctx, { eager }) + // return ctx.models.User.fetchAll(where, ctx, { eager }) }, // Authentication - currentUser(_, vars, ctx) { + async currentUser(_, vars, ctx) { if (!ctx.user) return null - return ctx.connectors.User.model.find(ctx.user, { eager }) + const user = await ctx.models.User.find(ctx.user.id) + user._currentRoles = await user.currentRoles() + return user }, searchUsers(_, { teamId, query }, ctx) { if (teamId) { - return ctx.connectors.User.model + return ctx.models.User.model .query() .where({ teamId }) .where('username', 'ilike', `${query}%`) } - return ctx.connectors.User.model + return ctx.models.User.model .query() .where('username', 'ilike', `${query}%`) }, @@ -63,9 +63,7 @@ const resolvers = { const user = { username: input.username, email: input.email, - passwordHash: await ctx.connectors.User.model.hashPassword( - input.password, - ), + passwordHash: await ctx.models.User.hashPassword(input.password), } const identity = { @@ -77,7 +75,7 @@ const resolvers = { user.defaultIdentity = identity try { - const result = await ctx.connectors.User.create(user, ctx, { + const result = await ctx.models.User.create(user, ctx, { eager: 'defaultIdentity', }) @@ -93,17 +91,15 @@ const resolvers = { } }, deleteUser(_, { id }, ctx) { - return ctx.connectors.User.delete(id, ctx) + return ctx.models.User.delete(id, ctx) }, async updateUser(_, { id, input }, ctx) { if (input.password) { - input.passwordHash = await ctx.connectors.User.model.hashPassword( - input.password, - ) + input.passwordHash = await ctx.models.User.hashPassword(input.password) delete input.password } - return ctx.connectors.User.update(id, input, ctx) + return ctx.models.User.update(id, input, ctx) }, // Authentication async loginUser(_, { input }, ctx) { @@ -112,7 +108,7 @@ const resolvers = { let isValid = false let user try { - user = await ctx.connectors.User.model.findByUsername(input.username) + user = await ctx.models.User.findByUsername(input.username) isValid = await user.validPassword(input.password) } catch (err) { logger.debug(err) @@ -126,7 +122,7 @@ const resolvers = { } }, async updateCurrentUsername(_, { username }, ctx) { - const user = await ctx.connectors.User.model.find(ctx.user) + const user = await ctx.models.User.find(ctx.user) user.username = username await user.save() return user @@ -134,16 +130,15 @@ const resolvers = { }, User: { async defaultIdentity(parent, args, ctx) { - const identity = await ctx.connectors.Identity.model - .query() + const identity = await ctx.models.Identity.query() .where({ userId: parent.id, isDefault: true }) .first() return identity }, async identities(parent, args, ctx) { - const identities = await ctx.connectors.Identity.model - .query() - .where({ userId: parent.id }) + const identities = await ctx.models.Identity.query().where({ + userId: parent.id, + }) return identities }, }, @@ -205,9 +200,17 @@ const typeDefs = ` defaultIdentity: Identity profilePicture: String online: Boolean + _currentRoles: [CurrentRole] + _currentGlobalRoles: [String] + } + + type CurrentRole { + id: ID + roles: [String] } interface Identity { + id: ID name: String aff: String # JATS <aff> email: String # JATS <aff> @@ -218,6 +221,7 @@ const typeDefs = ` # local identity (not from ORCID, etc.) type LocalIdentity implements Identity { + id: ID name: String email: String aff: String @@ -225,6 +229,7 @@ const typeDefs = ` } type ExternalIdentity implements Identity { + id: ID name: String identifier: String email: String diff --git a/server/model-user/src/user.js b/server/model-user/src/user.js index 468f9d470f6af4a510a59ec609a7cc467bc97136..fd8b6986db9db2826cfd01a66419c5f3f4686e72 100644 --- a/server/model-user/src/user.js +++ b/server/model-user/src/user.js @@ -53,6 +53,7 @@ class User extends BaseModel { modelClass: TeamMember, from: 'team_members.userId', to: 'team_members.teamId', + extra: ['status'], }, to: 'teams.id', }, @@ -78,12 +79,38 @@ class User extends BaseModel { } } - // eslint-disable-next-line class-methods-use-this - setOwners() { - // FIXME: this is overriden to be a no-op, because setOwners() is called by - // the API on create for all entity types and setting `owners` on a User is - // not allowed. This should instead be solved by having separate code paths - // in the API for different entity types. + // // eslint-disable-next-line class-methods-use-this + // setOwners() { + // // FIXME: this is overriden to be a no-op, because setOwners() is called by + // // the API on create for all entity types and setting `owners` on a User is + // // not allowed. This should instead be solved by having separate code paths + // // in the API for different entity types. + // } + + // This gives a view of the teams and team member structure to reflect + // the current roles the user is performing. E.g. if they are a member + // of a reviewer team and have the status of 'accepted', they will + // have a 'accepted:reviewer' role present in the returned object + async currentRoles(object) { + let teams + if (object && object.id) { + teams = await this.$relatedQuery('teams').where('objectId', object.id) + } else { + teams = await this.$relatedQuery('teams') + } + const roles = {} + + teams.forEach(t => { + const role = `${t.status ? `${t.status}:` : ''}${t.role}` + + // If there's an existing role for this object, add to the list + if (t.objectId && Array.isArray(roles[t.objectId])) { + roles[t.objectId].push(role) + } else if (t.objectId) { + roles[t.objectId] = [role] + } + }) + return Object.keys(roles).map(id => ({ id, roles: roles[id] })) } async save() { diff --git a/server/subscriptions.js b/server/subscriptions.js index f2a234b6bda4cb6893e30699948a3b7d9e713a59..18a3454ec2d38d79a02f9b0c6f007b34072c0475 100644 --- a/server/subscriptions.js +++ b/server/subscriptions.js @@ -11,7 +11,7 @@ const { token } = require('pubsweet-server/src/authentication') // TODO: Fix imp module.exports = { addSubscriptions: server => { - const connectors = require('pubsweet-server/src/connectors') + const models = require('@pubsweet/models') const helpers = require('pubsweet-server/src/helpers/authorization') const { User } = require('@pubsweet/models') @@ -31,23 +31,26 @@ module.exports = { reject(new Error('Bad auth token')) } - resolve({ user: id, connectors, helpers }) + resolve({ userId: id, models, helpers }) }) }) console.log('I AM ALIVE!') // Record a user's online status - await User.query() - .update({ online: true }) - .where('id', addTocontext.user) + const user = await User.query().updateAndFetchById( + addTocontext.userId, + { online: true }, + ) + + addTocontext.user = user return addTocontext }, onDisconnect: async (webSocket, context) => { const initialContext = await context.initPromise // Record that a user is no longer online - if (initialContext.user) { + if (initialContext.user && initialContext.user.id) { await User.query() .update({ online: false }) - .where('id', initialContext.user) + .where('id', initialContext.user.id) } }, },