diff --git a/backend/package.json b/backend/package.json index 8a7b43e8e..bb3638265 100644 --- a/backend/package.json +++ b/backend/package.json @@ -109,4 +109,4 @@ "prettier": "~1.14.3", "supertest": "~4.0.2" } -} \ No newline at end of file +} diff --git a/backend/public/uploads/.gitkeep b/backend/public/uploads/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/graphql-schema.js b/backend/src/graphql-schema.js index 0942b2381..a846541e7 100644 --- a/backend/src/graphql-schema.js +++ b/backend/src/graphql-schema.js @@ -12,6 +12,7 @@ import rewards from './resolvers/rewards.js' import socialMedia from './resolvers/socialMedia.js' import notifications from './resolvers/notifications' import comments from './resolvers/comments' +import users from './resolvers/users' export const typeDefs = fs .readFileSync(process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql')) @@ -35,5 +36,6 @@ export const resolvers = { ...socialMedia.Mutation, ...notifications.Mutation, ...comments.Mutation, + ...users.Mutation, }, } diff --git a/backend/src/middleware/notifications/extractMentions.js b/backend/src/middleware/notifications/extractIds/index.js similarity index 94% rename from backend/src/middleware/notifications/extractMentions.js rename to backend/src/middleware/notifications/extractIds/index.js index d6fa8ac3a..c2fcf169c 100644 --- a/backend/src/middleware/notifications/extractMentions.js +++ b/backend/src/middleware/notifications/extractIds/index.js @@ -2,6 +2,7 @@ import cheerio from 'cheerio' const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g export default function(content) { + if (!content) return [] const $ = cheerio.load(content) const urls = $('.mention') .map((_, el) => { diff --git a/backend/src/middleware/notifications/extractMentions.spec.js b/backend/src/middleware/notifications/extractIds/spec.js similarity index 93% rename from backend/src/middleware/notifications/extractMentions.spec.js rename to backend/src/middleware/notifications/extractIds/spec.js index d55c492ce..341c39cec 100644 --- a/backend/src/middleware/notifications/extractMentions.spec.js +++ b/backend/src/middleware/notifications/extractIds/spec.js @@ -1,6 +1,12 @@ -import extractIds from './extractMentions' +import extractIds from '.' + +describe('extractIds', () => { + describe('content undefined', () => { + it('returns empty array', () => { + expect(extractIds()).toEqual([]) + }) + }) -describe('extract', () => { describe('searches through links', () => { it('ignores links without .mention class', () => { const content = diff --git a/backend/src/middleware/notifications/index.js b/backend/src/middleware/notifications/index.js index 866a4376a..ca460a512 100644 --- a/backend/src/middleware/notifications/index.js +++ b/backend/src/middleware/notifications/index.js @@ -1,4 +1,4 @@ -import extractIds from './extractMentions' +import extractIds from './extractIds' const notify = async (resolve, root, args, context, resolveInfo) => { // extract user ids before xss-middleware removes link classes diff --git a/backend/src/middleware/validation/index.js b/backend/src/middleware/validation/index.js index 8e9be59ef..cfc852dcb 100644 --- a/backend/src/middleware/validation/index.js +++ b/backend/src/middleware/validation/index.js @@ -3,7 +3,7 @@ import { UserInputError } from 'apollo-server' const USERNAME_MIN_LENGTH = 3 const validateUsername = async (resolve, root, args, context, info) => { - if (args.name && args.name.length >= USERNAME_MIN_LENGTH) { + if (!('name' in args) || (args.name && args.name.length >= USERNAME_MIN_LENGTH)) { /* eslint-disable-next-line no-return-await */ return await resolve(root, args, context, info) } else { diff --git a/backend/src/resolvers/fileUpload/index.js b/backend/src/resolvers/fileUpload/index.js new file mode 100644 index 000000000..c37d87e39 --- /dev/null +++ b/backend/src/resolvers/fileUpload/index.js @@ -0,0 +1,27 @@ +import { createWriteStream } from 'fs' +import path from 'path' +import slug from 'slug' + +const storeUpload = ({ createReadStream, fileLocation }) => + new Promise((resolve, reject) => + createReadStream() + .pipe(createWriteStream(`public${fileLocation}`)) + .on('finish', resolve) + .on('error', reject), + ) + +export default async function fileUpload(params, { file, url }, uploadCallback = storeUpload) { + const upload = params[file] + + if (upload) { + const { createReadStream, filename } = await upload + const { name } = path.parse(filename) + const fileLocation = `/uploads/${Date.now()}-${slug(name)}` + await uploadCallback({ createReadStream, fileLocation }) + delete params[file] + + params[url] = fileLocation + } + + return params +} diff --git a/backend/src/resolvers/fileUpload/spec.js b/backend/src/resolvers/fileUpload/spec.js new file mode 100644 index 000000000..5767d6457 --- /dev/null +++ b/backend/src/resolvers/fileUpload/spec.js @@ -0,0 +1,65 @@ +import fileUpload from '.' + +describe('fileUpload', () => { + let params + let uploadCallback + + beforeEach(() => { + params = { + uploadAttribute: { + filename: 'avatar.jpg', + mimetype: 'image/jpeg', + encoding: '7bit', + createReadStream: jest.fn(), + }, + } + uploadCallback = jest.fn() + }) + + it('calls uploadCallback', async () => { + await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback) + expect(uploadCallback).toHaveBeenCalled() + }) + + describe('file name', () => { + it('saves the upload url in params[url]', async () => { + await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback) + expect(params.attribute).toMatch(/^\/uploads\/\d+-avatar$/) + }) + + it('uses the name without file ending', async () => { + params.uploadAttribute.filename = 'somePng.png' + await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback) + expect(params.attribute).toMatch(/^\/uploads\/\d+-somePng/) + }) + + it('creates a url safe name', async () => { + params.uploadAttribute.filename = + '/path/to/awkward?/ file-location/?foo- bar-avatar.jpg?foo- bar' + await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback) + expect(params.attribute).toMatch(/^\/uploads\/\d+-foo-bar-avatar$/) + }) + + describe('in case of duplicates', () => { + it('creates unique names to avoid overwriting existing files', async () => { + const { attribute: first } = await fileUpload( + { + ...params, + }, + { file: 'uploadAttribute', url: 'attribute' }, + uploadCallback, + ) + + await new Promise(resolve => setTimeout(resolve, 1000)) + const { attribute: second } = await fileUpload( + { + ...params, + }, + { file: 'uploadAttribute', url: 'attribute' }, + uploadCallback, + ) + expect(first).not.toEqual(second) + }) + }) + }) +}) diff --git a/backend/src/resolvers/posts.js b/backend/src/resolvers/posts.js index e4d0d6876..ea962a662 100644 --- a/backend/src/resolvers/posts.js +++ b/backend/src/resolvers/posts.js @@ -1,8 +1,15 @@ import { neo4jgraphql } from 'neo4j-graphql-js' +import fileUpload from './fileUpload' export default { Mutation: { + UpdatePost: async (object, params, context, resolveInfo) => { + params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) + return neo4jgraphql(object, params, context, resolveInfo, false) + }, + CreatePost: async (object, params, context, resolveInfo) => { + params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) const result = await neo4jgraphql(object, params, context, resolveInfo, false) const session = context.driver.session() diff --git a/backend/src/resolvers/users.js b/backend/src/resolvers/users.js new file mode 100644 index 000000000..53bf0967e --- /dev/null +++ b/backend/src/resolvers/users.js @@ -0,0 +1,15 @@ +import { neo4jgraphql } from 'neo4j-graphql-js' +import fileUpload from './fileUpload' + +export default { + Mutation: { + UpdateUser: async (object, params, context, resolveInfo) => { + params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' }) + return neo4jgraphql(object, params, context, resolveInfo, false) + }, + CreateUser: async (object, params, context, resolveInfo) => { + params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' }) + return neo4jgraphql(object, params, context, resolveInfo, false) + }, + }, +} diff --git a/backend/src/resolvers/users.spec.js b/backend/src/resolvers/users.spec.js index bf55784fd..22096b6c8 100644 --- a/backend/src/resolvers/users.spec.js +++ b/backend/src/resolvers/users.spec.js @@ -67,6 +67,7 @@ describe('users', () => { it('with no name', async () => { const variables = { id: 'u47', + name: null, } const expected = 'Username must be at least 3 characters long!' await expect(client.request(mutation, variables)).rejects.toThrow(expected) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 902a7abf9..a581d287c 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -1,3 +1,5 @@ +scalar Upload + type Query { isLoggedIn: Boolean! # Get the currently logged in User based on the given JWT Token @@ -18,6 +20,7 @@ type Query { ) CommentByPost(postId: ID!): [Comment]! } + type Mutation { # Get a JWT Token for the given Email and password login(email: String!, password: String!): String! @@ -99,6 +102,7 @@ type User { slug: String password: String! avatar: String + avatarUpload: Upload deleted: Boolean disabled: Boolean disabledBy: User @relation(name: "DISABLED", direction: "IN") @@ -175,6 +179,7 @@ type Post { content: String! contentExcerpt: String image: String + imageUpload: Upload visibility: VisibilityEnum deleted: Boolean disabled: Boolean