diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf2da9c3..22efb08dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,33 +4,6 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -#### [v0.4.2](https://github.com/Human-Connection/Human-Connection/compare/v0.4.1...v0.4.2) - -> 12 March 2020 - -- build(deps): bump @sentry/node from 5.13.1 to 5.14.0 in /backend [`#3260`](https://github.com/Human-Connection/Human-Connection/pull/3260) -- build(deps): bump graphql-shield from 7.0.14 to 7.1.0 in /backend [`#3259`](https://github.com/Human-Connection/Human-Connection/pull/3259) -- feat: more prominent output of ./scripts/translations/sort.sh and hint to --fix feature of the script on errors [`#3251`](https://github.com/Human-Connection/Human-Connection/pull/3251) -- build(deps): bump nodemailer from 6.4.4 to 6.4.5 in /backend [`#3254`](https://github.com/Human-Connection/Human-Connection/pull/3254) -- build(deps-dev): bump @vue/test-utils from 1.0.0-beta.31 to 1.0.0-beta.32 in /webapp [`#3248`](https://github.com/Human-Connection/Human-Connection/pull/3248) -- build(deps-dev): bump async-validator from 3.2.3 to 3.2.4 in /webapp [`#3255`](https://github.com/Human-Connection/Human-Connection/pull/3255) -- build(deps-dev): bump eslint-plugin-jest from 23.8.1 to 23.8.2 in /backend [`#3253`](https://github.com/Human-Connection/Human-Connection/pull/3253) -- feature: Delete_user_as_admin_through_API_only [`#3063`](https://github.com/Human-Connection/Human-Connection/pull/3063) -- feat: zero bell to all notifications page [2823] [`#3219`](https://github.com/Human-Connection/Human-Connection/pull/3219) -- fix: layout shift [2607] [`#3218`](https://github.com/Human-Connection/Human-Connection/pull/3218) -- feat: Documentation for locales script [`#3242`](https://github.com/Human-Connection/Human-Connection/pull/3242) -- build(deps): bump metascraper-audio from 5.11.1 to 5.11.6 in /backend [`#3235`](https://github.com/Human-Connection/Human-Connection/pull/3235) -- build(deps): bump metascraper-video from 5.11.1 to 5.11.6 in /backend [`#3247`](https://github.com/Human-Connection/Human-Connection/pull/3247) -- build(deps): bump metascraper-soundcloud from 5.11.5 to 5.11.6 in /backend [`#3246`](https://github.com/Human-Connection/Human-Connection/pull/3246) -- build(deps): bump metascraper-lang from 5.11.1 to 5.11.6 in /backend [`#3234`](https://github.com/Human-Connection/Human-Connection/pull/3234) -- build(deps): bump metascraper-description from 5.11.1 to 5.11.6 in /backend [`#3233`](https://github.com/Human-Connection/Human-Connection/pull/3233) -- build(deps): bump cross-env from 7.0.1 to 7.0.2 in /backend [`#3245`](https://github.com/Human-Connection/Human-Connection/pull/3245) -- build(deps): bump metascraper-title from 5.11.1 to 5.11.6 in /backend [`#3244`](https://github.com/Human-Connection/Human-Connection/pull/3244) -- chore: Update to v0.4.1 [`#3243`](https://github.com/Human-Connection/Human-Connection/pull/3243) -- DRY user.spec.js [`da16590`](https://github.com/Human-Connection/Human-Connection/commit/da165906e2ed12baddd902b43064103ab3adfa06) -- test deleteuser as admin, moderator, another user and as I myself, fix lint [`3983612`](https://github.com/Human-Connection/Human-Connection/commit/3983612c56ac92473a192a318959e4c691a3e7b8) -- feature: test delete user as admin [`84c1547`](https://github.com/Human-Connection/Human-Connection/commit/84c154798efac0cec4c13dfefae18a6a9542058a) - #### [v0.4.1](https://github.com/Human-Connection/Human-Connection/compare/v0.4.0...v0.4.1) > 9 March 2020 diff --git a/backend/package.json b/backend/package.json index 95df11845..13d5161d5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "human-connection-backend", - "version": "0.4.2", + "version": "0.4.1", "description": "GraphQL Backend for Human Connection", "main": "src/index.js", "scripts": { @@ -61,7 +61,7 @@ "graphql-middleware": "~4.0.2", "graphql-middleware-sentry": "^3.2.1", "graphql-redis-subscriptions": "^2.2.1", - "graphql-shield": "~7.2.0", + "graphql-shield": "~7.0.14", "graphql-tag": "~2.10.3", "helmet": "~3.21.3", "ioredis": "^4.16.0", @@ -92,11 +92,11 @@ "neo4j-graphql-js": "^2.11.5", "neode": "^0.3.7", "node-fetch": "~2.6.0", - "nodemailer": "^6.4.5", + "nodemailer": "^6.4.4", "nodemailer-html-to-text": "^3.1.0", "npm-run-all": "~4.1.5", "request": "~2.88.2", - "sanitize-html": "~1.22.1", + "sanitize-html": "~1.22.0", "slug": "~2.1.1", "subscriptions-transport-ws": "^0.9.16", "trunc-html": "~1.1.2", diff --git a/backend/src/activitypub/ActivityPub.js b/backend/src/activitypub/ActivityPub.js index 1794c2d3b..c4ad7f4b3 100644 --- a/backend/src/activitypub/ActivityPub.js +++ b/backend/src/activitypub/ActivityPub.js @@ -7,7 +7,7 @@ import request from 'request' import NitroDataSource from './NitroDataSource' import router from './routes' import Collections from './Collections' -import uuid from 'uuid/v4' +import { v4 as uuid } from 'uuid' import CONFIG from '../config' const debug = require('debug')('ea') diff --git a/backend/src/db/factories.js b/backend/src/db/factories.js index 159a71a62..010ef67ad 100644 --- a/backend/src/db/factories.js +++ b/backend/src/db/factories.js @@ -4,9 +4,16 @@ import slugify from 'slug' import { hashSync } from 'bcryptjs' import { Factory } from 'rosie' import { getDriver, getNeode } from './neo4j' +import CONFIG from '../config/index.js' const neode = getNeode() +const uniqueImageUrl = imageUrl => { + const newUrl = new URL(imageUrl, CONFIG.CLIENT_URI) + newUrl.search = `random=${uuid()}` + return newUrl.toString() +} + export const cleanDatabase = async (options = {}) => { const { driver = getDriver() } = options const session = driver.session() @@ -39,14 +46,23 @@ Factory.define('badge') return neode.create('Badge', buildObject) }) -Factory.define('userWithoutEmailAddress') +Factory.define('image') + .attr('url', faker.image.unsplash.imageUrl) + .attr('aspectRatio', 1) + .attr('alt', faker.lorem.sentence) + .after((buildObject, options) => { + const { url: imageUrl } = buildObject + if (imageUrl) buildObject.url = uniqueImageUrl(imageUrl) + return neode.create('Image', buildObject) + }) + +Factory.define('basicUser') .option('password', '1234') .attrs({ id: uuid, name: faker.name.findName, password: '1234', role: 'user', - avatar: faker.internet.avatar, about: faker.lorem.paragraph, termsAndConditionsAgreedVersion: '0.0.1', termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z', @@ -60,19 +76,29 @@ Factory.define('userWithoutEmailAddress') .attr('encryptedPassword', ['password'], password => { return hashSync(password, 10) }) + +Factory.define('userWithoutEmailAddress') + .extend('basicUser') .after(async (buildObject, options) => { return neode.create('User', buildObject) }) Factory.define('user') - .extend('userWithoutEmailAddress') + .extend('basicUser') .option('email', faker.internet.exampleEmail) + .option('avatar', () => + Factory.build('image', { + url: faker.internet.avatar(), + }), + ) .after(async (buildObject, options) => { - const [user, email] = await Promise.all([ - buildObject, + const [user, email, avatar] = await Promise.all([ + neode.create('User', buildObject), neode.create('EmailAddress', { email: options.email }), + options.avatar, ]) await Promise.all([user.relateTo(email, 'primaryEmail'), email.relateTo(user, 'belongsTo')]) + if (avatar) await user.relateTo(avatar, 'avatar') return user }) @@ -93,11 +119,11 @@ Factory.define('post') return Factory.build('user') }) .option('pinnedBy', null) + .option('image', () => Factory.build('image')) .attrs({ id: uuid, title: faker.lorem.sentence, content: faker.lorem.paragraphs, - image: faker.image.unsplash.imageUrl, visibility: 'public', deleted: false, imageBlurred: false, @@ -117,9 +143,10 @@ Factory.define('post') return language || 'en' }) .after(async (buildObject, options) => { - const [post, author, categories, tags] = await Promise.all([ + const [post, author, image, categories, tags] = await Promise.all([ neode.create('Post', buildObject), options.author, + options.image, options.categories, options.tags, ]) @@ -128,6 +155,7 @@ Factory.define('post') Promise.all(categories.map(c => c.relateTo(post, 'post'))), Promise.all(tags.map(t => t.relateTo(post, 'post'))), ]) + if (image) await post.relateTo(image, 'image') if (buildObject.pinned) { const pinnedBy = await (options.pinnedBy || Factory.build('user', { role: 'admin' })) await pinnedBy.relateTo(post, 'pinned') diff --git a/backend/src/db/migrations/20200125010142-refactor_all_images_to_separate_type.js b/backend/src/db/migrations/20200125010142-refactor_all_images_to_separate_type.js new file mode 100644 index 000000000..634a0552e --- /dev/null +++ b/backend/src/db/migrations/20200125010142-refactor_all_images_to_separate_type.js @@ -0,0 +1,101 @@ +/* eslint-disable no-console */ +import { getDriver } from '../../db/neo4j' + +export const description = ` + Refactor all our image properties on posts and users to a dedicated type + "Image" which contains metadata and image file urls. +` + +const printSummaries = summaries => { + console.log('=========================================') + summaries.forEach(stat => { + console.log(stat.query.text) + console.log(JSON.stringify(stat.counters, null, 2)) + }) + console.log('=========================================') +} + +export async function up() { + const driver = getDriver() + const session = driver.session() + const writeTxResultPromise = session.writeTransaction(async txc => { + const runs = await Promise.all( + [ + ` + MATCH (post:Post) + WHERE post.image IS NOT NULL + CREATE (post)-[:HERO_IMAGE]->(image:Image) + SET + image.url = post.image, + image.sensitive = post.imageBlurred, + image.aspectRatio = post.imageAspectRatio + REMOVE + post.image, + post.imageBlurred, + post.imageAspectRatio + `, + ` + MATCH (user:User) + WHERE user.avatar IS NOT NULL + CREATE (user)-[:AVATAR_IMAGE]->(avatar:Image) + SET avatar.url = user.avatar + REMOVE user.avatar + `, + ` + MATCH (user:User) + WHERE user.coverImg IS NOT NULL + CREATE (user)-[:COVER_IMAGE]->(coverImage:Image) + SET coverImage.url = user.coverImg + REMOVE user.coverImg + `, + ].map(s => txc.run(s)), + ) + return runs.map(({ summary }) => summary) + }) + + try { + const stats = await writeTxResultPromise + console.log('Created image nodes from all user avatars and post images.') + printSummaries(stats) + } finally { + session.close() + } +} + +export async function down() { + const driver = getDriver() + const session = driver.session() + const writeTxResultPromise = session.writeTransaction(async txc => { + const runs = await Promise.all( + [ + ` + MATCH (post)-[:HERO_IMAGE]->(image:Image) + SET + post.image = image.url, + post.imageBlurred = image.sensitive, + post.imageAspectRatio = image.aspectRatio + DETACH DELETE image + `, + ` + MATCH(user)-[:AVATAR_IMAGE]->(avatar:Image) + SET user.avatar = avatar.url + DETACH DELETE avatar + `, + ` + MATCH(user)-[:COVER_IMAGE]->(coverImage:Image) + SET user.coverImg = coverImage.url + DETACH DELETE coverImage + `, + ].map(s => txc.run(s)), + ) + return runs.map(({ summary }) => summary) + }) + + try { + const stats = await writeTxResultPromise + console.log('UNDO: Split images from users and posts.') + printSummaries(stats) + } finally { + session.close() + } +} diff --git a/backend/src/db/migrations/20200213230248-add_unique_index_to_image_url.js b/backend/src/db/migrations/20200213230248-add_unique_index_to_image_url.js new file mode 100644 index 000000000..60d67432f --- /dev/null +++ b/backend/src/db/migrations/20200213230248-add_unique_index_to_image_url.js @@ -0,0 +1,53 @@ +import { getDriver } from '../../db/neo4j' + +export const description = ` + We introduced a new node label 'Image' and we need a primary key for it. Best + would probably be the 'url' property which should be unique and would also + prevent us from overwriting existing images. +` + +export async function up(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + CREATE CONSTRAINT ON ( image:Image ) ASSERT image.url IS UNIQUE + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + } finally { + session.close() + } +} + +export async function down(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + DROP CONSTRAINT ON ( image:Image ) ASSERT image.url IS UNIQUE + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + } finally { + session.close() + } +} diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index d1e430629..953f80b55 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -389,13 +389,15 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] { id: 'p0', language: sample(languages), - image: faker.image.unsplash.food(300, 169), - imageBlurred: true, - imageAspectRatio: 300 / 169, }, { categoryIds: ['cat16'], author: peterLustig, + image: Factory.build('image', { + url: faker.image.unsplash.food(300, 169), + sensitive: true, + aspectRatio: 300 / 169, + }), }, ), Factory.build( @@ -403,12 +405,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] { id: 'p1', language: sample(languages), - image: faker.image.unsplash.technology(300, 1500), - imageAspectRatio: 300 / 1500, }, { categoryIds: ['cat1'], author: bobDerBaumeister, + image: Factory.build('image', { + url: faker.image.unsplash.technology(300, 1500), + aspectRatio: 300 / 1500, + }), }, ), Factory.build( @@ -449,12 +453,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] { id: 'p6', language: sample(languages), - image: faker.image.unsplash.buildings(300, 857), - imageAspectRatio: 300 / 857, }, { categoryIds: ['cat6'], author: peterLustig, + image: Factory.build('image', { + url: faker.image.unsplash.buildings(300, 857), + aspectRatio: 300 / 857, + }), }, ), Factory.build( @@ -472,11 +478,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] 'post', { id: 'p10', - imageBlurred: true, }, { categoryIds: ['cat10'], author: dewey, + image: Factory.build('image', { + sensitive: true, + }), }, ), Factory.build( @@ -484,12 +492,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] { id: 'p11', language: sample(languages), - image: faker.image.unsplash.people(300, 901), - imageAspectRatio: 300 / 901, }, { categoryIds: ['cat11'], author: louie, + image: Factory.build('image', { + url: faker.image.unsplash.people(300, 901), + aspectRatio: 300 / 901, + }), }, ), Factory.build( @@ -508,12 +518,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] { id: 'p14', language: sample(languages), - image: faker.image.unsplash.objects(300, 200), - imageAspectRatio: 300 / 450, }, { categoryIds: ['cat14'], author: jennyRostock, + image: Factory.build('image', { + url: faker.image.unsplash.objects(300, 200), + aspectRatio: 300 / 450, + }), }, ), Factory.build( @@ -539,22 +551,8 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] const hashtagAndMention1 = 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' const createPostMutation = gql` - mutation( - $id: ID - $title: String! - $content: String! - $categoryIds: [ID] - $imageBlurred: Boolean - $imageAspectRatio: Float - ) { - CreatePost( - id: $id - title: $title - content: $content - categoryIds: $categoryIds - imageBlurred: $imageBlurred - imageAspectRatio: $imageAspectRatio - ) { + mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { + CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { id } } @@ -568,7 +566,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] title: `Nature Philosophy Yoga`, content: hashtag1, categoryIds: ['cat2'], - imageAspectRatio: 300 / 200, }, }), mutate({ @@ -578,7 +575,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] title: 'This is post #7', content: `${mention1} ${faker.lorem.paragraph()}`, categoryIds: ['cat7'], - imageAspectRatio: 300 / 180, }, }), mutate({ @@ -589,7 +585,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] title: `Quantum Flow Theory explains Quantum Gravity`, content: hashtagAndMention1, categoryIds: ['cat8'], - imageAspectRatio: 300 / 900, }, }), mutate({ @@ -599,7 +594,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] title: 'This is post #12', content: `${mention2} ${faker.lorem.paragraph()}`, categoryIds: ['cat12'], - imageAspectRatio: 300 / 200, }, }), ]) @@ -759,6 +753,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, ), ]) + const trollingComment = comments[0] await Promise.all([ @@ -939,12 +934,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] [...Array(30).keys()].map(() => Factory.build( 'post', - { - image: faker.image.unsplash.objects(), - }, + {}, { categoryIds: ['cat1'], author: jennyRostock, + image: Factory.build('image', { + url: faker.image.unsplash.objects(), + }), }, ), ), @@ -993,12 +989,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] [...Array(21).keys()].map(() => Factory.build( 'post', - { - image: faker.image.unsplash.buildings(), - }, + {}, { categoryIds: ['cat1'], author: peterLustig, + image: Factory.build('image', { + url: faker.image.unsplash.buildings(), + }), }, ), ), @@ -1047,12 +1044,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] [...Array(11).keys()].map(() => Factory.build( 'post', - { - image: faker.image.unsplash.food(), - }, + {}, { categoryIds: ['cat1'], author: dewey, + image: Factory.build('image', { + url: faker.image.unsplash.food(), + }), }, ), ), @@ -1101,12 +1099,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] [...Array(16).keys()].map(() => Factory.build( 'post', - { - image: faker.image.unsplash.technology(), - }, + {}, { categoryIds: ['cat1'], author: louie, + image: Factory.build('image', { + url: faker.image.unsplash.technology(), + }), }, ), ), @@ -1155,12 +1154,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] [...Array(45).keys()].map(() => Factory.build( 'post', - { - image: faker.image.unsplash.people(), - }, + {}, { categoryIds: ['cat1'], author: bobDerBaumeister, + image: Factory.build('image', { + url: faker.image.unsplash.people(), + }), }, ), ), @@ -1209,12 +1209,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] [...Array(8).keys()].map(() => Factory.build( 'post', - { - image: faker.image.unsplash.nature(), - }, + {}, { categoryIds: ['cat1'], author: huey, + image: Factory.build('image', { + url: faker.image.unsplash.nature(), + }), }, ), ), diff --git a/backend/src/jwt/decode.js b/backend/src/jwt/decode.js index 5433a8c76..8dbcb080d 100644 --- a/backend/src/jwt/decode.js +++ b/backend/src/jwt/decode.js @@ -15,10 +15,10 @@ export default async (driver, authorizationHeader) => { const writeTxResultPromise = session.writeTransaction(async transaction => { const updateUserLastActiveTransactionResponse = await transaction.run( - ` + ` MATCH (user:User {id: $id, deleted: false, disabled: false }) SET user.lastActiveAt = toString(datetime()) - RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId} + RETURN user {.id, .slug, .name, .role, .disabled, .actorId} LIMIT 1 `, { id }, diff --git a/backend/src/jwt/decode.spec.js b/backend/src/jwt/decode.spec.js index aa8ff0674..80dfe9733 100644 --- a/backend/src/jwt/decode.spec.js +++ b/backend/src/jwt/decode.spec.js @@ -69,23 +69,23 @@ describe('decode', () => { { role: 'user', name: 'Jenny Rostock', - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', id: 'u3', slug: 'jenny-rostock', }, { + image: Factory.build('image', { + url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', + }), email: 'user@example.org', }, ) }) - it('returns user object except email', async () => { + it('returns user object without email', async () => { await expect(decode(driver, authorizationHeader)).resolves.toMatchObject({ role: 'user', name: 'Jenny Rostock', - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', id: 'u3', - email: null, slug: 'jenny-rostock', }) }) diff --git a/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js b/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js index de5626d14..63569ddb0 100644 --- a/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js +++ b/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js @@ -28,14 +28,21 @@ beforeAll(async () => { password: '1234', }, ), - Factory.build('user', { - id: 'u2', - role: 'user', - name: 'Offensive Name', - slug: 'offensive-name', - avatar: '/some/offensive/avatar.jpg', - about: 'This self description is very offensive', - }), + Factory.build( + 'user', + { + id: 'u2', + role: 'user', + name: 'Offensive Name', + slug: 'offensive-name', + about: 'This self description is very offensive', + }, + { + avatar: Factory.build('image', { + url: '/some/offensive/avatar.jpg', + }), + }, + ), neode.create('Category', { id: 'cat9', name: 'Democracy & Politics', @@ -96,10 +103,12 @@ beforeAll(async () => { title: 'Disabled post', content: 'This is an offensive post content', contentExcerpt: 'This is an offensive post content', - image: '/some/offensive/image.jpg', deleted: false, }, { + image: Factory.build('image', { + url: '/some/offensive/image.jpg', + }), author: troll, categoryIds, }, @@ -213,7 +222,9 @@ describe('softDeleteMiddleware', () => { name slug about - avatar + avatar { + url + } } } } @@ -229,7 +240,9 @@ describe('softDeleteMiddleware', () => { contributions { title slug - image + image { + url + } content contentExcerpt } @@ -253,7 +266,10 @@ describe('softDeleteMiddleware', () => { it('displays slug', () => expect(subject.slug).toEqual('offensive-name')) it('displays about', () => expect(subject.about).toEqual('This self description is very offensive')) - it('displays avatar', () => expect(subject.avatar).toEqual('/some/offensive/avatar.jpg')) + it('displays avatar', () => + expect(subject.avatar).toEqual({ + url: expect.stringContaining('/some/offensive/avatar.jpg'), + })) }) describe('Post', () => { @@ -265,7 +281,10 @@ describe('softDeleteMiddleware', () => { expect(subject.content).toEqual('This is an offensive post content')) it('displays contentExcerpt', () => expect(subject.contentExcerpt).toEqual('This is an offensive post content')) - it('displays image', () => expect(subject.image).toEqual('/some/offensive/image.jpg')) + it('displays image', () => + expect(subject.image).toEqual({ + url: expect.stringContaining('/some/offensive/image.jpg'), + })) }) describe('Comment', () => { @@ -288,7 +307,7 @@ describe('softDeleteMiddleware', () => { it('obfuscates name', () => expect(subject.name).toEqual('UNAVAILABLE')) it('obfuscates slug', () => expect(subject.slug).toEqual('UNAVAILABLE')) it('obfuscates about', () => expect(subject.about).toEqual('UNAVAILABLE')) - it('obfuscates avatar', () => expect(subject.avatar).toEqual('UNAVAILABLE')) + it('obfuscates avatar', () => expect(subject.avatar).toEqual(null)) }) describe('Post', () => { diff --git a/backend/src/models/Image.js b/backend/src/models/Image.js new file mode 100644 index 000000000..19824b493 --- /dev/null +++ b/backend/src/models/Image.js @@ -0,0 +1,7 @@ +export default { + url: { primary: true, type: 'string', uri: { allowRelative: true } }, + alt: { type: 'string' }, + sensitive: { type: 'boolean', default: false }, + aspectRatio: { type: 'float', default: 1.0 }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, +} diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js index 15eedbf64..43f63ebd3 100644 --- a/backend/src/models/Post.js +++ b/backend/src/models/Post.js @@ -4,6 +4,12 @@ export default { id: { type: 'string', primary: true, default: uuid }, activityId: { type: 'string', allow: [null] }, objectId: { type: 'string', allow: [null] }, + image: { + type: 'relationship', + relationship: 'HERO_IMAGE', + target: 'Image', + direction: 'out', + }, author: { type: 'relationship', relationship: 'WROTE', @@ -14,7 +20,6 @@ export default { slug: { type: 'string', allow: [null], unique: 'true' }, content: { type: 'string', disallow: [null], min: 3 }, contentExcerpt: { type: 'string', allow: [null] }, - image: { type: 'string', allow: [null] }, deleted: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false }, notified: { @@ -39,8 +44,6 @@ export default { default: () => new Date().toISOString(), }, language: { type: 'string', allow: [null] }, - imageBlurred: { type: 'boolean', default: false }, - imageAspectRatio: { type: 'float', default: 1.0 }, comments: { type: 'relationship', relationship: 'COMMENTS', diff --git a/backend/src/models/User.js b/backend/src/models/User.js index d79fb79b9..ae7e1ae8c 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -6,8 +6,12 @@ export default { name: { type: 'string', disallow: [null], min: 3 }, slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true }, encryptedPassword: 'string', - avatar: { type: 'string', allow: [null] }, - coverImg: { type: 'string', allow: [null] }, + avatar: { + type: 'relationship', + relationship: 'AVATAR_IMAGE', + target: 'Image', + direction: 'out', + }, deleted: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false }, role: { type: 'string', default: 'user' }, diff --git a/backend/src/models/index.js b/backend/src/models/index.js index dbb6a927e..c53ef89ab 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -1,6 +1,7 @@ // NOTE: We cannot use `fs` here to clean up the code. Cypress breaks on any npm // module that is not browser-compatible. Node's `fs` module is server-side only export default { + Image: require('./Image.js').default, Badge: require('./Badge.js').default, User: require('./User.js').default, EmailAddress: require('./EmailAddress.js').default, diff --git a/backend/src/schema/resolvers/fileUpload/index.js b/backend/src/schema/resolvers/fileUpload/index.js deleted file mode 100644 index 3c41a5d11..000000000 --- a/backend/src/schema/resolvers/fileUpload/index.js +++ /dev/null @@ -1,28 +0,0 @@ -import { createWriteStream } from 'fs' -import path from 'path' -import slug from 'slug' -import { v4 as uuid } from 'uuid' - -const localFileUpload = async ({ createReadStream, uniqueFilename }) => { - await new Promise((resolve, reject) => - createReadStream() - .pipe(createWriteStream(`public${uniqueFilename}`)) - .on('finish', resolve) - .on('error', reject), - ) - return uniqueFilename -} - -export default async function fileUpload(params, { file, url }, uploadCallback = localFileUpload) { - const upload = params[file] - if (upload) { - const { createReadStream, filename } = await upload - const { name, ext } = path.parse(filename) - const uniqueFilename = `/uploads/${uuid()}-${slug(name)}${ext}` - const location = await uploadCallback({ createReadStream, uniqueFilename }) - delete params[file] - params[url] = location - } - - return params -} diff --git a/backend/src/schema/resolvers/fileUpload/spec.js b/backend/src/schema/resolvers/fileUpload/spec.js deleted file mode 100644 index fee0bf81b..000000000 --- a/backend/src/schema/resolvers/fileUpload/spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import fileUpload from '.' - -const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}' - -describe('fileUpload', () => { - let params - let uploadCallback - - beforeEach(() => { - params = { - uploadAttribute: { - filename: 'avatar.jpg', - mimetype: 'image/jpeg', - encoding: '7bit', - createReadStream: jest.fn(), - }, - } - uploadCallback = jest.fn(({ uniqueFilename }) => uniqueFilename) - }) - - 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(new RegExp(`^/uploads/${uuid}-avatar.jpg`)) - }) - - it('creates a url safe name', async () => { - params.uploadAttribute.filename = '/path/to/awkward?/ file-location/?foo- bar-avatar.jpg' - await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback) - expect(params.attribute).toMatch(new RegExp(`/uploads/${uuid}-foo-bar-avatar.jpg$`)) - }) - - 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, - ) - - const { attribute: second } = await fileUpload( - { - ...params, - }, - { file: 'uploadAttribute', url: 'attribute' }, - uploadCallback, - ) - expect(first).not.toEqual(second) - }) - }) - }) -}) diff --git a/backend/src/schema/resolvers/images.js b/backend/src/schema/resolvers/images.js new file mode 100644 index 000000000..8b3f4a3e8 --- /dev/null +++ b/backend/src/schema/resolvers/images.js @@ -0,0 +1,8 @@ +import Resolver from './helpers/Resolver' +export default { + Image: { + ...Resolver('Image', { + undefinedToNull: ['sensitive', 'alt', 'aspectRatio'], + }), + }, +} diff --git a/backend/src/schema/resolvers/images/images.js b/backend/src/schema/resolvers/images/images.js new file mode 100644 index 000000000..51bd16d7d --- /dev/null +++ b/backend/src/schema/resolvers/images/images.js @@ -0,0 +1,121 @@ +import path from 'path' +import { v4 as uuid } from 'uuid' +import slug from 'slug' +import { existsSync, unlinkSync, createWriteStream } from 'fs' +import { getDriver } from '../../../db/neo4j' +import { UserInputError } from 'apollo-server' + +// const widths = [34, 160, 320, 640, 1024] + +export async function deleteImage(resource, relationshipType, opts = {}) { + sanitizeRelationshipType(relationshipType) + const { transaction, deleteCallback } = opts + if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts) + const txResult = await transaction.run( + ` + MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(image:Image) + WITH image, image {.*} as imageProps + DETACH DELETE image + RETURN imageProps + `, + { resource }, + ) + const [image] = txResult.records.map(record => record.get('imageProps')) + // This behaviour differs from `mergeImage`. If you call `mergeImage` + // with metadata for an image that does not exist, it's an indicator + // of an error (so throw an error). If we bulk delete an image, it + // could very well be that there is no image for the resource. + if (image) deleteImageFile(image, deleteCallback) + return image +} + +export async function mergeImage(resource, relationshipType, imageInput, opts = {}) { + if (typeof imageInput === 'undefined') return + if (imageInput === null) return deleteImage(resource, relationshipType, opts) + sanitizeRelationshipType(relationshipType) + const { transaction, uploadCallback, deleteCallback } = opts + if (!transaction) + return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts) + + let txResult + txResult = await transaction.run( + ` + MATCH (resource {id: $resource.id})-[:${relationshipType}]->(image:Image) + RETURN image {.*} + `, + { resource }, + ) + const [existingImage] = txResult.records.map(record => record.get('image')) + const { upload } = imageInput + if (!(existingImage || upload)) throw new UserInputError('Cannot find image for given resource') + if (existingImage && upload) deleteImageFile(existingImage, deleteCallback) + const url = await uploadImageFile(upload, uploadCallback) + const { alt, sensitive, aspectRatio } = imageInput + const image = { alt, sensitive, aspectRatio, url } + txResult = await transaction.run( + ` + MATCH (resource {id: $resource.id}) + MERGE (resource)-[:${relationshipType}]->(image:Image) + ON CREATE SET image.createdAt = toString(datetime()) + ON MATCH SET image.updatedAt = toString(datetime()) + SET image += $image + RETURN image {.*} + `, + { resource, image }, + ) + const [mergedImage] = txResult.records.map(record => record.get('image')) + return mergedImage +} + +const wrapTransaction = async (wrappedCallback, args, opts) => { + const session = getDriver().session() + try { + const result = await session.writeTransaction(async transaction => { + return wrappedCallback(...args, { ...opts, transaction }) + }) + return result + } finally { + session.close() + } +} + +const deleteImageFile = (image, deleteCallback = localFileDelete) => { + const { url } = image + deleteCallback(url) + return url +} + +const uploadImageFile = async (upload, uploadCallback = localFileUpload) => { + if (!upload) return undefined + const { createReadStream, filename, mimetype } = await upload + const { name, ext } = path.parse(filename) + const uniqueFilename = `${uuid()}-${slug(name)}${ext}` + + return uploadCallback({ + createReadStream, + destination: `/uploads/${uniqueFilename}`, + mimetype, + }) +} + +const sanitizeRelationshipType = relationshipType => { + // Cypher query language does not allow to parameterize relationship types + // See: https://github.com/neo4j/neo4j/issues/340 + if (!['HERO_IMAGE', 'AVATAR_IMAGE'].includes(relationshipType)) { + throw new Error(`Unknown relationship type ${relationshipType}`) + } +} + +const localFileUpload = ({ createReadStream, destination }) => { + return new Promise((resolve, reject) => + createReadStream() + .pipe(createWriteStream(`public${destination}`)) + .on('finish', () => resolve(destination)) + .on('error', reject), + ) +} + +const localFileDelete = async url => { + const location = `public${url}` + if (existsSync(location)) unlinkSync(location) +} diff --git a/backend/src/schema/resolvers/images/images.spec.js b/backend/src/schema/resolvers/images/images.spec.js new file mode 100644 index 000000000..228394b01 --- /dev/null +++ b/backend/src/schema/resolvers/images/images.spec.js @@ -0,0 +1,344 @@ +import { deleteImage, mergeImage } from './images' +import { getNeode, getDriver } from '../../../db/neo4j' +import Factory, { cleanDatabase } from '../../../db/factories' +import { UserInputError } from 'apollo-server' + +const driver = getDriver() +const neode = getNeode() +const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}' +let uploadCallback +let deleteCallback + +beforeEach(async () => { + await cleanDatabase() + uploadCallback = jest.fn(({ destination }) => destination) + deleteCallback = jest.fn() +}) + +describe('deleteImage', () => { + describe('given a resource with an image', () => { + let user + beforeEach(async () => { + user = await Factory.build( + 'user', + {}, + { + avatar: Factory.build('image', { + url: '/some/avatar/url/', + alt: 'This is the avatar image of a user', + }), + }, + ) + user = await user.toJson() + }) + + it('soft deletes `Image` node', async () => { + await expect(neode.all('Image')).resolves.toHaveLength(1) + await deleteImage(user, 'AVATAR_IMAGE', { deleteCallback }) + await expect(neode.all('Image')).resolves.toHaveLength(0) + }) + + it('calls deleteCallback', async () => { + user = await Factory.build('user') + user = await user.toJson() + await deleteImage(user, 'AVATAR_IMAGE', { deleteCallback }) + expect(deleteCallback).toHaveBeenCalled() + }) + + describe('given a transaction parameter', () => { + it('executes cypher statements within the transaction', async () => { + const session = driver.session() + let someString + try { + someString = await session.writeTransaction(async transaction => { + await deleteImage(user, 'AVATAR_IMAGE', { + deleteCallback, + transaction, + }) + const txResult = await transaction.run('RETURN "Hello" as result') + const [result] = txResult.records.map(record => record.get('result')) + return result + }) + } finally { + session.close() + } + await expect(neode.all('Image')).resolves.toHaveLength(0) + await expect(someString).toEqual('Hello') + }) + + it('rolls back the transaction in case of errors', async done => { + await expect(neode.all('Image')).resolves.toHaveLength(1) + const session = driver.session() + try { + await session.writeTransaction(async transaction => { + await deleteImage(user, 'AVATAR_IMAGE', { + deleteCallback, + transaction, + }) + throw new Error('Ouch!') + }) + } catch (err) { + // nothing has been deleted + await expect(neode.all('Image')).resolves.toHaveLength(1) + // all good + done() + } finally { + session.close() + } + }) + }) + }) +}) + +describe('mergeImage', () => { + let imageInput + let post + beforeEach(() => { + imageInput = { + alt: 'A description of the new image', + } + }) + + describe('on existing resource', () => { + beforeEach(async () => { + post = await Factory.build( + 'post', + { id: 'p99' }, + { + author: Factory.build('user', {}, { avatar: null }), + image: null, + }, + ) + post = await post.toJson() + }) + + describe('given image.upload', () => { + beforeEach(() => { + imageInput = { + ...imageInput, + upload: { + filename: 'image.jpg', + mimetype: 'image/jpeg', + encoding: '7bit', + createReadStream: () => ({ + pipe: () => ({ + on: (_, callback) => callback(), + }), + }), + }, + } + }) + + it('returns new image', async () => { + await expect( + mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }), + ).resolves.toMatchObject({ + url: expect.any(String), + alt: 'A description of the new image', + }) + }) + + it('calls upload callback', async () => { + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + expect(uploadCallback).toHaveBeenCalled() + }) + + it('creates `:Image` node', async () => { + await expect(neode.all('Image')).resolves.toHaveLength(0) + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + await expect(neode.all('Image')).resolves.toHaveLength(1) + }) + + it('creates a url safe name', async () => { + imageInput.upload.filename = '/path/to/awkward?/ file-location/?foo- bar-avatar.jpg' + await expect( + mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }), + ).resolves.toMatchObject({ + url: expect.stringMatching(new RegExp(`^/uploads/${uuid}-foo-bar-avatar.jpg`)), + }) + }) + + it.skip('automatically creates different image sizes', async () => { + await expect( + mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }), + ).resolves.toEqual({ + url: expect.any(String), + alt: expect.any(String), + urlW34: expect.stringMatching(new RegExp(`^/uploads/W34/${uuid}-image.jpg`)), + urlW160: expect.stringMatching(new RegExp(`^/uploads/W160/${uuid}-image.jpg`)), + urlW320: expect.stringMatching(new RegExp(`^/uploads/W320/${uuid}-image.jpg`)), + urlW640: expect.stringMatching(new RegExp(`^/uploads/W640/${uuid}-image.jpg`)), + urlW1024: expect.stringMatching(new RegExp(`^/uploads/W1024/${uuid}-image.jpg`)), + }) + }) + + it('connects resource with image via given image type', async () => { + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + const result = await neode.cypher(` + MATCH(p:Post {id: "p99"})-[:HERO_IMAGE]->(i:Image) RETURN i,p + `) + post = neode.hydrateFirst(result, 'p', neode.model('Post')) + const image = neode.hydrateFirst(result, 'i', neode.model('Image')) + expect(post).toBeTruthy() + expect(image).toBeTruthy() + }) + + it('whitelists relationship types', async () => { + await expect( + mergeImage(post, 'WHATEVER', imageInput, { uploadCallback, deleteCallback }), + ).rejects.toEqual(new Error('Unknown relationship type WHATEVER')) + }) + + it('sets metadata', async () => { + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + const image = await neode.first('Image', {}) + await expect(image.toJson()).resolves.toMatchObject({ + alt: 'A description of the new image', + createdAt: expect.any(String), + url: expect.any(String), + }) + }) + + describe('given a transaction parameter', () => { + it('executes cypher statements within the transaction', async () => { + const session = driver.session() + try { + await session.writeTransaction(async transaction => { + const image = await mergeImage(post, 'HERO_IMAGE', imageInput, { + uploadCallback, + deleteCallback, + transaction, + }) + return transaction.run( + ` + MATCH(image:Image {url: $image.url}) + SET image.alt = 'This alt text gets overwritten' + RETURN image {.*} + `, + { image }, + ) + }) + } finally { + session.close() + } + const image = await neode.first('Image', { alt: 'This alt text gets overwritten' }) + await expect(image.toJson()).resolves.toMatchObject({ + alt: 'This alt text gets overwritten', + }) + }) + + it('rolls back the transaction in case of errors', async done => { + const session = driver.session() + try { + await session.writeTransaction(async transaction => { + const image = await mergeImage(post, 'HERO_IMAGE', imageInput, { + uploadCallback, + deleteCallback, + transaction, + }) + return transaction.run('Ooops invalid cypher!', { image }) + }) + } catch (err) { + // nothing has been created + await expect(neode.all('Image')).resolves.toHaveLength(0) + // all good + done() + } finally { + session.close() + } + }) + }) + + describe('if resource has an image already', () => { + beforeEach(async () => { + const [post, image] = await Promise.all([ + neode.find('Post', 'p99'), + Factory.build('image'), + ]) + await post.relateTo(image, 'image') + }) + + it('calls deleteCallback', async () => { + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + expect(deleteCallback).toHaveBeenCalled() + }) + + it('calls uploadCallback', async () => { + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + expect(uploadCallback).toHaveBeenCalled() + }) + + it('updates metadata of existing image node', async () => { + await expect(neode.all('Image')).resolves.toHaveLength(1) + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + await expect(neode.all('Image')).resolves.toHaveLength(1) + const image = await neode.first('Image', {}) + await expect(image.toJson()).resolves.toMatchObject({ + alt: 'A description of the new image', + createdAt: expect.any(String), + url: expect.any(String), + // TODO + // width: + // height: + }) + }) + }) + }) + }) + + describe('without image.upload', () => { + it('throws UserInputError', async () => { + post = await Factory.build('post', { id: 'p99' }, { image: null }) + post = await post.toJson() + await expect(mergeImage(post, 'HERO_IMAGE', imageInput)).rejects.toEqual( + new UserInputError('Cannot find image for given resource'), + ) + }) + + describe('if resource has an image already', () => { + beforeEach(async () => { + post = await Factory.build( + 'post', + { + id: 'p99', + }, + { + author: Factory.build( + 'user', + {}, + { + avatar: null, + }, + ), + image: Factory.build('image', { + alt: 'This is the previous, not updated image', + url: '/some/original/url', + }), + }, + ) + post = await post.toJson() + }) + + it('does not call deleteCallback', async () => { + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + expect(deleteCallback).not.toHaveBeenCalled() + }) + + it('does not call uploadCallback', async () => { + await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) + expect(uploadCallback).not.toHaveBeenCalled() + }) + + it('updates metadata', async () => { + await mergeImage(post, 'HERO_IMAGE', imageInput) + const images = await neode.all('Image') + expect(images).toHaveLength(1) + await expect(images.first().toJson()).resolves.toMatchObject({ + createdAt: expect.any(String), + url: expect.any(String), + alt: 'A description of the new image', + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 1d4c4bfaa..eb265e528 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid' import { neo4jgraphql } from 'neo4j-graphql-js' import { isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' -import fileUpload from './fileUpload' +import { mergeImage, deleteImage } from './images/images' import Resolver from './helpers/Resolver' import { filterForMutedUsers } from './helpers/filterForMutedUsers' @@ -77,14 +77,16 @@ export default { Mutation: { CreatePost: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params + const { image: imageInput } = params delete params.categoryIds - params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) + delete params.image params.id = params.id || uuid() const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async transaction => { const createPostTransactionResponse = await transaction.run( ` - CREATE (post:Post {params}) + CREATE (post:Post) + SET post += $params SET post.createdAt = toString(datetime()) SET post.updatedAt = toString(datetime()) WITH post @@ -94,14 +96,18 @@ export default { UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) - RETURN post + RETURN post {.*} `, { userId: context.user.id, categoryIds, params }, ) - return createPostTransactionResponse.records.map(record => record.get('post').properties) + const [post] = createPostTransactionResponse.records.map(record => record.get('post')) + if (imageInput) { + await mergeImage(post, 'HERO_IMAGE', imageInput, { transaction }) + } + return post }) try { - const [post] = await writeTxResultPromise + const post = await writeTxResultPromise return post } catch (e) { if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') @@ -113,8 +119,9 @@ export default { }, UpdatePost: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params + const { image: imageInput } = params delete params.categoryIds - params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) + delete params.image const session = context.driver.session() let updatePostCypher = ` MATCH (post:Post {id: $params.id}) @@ -142,7 +149,7 @@ export default { ` } - updatePostCypher += `RETURN post` + updatePostCypher += `RETURN post {.*}` const updatePostVariables = { categoryIds, params } try { const writeTxResultPromise = session.writeTransaction(async transaction => { @@ -150,9 +157,11 @@ export default { updatePostCypher, updatePostVariables, ) - return updatePostTransactionResponse.records.map(record => record.get('post').properties) + const [post] = updatePostTransactionResponse.records.map(record => record.get('post')) + await mergeImage(post, 'HERO_IMAGE', imageInput, { transaction }) + return post }) - const [post] = await writeTxResultPromise + const post = await writeTxResultPromise return post } finally { session.close() @@ -171,15 +180,16 @@ export default { SET post.contentExcerpt = 'UNAVAILABLE' SET post.title = 'UNAVAILABLE' SET comment.deleted = TRUE - REMOVE post.image - RETURN post + RETURN post {.*} `, { postId: args.id }, ) - return deletePostTransactionResponse.records.map(record => record.get('post').properties) + const [post] = deletePostTransactionResponse.records.map(record => record.get('post')) + await deleteImage(post, 'HERO_IMAGE', { transaction }) + return post }) try { - const [post] = await writeTxResultPromise + const post = await writeTxResultPromise return post } finally { session.close() @@ -311,16 +321,7 @@ export default { }, Post: { ...Resolver('Post', { - undefinedToNull: [ - 'activityId', - 'objectId', - 'image', - 'language', - 'pinnedAt', - 'pinned', - 'imageBlurred', - 'imageAspectRatio', - ], + undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'], hasMany: { tags: '-[:TAGGED]->(related:Tag)', categories: '-[:CATEGORIZED]->(related:Category)', @@ -331,6 +332,7 @@ export default { hasOne: { author: '<-[:WROTE]-(related:User)', pinnedBy: '<-[:PINNED]-(related:User)', + image: '-[:HERO_IMAGE]->(related:Image)', }, count: { commentsCount: diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 88a09843d..b24383fba 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -336,8 +336,14 @@ describe('CreatePost', () => { describe('UpdatePost', () => { let author, newlyCreatedPost const updatePostMutation = gql` - mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { - UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID], $image: ImageInput) { + UpdatePost( + id: $id + title: $title + content: $content + categoryIds: $categoryIds + image: $image + ) { id title content @@ -472,418 +478,471 @@ describe('UpdatePost', () => { ) }) }) + + describe('params.image', () => { + describe('is object', () => { + beforeEach(() => { + variables = { ...variables, image: { sensitive: true } } + }) + it('updates the image', async () => { + await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() + await mutate({ mutation: updatePostMutation, variables }) + await expect(neode.first('Image', { sensitive: true })).resolves.toBeTruthy() + }) + }) + + describe('is null', () => { + beforeEach(() => { + variables = { ...variables, image: null } + }) + it('deletes the image', async () => { + await expect(neode.all('Image')).resolves.toHaveLength(6) + await mutate({ mutation: updatePostMutation, variables }) + await expect(neode.all('Image')).resolves.toHaveLength(5) + }) + }) + + describe('is undefined', () => { + beforeEach(() => { + delete variables.image + }) + it('keeps the image unchanged', async () => { + await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() + await mutate({ mutation: updatePostMutation, variables }) + await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() + }) + }) + }) + }) +}) + +describe('pin posts', () => { + let author + const pinPostMutation = gql` + mutation($id: ID!) { + pinPost(id: $id) { + id + title + content + author { + name + slug + } + pinnedBy { + id + name + role + } + createdAt + updatedAt + pinnedAt + pinned + } + } + ` + beforeEach(async () => { + author = await Factory.build('user', { slug: 'the-author' }) + await Factory.build( + 'post', + { + id: 'p9876', + title: 'Old title', + content: 'Old content', + }, + { + author, + categoryIds, + }, + ) + variables = { + id: 'p9876', + } }) - describe('pin posts', () => { - const pinPostMutation = gql` - mutation($id: ID!) { - pinPost(id: $id) { - id - title - content - author { - name - slug - } - pinnedBy { - id - name - role - } - createdAt - updatedAt - pinnedAt - pinned - } - } - ` + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, + }) + }) + }) + + describe('ordinary users', () => { + it('throws authorization error', async () => { + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, + }) + }) + }) + + describe('moderators', () => { + let moderator beforeEach(async () => { - variables = { ...variables } + moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) + authenticatedUser = await moderator.toJson() }) - describe('unauthenticated', () => { - it('throws authorization error', async () => { - authenticatedUser = null - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], - data: { pinPost: null }, - }) + it('throws authorization error', async () => { + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { pinPost: null }, }) }) + }) - describe('ordinary users', () => { - it('throws authorization error', async () => { - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], - data: { pinPost: null }, - }) + describe('admins', () => { + let admin + beforeEach(async () => { + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), }) + authenticatedUser = await admin.toJson() }) - describe('moderators', () => { - let moderator + describe('are allowed to pin posts', () => { beforeEach(async () => { - moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) - authenticatedUser = await moderator.toJson() + await Factory.build( + 'post', + { + id: 'created-and-pinned-by-same-admin', + }, + { + author: admin, + }, + ) + variables = { ...variables, id: 'created-and-pinned-by-same-admin' } }) - it('throws authorization error', async () => { - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], - data: { pinPost: null }, - }) - }) - }) - - describe('admins', () => { - let admin - beforeEach(async () => { - admin = await user.update({ - role: 'admin', - name: 'Admin', - updatedAt: new Date().toISOString(), - }) - authenticatedUser = await admin.toJson() - }) - - describe('are allowed to pin posts', () => { - beforeEach(async () => { - await Factory.build( - 'post', - { + it('responds with the updated Post', async () => { + const expected = { + data: { + pinPost: { id: 'created-and-pinned-by-same-admin', - }, - { - author: admin, - }, - ) - variables = { ...variables, id: 'created-and-pinned-by-same-admin' } - }) - - it('responds with the updated Post', async () => { - const expected = { - data: { - pinPost: { - id: 'created-and-pinned-by-same-admin', - author: { - name: 'Admin', - }, - pinnedBy: { - id: 'current-user', - name: 'Admin', - role: 'admin', - }, + author: { + name: 'Admin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', }, }, - errors: undefined, - } + }, + errors: undefined, + } - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - - it('sets createdAt date for PINNED', async () => { - const expected = { - data: { - pinPost: { - id: 'created-and-pinned-by-same-admin', - pinnedAt: expect.any(String), - }, - }, - errors: undefined, - } - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - - it('sets redundant `pinned` property for performant ordering', async () => { - variables = { ...variables, id: 'created-and-pinned-by-same-admin' } - const expected = { - data: { pinPost: { pinned: true } }, - errors: undefined, - } - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) }) - describe('post created by another admin', () => { - let otherAdmin - beforeEach(async () => { - otherAdmin = await Factory.build('user', { - role: 'admin', - name: 'otherAdmin', - }) - authenticatedUser = await otherAdmin.toJson() - await Factory.build( - 'post', - { + it('sets createdAt date for PINNED', async () => { + const expected = { + data: { + pinPost: { + id: 'created-and-pinned-by-same-admin', + pinnedAt: expect.any(String), + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('sets redundant `pinned` property for performant ordering', async () => { + variables = { ...variables, id: 'created-and-pinned-by-same-admin' } + const expected = { + data: { pinPost: { pinned: true } }, + errors: undefined, + } + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('post created by another admin', () => { + let otherAdmin + beforeEach(async () => { + otherAdmin = await Factory.build('user', { + role: 'admin', + name: 'otherAdmin', + }) + authenticatedUser = await otherAdmin.toJson() + await Factory.build( + 'post', + { + id: 'created-by-one-admin-pinned-by-different-one', + }, + { + author: otherAdmin, + }, + ) + }) + + it('responds with the updated Post', async () => { + authenticatedUser = await admin.toJson() + variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' } + const expected = { + data: { + pinPost: { id: 'created-by-one-admin-pinned-by-different-one', - }, - { - author: otherAdmin, - }, - ) - }) - - it('responds with the updated Post', async () => { - authenticatedUser = await admin.toJson() - variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' } - const expected = { - data: { - pinPost: { - id: 'created-by-one-admin-pinned-by-different-one', - author: { - name: 'otherAdmin', - }, - pinnedBy: { - id: 'current-user', - name: 'Admin', - role: 'admin', - }, + author: { + name: 'otherAdmin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', }, }, - errors: undefined, - } + }, + errors: undefined, + } - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) }) + }) - describe('post created by another user', () => { - it('responds with the updated Post', async () => { - const expected = { - data: { - pinPost: { - id: 'p9876', - author: { - slug: 'the-author', - }, - pinnedBy: { - id: 'current-user', - name: 'Admin', - role: 'admin', - }, + describe('post created by another user', () => { + it('responds with the updated Post', async () => { + const expected = { + data: { + pinPost: { + id: 'p9876', + author: { + slug: 'the-author', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', }, }, - errors: undefined, - } + }, + errors: undefined, + } - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('pinned post already exists', () => { + let pinnedPost + beforeEach(async () => { + await Factory.build( + 'post', + { + id: 'only-pinned-post', + }, + { + author: admin, + }, + ) + await mutate({ mutation: pinPostMutation, variables }) + }) + + it('removes previous `pinned` attribute', async () => { + const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post' + pinnedPost = await neode.cypher(cypher) + expect(pinnedPost.records).toHaveLength(1) + variables = { ...variables, id: 'only-pinned-post' } + await mutate({ mutation: pinPostMutation, variables }) + pinnedPost = await neode.cypher(cypher) + expect(pinnedPost.records).toHaveLength(1) + }) + + it('removes previous PINNED relationship', async () => { + variables = { ...variables, id: 'only-pinned-post' } + await mutate({ mutation: pinPostMutation, variables }) + pinnedPost = await neode.cypher( + `MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`, + ) + expect(pinnedPost.records).toHaveLength(1) + }) + }) + + describe('PostOrdering', () => { + beforeEach(async () => { + await Factory.build('post', { + id: 'im-a-pinned-post', + createdAt: '2019-11-22T17:26:29.070Z', + pinned: true, + }) + await Factory.build('post', { + id: 'i-was-created-before-pinned-post', + // fairly old, so this should be 3rd + createdAt: '2019-10-22T17:26:29.070Z', }) }) - describe('pinned post already exists', () => { - let pinnedPost - beforeEach(async () => { - await Factory.build( - 'post', - { - id: 'only-pinned-post', - }, - { - author: admin, - }, - ) - await mutate({ mutation: pinPostMutation, variables }) + describe('order by `pinned_asc` and `createdAt_desc`', () => { + beforeEach(() => { + // this is the ordering in the frontend + variables = { orderBy: ['pinned_asc', 'createdAt_desc'] } }) - it('removes previous `pinned` attribute', async () => { - const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post' - pinnedPost = await neode.cypher(cypher) - expect(pinnedPost.records).toHaveLength(1) - variables = { ...variables, id: 'only-pinned-post' } - await mutate({ mutation: pinPostMutation, variables }) - pinnedPost = await neode.cypher(cypher) - expect(pinnedPost.records).toHaveLength(1) - }) - - it('removes previous PINNED relationship', async () => { - variables = { ...variables, id: 'only-pinned-post' } - await mutate({ mutation: pinPostMutation, variables }) - pinnedPost = await neode.cypher( - `MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`, - ) - expect(pinnedPost.records).toHaveLength(1) - }) - }) - - describe('PostOrdering', () => { - beforeEach(async () => { - await Factory.build('post', { - id: 'im-a-pinned-post', - createdAt: '2019-11-22T17:26:29.070Z', - pinned: true, - }) - await Factory.build('post', { - id: 'i-was-created-before-pinned-post', - // fairly old, so this should be 3rd - createdAt: '2019-10-22T17:26:29.070Z', - }) - }) - - describe('order by `pinned_asc` and `createdAt_desc`', () => { - beforeEach(() => { - // this is the ordering in the frontend - variables = { orderBy: ['pinned_asc', 'createdAt_desc'] } - }) - - it('pinned post appear first even when created before other posts', async () => { - const postOrderingQuery = gql` - query($orderBy: [_PostOrdering]) { - Post(orderBy: $orderBy) { - id - pinned - createdAt - pinnedAt - } + it('pinned post appear first even when created before other posts', async () => { + const postOrderingQuery = gql` + query($orderBy: [_PostOrdering]) { + Post(orderBy: $orderBy) { + id + pinned + createdAt + pinnedAt } - ` - await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({ - data: { - Post: [ - { - id: 'im-a-pinned-post', - pinned: true, - createdAt: '2019-11-22T17:26:29.070Z', - pinnedAt: expect.any(String), - }, - { - id: 'p9876', - pinned: null, - createdAt: expect.any(String), - pinnedAt: null, - }, - { - id: 'i-was-created-before-pinned-post', - pinned: null, - createdAt: '2019-10-22T17:26:29.070Z', - pinnedAt: null, - }, - ], - }, - errors: undefined, - }) + } + ` + await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({ + data: { + Post: [ + { + id: 'im-a-pinned-post', + pinned: true, + createdAt: '2019-11-22T17:26:29.070Z', + pinnedAt: expect.any(String), + }, + { + id: 'p9876', + pinned: null, + createdAt: expect.any(String), + pinnedAt: null, + }, + { + id: 'i-was-created-before-pinned-post', + pinned: null, + createdAt: '2019-10-22T17:26:29.070Z', + pinnedAt: null, + }, + ], + }, + errors: undefined, }) }) }) }) }) +}) - describe('unpin posts', () => { - const unpinPostMutation = gql` - mutation($id: ID!) { - unpinPost(id: $id) { +describe('unpin posts', () => { + let pinnedPost + const unpinPostMutation = gql` + mutation($id: ID!) { + unpinPost(id: $id) { + id + title + content + author { + name + slug + } + pinnedBy { id - title - content - author { - name - slug - } - pinnedBy { - id - name - role - } - createdAt - updatedAt - pinned - pinnedAt + name + role } + createdAt + updatedAt + pinned + pinnedAt } - ` + } + ` + beforeEach(async () => { + pinnedPost = await Factory.build('post', { id: 'post-to-be-unpinned' }) + variables = { + id: 'post-to-be-unpinned', + } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, + }) + }) + }) + + describe('users cannot unpin posts', () => { + it('throws authorization error', async () => { + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, + }) + }) + }) + + describe('moderators cannot unpin posts', () => { + let moderator beforeEach(async () => { - variables = { ...variables } + moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) + authenticatedUser = await moderator.toJson() }) - describe('unauthenticated', () => { - it('throws authorization error', async () => { - authenticatedUser = null - await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], - data: { unpinPost: null }, - }) + it('throws authorization error', async () => { + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + data: { unpinPost: null }, }) }) + }) - describe('users cannot unpin posts', () => { - it('throws authorization error', async () => { - await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], - data: { unpinPost: null }, - }) + describe('admin can unpin posts', () => { + let admin + beforeEach(async () => { + admin = await user.update({ + role: 'admin', + name: 'Admin', + updatedAt: new Date().toISOString(), }) + authenticatedUser = await admin.toJson() + await admin.relateTo(pinnedPost, 'pinned', { createdAt: new Date().toISOString() }) }) - describe('moderators cannot unpin posts', () => { - let moderator - beforeEach(async () => { - moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) - authenticatedUser = await moderator.toJson() - }) - - it('throws authorization error', async () => { - await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], - data: { unpinPost: null }, - }) - }) - }) - - describe('admin can unpin posts', () => { - let admin, pinnedPost - beforeEach(async () => { - pinnedPost = await Factory.build('post', { id: 'post-to-be-unpinned' }) - admin = await user.update({ - role: 'admin', - name: 'Admin', - updatedAt: new Date().toISOString(), - }) - authenticatedUser = await admin.toJson() - await admin.relateTo(pinnedPost, 'pinned', { createdAt: new Date().toISOString() }) - variables = { ...variables, id: 'post-to-be-unpinned' } - }) - - it('responds with the unpinned Post', async () => { - authenticatedUser = await admin.toJson() - const expected = { - data: { - unpinPost: { - id: 'post-to-be-unpinned', - pinnedBy: null, - pinnedAt: null, - }, + it('responds with the unpinned Post', async () => { + authenticatedUser = await admin.toJson() + const expected = { + data: { + unpinPost: { + id: 'post-to-be-unpinned', + pinnedBy: null, + pinnedAt: null, }, - errors: undefined, - } + }, + errors: undefined, + } - await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) - it('unsets `pinned` property', async () => { - const expected = { - data: { - unpinPost: { - id: 'post-to-be-unpinned', - pinned: null, - }, + it('unsets `pinned` property', async () => { + const expected = { + data: { + unpinPost: { + id: 'post-to-be-unpinned', + pinned: null, }, - errors: undefined, - } - await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) + }, + errors: undefined, + } + await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject( + expected, + ) }) }) }) @@ -897,7 +956,9 @@ describe('DeletePost', () => { deleted content contentExcerpt - image + image { + url + } comments { deleted content @@ -915,9 +976,11 @@ describe('DeletePost', () => { id: 'p4711', title: 'I will be deleted', content: 'To be deleted', - image: 'path/to/some/image', }, { + image: Factory.build('image', { + url: 'path/to/some/image', + }), author, categoryIds, }, diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js index 1e7708395..921570e5d 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -1,11 +1,9 @@ import { UserInputError } from 'apollo-server' import { getNeode } from '../../db/neo4j' -import fileUpload from './fileUpload' import encryptPassword from '../../helpers/encryptPassword' import generateNonce from './helpers/generateNonce' import existingEmailAddress from './helpers/existingEmailAddress' import normalizeEmail from './helpers/normalizeEmail' -import createOrUpdateLocations from './users/location' const neode = getNeode() @@ -24,8 +22,6 @@ export default { } }, SignupVerification: async (_parent, args, context) => { - const { driver } = context - const session = driver.session() const { termsAndConditionsAgreedVersion } = args const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) if (!regEx.test(termsAndConditionsAgreedVersion)) { @@ -35,27 +31,39 @@ export default { let { nonce, email } = args email = normalizeEmail(email) - const result = await neode.cypher( - ` - MATCH(email:EmailAddress {nonce: {nonce}, email: {email}}) - WHERE NOT (email)-[:BELONGS_TO]->() - RETURN email - `, - { nonce, email }, - ) - const emailAddress = await neode.hydrateFirst(result, 'email', neode.model('EmailAddress')) - if (!emailAddress) throw new UserInputError('Invalid email or nonce') - args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' }) - args = await encryptPassword(args) + delete args.nonce + delete args.email + args = encryptPassword(args) + + const { driver } = context + const session = driver.session() + const writeTxResultPromise = session.writeTransaction(async transaction => { + const createUserTransactionResponse = await transaction.run( + ` + MATCH(email:EmailAddress {nonce: $nonce, email: $email}) + WHERE NOT (email)-[:BELONGS_TO]->() + CREATE (user:User) + MERGE(user)-[:PRIMARY_EMAIL]->(email) + MERGE(user)<-[:BELONGS_TO]-(email) + SET user += $args + SET user.id = randomUUID() + SET user.role = 'user' + SET user.createdAt = toString(datetime()) + SET user.updatedAt = toString(datetime()) + SET user.allowEmbedIframes = FALSE + SET user.showShoutsPublicly = FALSE + SET email.verifiedAt = toString(datetime()) + RETURN user {.*} + `, + { args, nonce, email }, + ) + const [user] = createUserTransactionResponse.records.map(record => record.get('user')) + if (!user) throw new UserInputError('Invalid email or nonce') + return user + }) try { - const user = await neode.create('User', args) - await Promise.all([ - user.relateTo(emailAddress, 'primaryEmail'), - emailAddress.relateTo(user, 'belongsTo'), - emailAddress.update({ verifiedAt: new Date().toISOString() }), - ]) - await createOrUpdateLocations(args.id, args.locationName, session) - return user.toJson() + const user = await writeTxResultPromise + return user } catch (e) { if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') throw new UserInputError('User with this slug already exists!') diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index 2014b01b8..dd081321d 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -48,7 +48,7 @@ export default { const loginTransactionResponse = await transaction.run( ` MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail}) - RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1 + RETURN user {.id, .slug, .name, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1 `, { userEmail: email }, ) diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index 1e295638d..399e2ace3 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -106,7 +106,9 @@ describe('currentUser', () => { id slug name - avatar + avatar { + url + } email role } @@ -131,13 +133,15 @@ describe('currentUser', () => { { id: 'u3', // the `id` is the only thing that has to match the decoded JWT bearer token - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', name: 'Matilde Hermiston', slug: 'matilde-hermiston', role: 'user', }, { email: 'test@example.org', + avatar: Factory.build('image', { + url: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', + }), }, ) const userBearerToken = encode({ id: 'u3' }) @@ -149,7 +153,9 @@ describe('currentUser', () => { data: { currentUser: { id: 'u3', - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', + avatar: Factory.build('image', { + url: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', + }), email: 'test@example.org', name: 'Matilde Hermiston', slug: 'matilde-hermiston', diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index c29b021be..252265ac3 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -1,7 +1,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js' -import fileUpload from './fileUpload' import { getNeode } from '../../db/neo4j' import { UserInputError, ForbiddenError } from 'apollo-server' +import { mergeImage, deleteImage } from './images/images' import Resolver from './helpers/Resolver' import log from './helpers/databaseLogger' import createOrUpdateLocations from './users/location' @@ -140,6 +140,8 @@ export default { }, UpdateUser: async (_parent, params, context, _resolveInfo) => { const { termsAndConditionsAgreedVersion } = params + const { avatar: avatarInput } = params + delete params.avatar if (termsAndConditionsAgreedVersion) { const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) if (!regEx.test(termsAndConditionsAgreedVersion)) { @@ -147,7 +149,6 @@ export default { } params.termsAndConditionsAgreedAt = new Date().toISOString() } - params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' }) const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async transaction => { @@ -156,14 +157,18 @@ export default { MATCH (user:User {id: $params.id}) SET user += $params SET user.updatedAt = toString(datetime()) - RETURN user + RETURN user {.*} `, { params }, ) - return updateUserTransactionResponse.records.map(record => record.get('user').properties) + const [user] = updateUserTransactionResponse.records.map(record => record.get('user')) + if (avatarInput) { + await mergeImage(user, 'AVATAR_IMAGE', avatarInput, { transaction }) + } + return user }) try { - const [user] = await writeTxResultPromise + const user = await writeTxResultPromise await createOrUpdateLocations(params.id, params.locationName, session) return user } catch (error) { @@ -173,34 +178,38 @@ export default { } }, DeleteUser: async (object, params, context, resolveInfo) => { - const { resource } = params + const { resource, id: userId } = params const session = context.driver.session() - const { id: userId } = params - try { + + const deleteUserTxResultPromise = session.writeTransaction(async transaction => { if (resource && resource.length) { - await session.writeTransaction(transaction => { - resource.map(node => { - return transaction.run( + await Promise.all( + resource.map(async node => { + const txResult = await transaction.run( ` - MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) - OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment) - SET resource.deleted = true - SET resource.content = 'UNAVAILABLE' - SET resource.contentExcerpt = 'UNAVAILABLE' - SET comment.deleted = true - RETURN author - `, + MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) + OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment) + SET resource.deleted = true + SET resource.content = 'UNAVAILABLE' + SET resource.contentExcerpt = 'UNAVAILABLE' + SET comment.deleted = true + RETURN resource {.*} + `, { userId, }, ) - }) - }) + return Promise.all( + txResult.records + .map(record => record.get('resource')) + .map(resource => deleteImage(resource, 'HERO_IMAGE', { transaction })), + ) + }), + ) } - const deleteUserTxResultPromise = session.writeTransaction(async transaction => { - const deleteUserTransactionResponse = await transaction.run( - ` + const deleteUserTransactionResponse = await transaction.run( + ` MATCH (user:User {id: $userId}) SET user.deleted = true SET user.name = 'UNAVAILABLE' @@ -211,14 +220,17 @@ export default { WITH user OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia) DETACH DELETE socialMedia - RETURN user + RETURN user {.*} `, - { userId }, - ) - log(deleteUserTransactionResponse) - return deleteUserTransactionResponse.records.map(record => record.get('user').properties) - }) - const [user] = await deleteUserTxResultPromise + { userId }, + ) + log(deleteUserTransactionResponse) + const [user] = deleteUserTransactionResponse.records.map(record => record.get('user')) + await deleteImage(user, 'AVATAR_IMAGE', { transaction }) + return user + }) + try { + const user = await deleteUserTxResultPromise return user } finally { session.close() @@ -237,8 +249,6 @@ export default { ...Resolver('User', { undefinedToNull: [ 'actorId', - 'avatar', - 'coverImg', 'deleted', 'disabled', 'locationName', @@ -272,6 +282,7 @@ export default { badgesCount: '<-[:REWARDED]-(related:Badge)', }, hasOne: { + avatar: '-[:AVATAR_IMAGE]->(related:Image)', invitedBy: '<-[:INVITED]-(related:User)', location: '-[:IS_IN]->(related:Location)', }, diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 892d2b4b4..cb9012133 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -29,7 +29,7 @@ beforeAll(() => { mutate = createTestClient(server).mutate }) -afterEach(async () => { +beforeEach(async () => { await cleanDatabase() }) @@ -495,6 +495,12 @@ describe('DeleteUser', () => { mutate({ mutation: deleteUserMutation, variables }), ).resolves.toMatchObject(expectedResponse) }) + + it('deletes user avatar and post hero images', async () => { + await expect(neode.all('Image')).resolves.toHaveLength(22) + await mutate({ mutation: deleteUserMutation, variables }) + await expect(neode.all('Image')).resolves.toHaveLength(20) + }) }) }) @@ -785,6 +791,12 @@ describe('DeleteUser', () => { mutate({ mutation: deleteUserMutation, variables }), ).resolves.toMatchObject(expectedResponse) }) + + it('deletes user avatar and post hero images', async () => { + await expect(neode.all('Image')).resolves.toHaveLength(22) + await mutate({ mutation: deleteUserMutation, variables }) + await expect(neode.all('Image')).resolves.toHaveLength(20) + }) }) }) @@ -834,7 +846,6 @@ describe('DeleteUser', () => { ).resolves.toMatchObject(expectedResponse) }) }) - describe('deletion of all post and comments requested', () => { beforeEach(() => { variables = { ...variables, resource: ['Post', 'Comment'] } @@ -882,27 +893,27 @@ describe('DeleteUser', () => { }) }) }) + }) + }) - describe('connected `EmailAddress` nodes', () => { - it('will be removed completely', async () => { - await expect(neode.all('EmailAddress')).resolves.toHaveLength(2) - await mutate({ mutation: deleteUserMutation, variables }) - await expect(neode.all('EmailAddress')).resolves.toHaveLength(1) - }) - }) + describe('connected `EmailAddress` nodes', () => { + it('will be removed completely', async () => { + await expect(neode.all('EmailAddress')).resolves.toHaveLength(2) + await mutate({ mutation: deleteUserMutation, variables }) + await expect(neode.all('EmailAddress')).resolves.toHaveLength(1) + }) + }) - describe('connected `SocialMedia` nodes', () => { - beforeEach(async () => { - const socialMedia = await Factory.build('socialMedia') - await socialMedia.relateTo(user, 'ownedBy') - }) + describe('connected `SocialMedia` nodes', () => { + beforeEach(async () => { + const socialMedia = await Factory.build('socialMedia') + await socialMedia.relateTo(user, 'ownedBy') + }) - it('will be removed completely', async () => { - await expect(neode.all('SocialMedia')).resolves.toHaveLength(1) - await mutate({ mutation: deleteUserMutation, variables }) - await expect(neode.all('SocialMedia')).resolves.toHaveLength(0) - }) - }) + it('will be removed completely', async () => { + await expect(neode.all('SocialMedia')).resolves.toHaveLength(1) + await mutate({ mutation: deleteUserMutation, variables }) + await expect(neode.all('SocialMedia')).resolves.toHaveLength(0) }) }) }) diff --git a/backend/src/schema/resolvers/users/location.spec.js b/backend/src/schema/resolvers/users/location.spec.js index 04216dcb5..2e74e5c03 100644 --- a/backend/src/schema/resolvers/users/location.spec.js +++ b/backend/src/schema/resolvers/users/location.spec.js @@ -8,28 +8,6 @@ const neode = getNeode() const driver = getDriver() let authenticatedUser, mutate, variables -const signupVerificationMutation = gql` - mutation( - $name: String! - $password: String! - $email: String! - $nonce: String! - $termsAndConditionsAgreedVersion: String! - $locationName: String - ) { - SignupVerification( - name: $name - password: $password - email: $email - nonce: $nonce - termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion - locationName: $locationName - ) { - locationName - } - } -` - const updateUserMutation = gql` mutation($id: ID!, $name: String!, $locationName: String) { UpdateUser(id: $id, name: $name, locationName: $locationName) { @@ -38,9 +16,10 @@ const updateUserMutation = gql` } ` -let newlyCreatedNodesWithLocales = [ +const newlyCreatedNodesWithLocales = [ { city: { + lng: -74.5763, lat: 41.1534, nameES: 'Hamburg', nameFR: 'Hamburg', @@ -54,7 +33,6 @@ let newlyCreatedNodesWithLocales = [ name: 'Hamburg', namePL: 'Hamburg', id: 'place.5977106083398860', - lng: -74.5763, }, state: { namePT: 'Nova Jérsia', @@ -105,82 +83,12 @@ beforeEach(() => { authenticatedUser = null }) -afterEach(() => { - cleanDatabase() -}) +afterEach(cleanDatabase) describe('userMiddleware', () => { - describe('SignupVerification', () => { - beforeEach(async () => { - variables = { - ...variables, - name: 'John Doe', - password: '123', - email: 'john@example.org', - nonce: '123456', - termsAndConditionsAgreedVersion: '0.1.0', - locationName: 'Hamburg, New Jersey, United States of America', - } - const args = { - email: 'john@example.org', - nonce: '123456', - } - await neode.model('EmailAddress').create(args) - }) - it('creates a Location node with localised city/state/country names', async () => { - await mutate({ mutation: signupVerificationMutation, variables }) - const locations = await neode.cypher( - `MATCH (city:Location)-[:IS_IN]->(state:Location)-[:IS_IN]->(country:Location) return city, state, country`, - ) - expect( - locations.records.map(record => { - return { - city: record.get('city').properties, - state: record.get('state').properties, - country: record.get('country').properties, - } - }), - ).toEqual(newlyCreatedNodesWithLocales) - }) - }) - describe('UpdateUser', () => { let user beforeEach(async () => { - newlyCreatedNodesWithLocales = [ - { - city: { - lat: 53.55, - nameES: 'Hamburgo', - nameFR: 'Hambourg', - nameIT: 'Amburgo', - nameEN: 'Hamburg', - type: 'region', - namePT: 'Hamburgo', - nameRU: 'Гамбург', - nameDE: 'Hamburg', - nameNL: 'Hamburg', - namePL: 'Hamburg', - name: 'Hamburg', - id: 'region.10793468240398860', - lng: 10, - }, - country: { - namePT: 'Alemanha', - nameRU: 'Германия', - nameDE: 'Deutschland', - nameNL: 'Duitsland', - nameES: 'Alemania', - name: 'Germany', - namePL: 'Niemcy', - nameFR: 'Allemagne', - nameIT: 'Germania', - id: 'country.10743216036480410', - nameEN: 'Germany', - type: 'country', - }, - }, - ] user = await Factory.build('user', { id: 'updating-user', }) @@ -192,17 +100,18 @@ describe('userMiddleware', () => { ...variables, id: 'updating-user', name: 'Updating user', - locationName: 'Hamburg, Germany', + locationName: 'Hamburg, New Jersey, United States of America', } await mutate({ mutation: updateUserMutation, variables }) const locations = await neode.cypher( - `MATCH (city:Location)-[:IS_IN]->(country:Location) return city, country`, + `MATCH (city:Location)-[:IS_IN]->(state:Location)-[:IS_IN]->(country:Location) return city {.*}, state {.*}, country {.*}`, ) expect( locations.records.map(record => { return { - city: record.get('city').properties, - country: record.get('country').properties, + city: record.get('city'), + state: record.get('state'), + country: record.get('country'), } }), ).toEqual(newlyCreatedNodesWithLocales) diff --git a/backend/src/schema/types/type/EmailAddress.gql b/backend/src/schema/types/type/EmailAddress.gql index 99e309602..e09ec9e63 100644 --- a/backend/src/schema/types/type/EmailAddress.gql +++ b/backend/src/schema/types/type/EmailAddress.gql @@ -9,14 +9,10 @@ type Mutation { SignupByInvitation(email: String!, token: String!): EmailAddress SignupVerification( nonce: String! - name: String! email: String! + name: String! password: String! slug: String - avatar: String - coverImg: String - avatarUpload: Upload - locationName: String about: String termsAndConditionsAgreedVersion: String! locale: String diff --git a/backend/src/schema/types/type/Image.gql b/backend/src/schema/types/type/Image.gql new file mode 100644 index 000000000..41cc11eef --- /dev/null +++ b/backend/src/schema/types/type/Image.gql @@ -0,0 +1,18 @@ +type Image { + url: ID!, + # urlW34: String, + # urlW160: String, + # urlW320: String, + # urlW640: String, + # urlW1024: String, + alt: String, + sensitive: Boolean, + aspectRatio: Float, +} + +input ImageInput { + alt: String, + upload: Upload, + sensitive: Boolean, + aspectRatio: Float, +} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 71fcb9605..01d8409ad 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -40,7 +40,6 @@ input _PostFilter { content_not_starts_with: String content_ends_with: String content_not_ends_with: String - image: String visibility: Visibility visibility_not: Visibility visibility_in: [Visibility!] @@ -82,7 +81,6 @@ input _PostFilter { emotions_none: _PostEMOTEDFilter emotions_single: _PostEMOTEDFilter emotions_every: _PostEMOTEDFilter - imageBlurred: Boolean } enum _PostOrdering { @@ -94,8 +92,6 @@ enum _PostOrdering { slug_desc content_asc content_desc - image_asc - image_desc visibility_asc visibility_desc createdAt_asc @@ -118,9 +114,7 @@ type Post { slug: String! content: String! contentExcerpt: String - image: String - imageUpload: Upload - imageAspectRatio: Float + image: Image @relation(name: "HERO_IMAGE", direction: "OUT") visibility: Visibility deleted: Boolean disabled: Boolean @@ -128,7 +122,6 @@ type Post { createdAt: String updatedAt: String language: String - imageBlurred: Boolean pinnedAt: String @cypher( statement: "MATCH (this)<-[pinned:PINNED]-(:User) WHERE NOT this.deleted = true AND NOT this.disabled = true RETURN pinned.createdAt" ) @@ -178,14 +171,11 @@ type Mutation { title: String! slug: String content: String! - image: String - imageUpload: Upload + image: ImageInput, visibility: Visibility language: String categoryIds: [ID] contentExcerpt: String - imageBlurred: Boolean - imageAspectRatio: Float ): Post UpdatePost( id: ID! @@ -193,13 +183,10 @@ type Mutation { slug: String content: String! contentExcerpt: String - image: String - imageUpload: Upload + image: ImageInput, visibility: Visibility language: String categoryIds: [ID] - imageBlurred: Boolean - imageAspectRatio: Float ): Post DeletePost(id: ID!): Post AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 4c3555049..af525396b 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -5,10 +5,6 @@ enum _UserOrdering { name_desc slug_asc slug_desc - avatar_asc - avatar_desc - coverImg_asc - coverImg_desc role_asc role_desc locationName_asc @@ -29,8 +25,7 @@ type User { name: String email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email") slug: String! - avatar: String - coverImg: String + avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT") deleted: Boolean disabled: Boolean role: UserGroup! @@ -161,8 +156,6 @@ type Query { email: String # admins need to search for a user sometimes name: String slug: String - avatar: String - coverImg: String role: UserGroup locationName: String about: String @@ -198,9 +191,7 @@ type Mutation { name: String email: String slug: String - avatar: String - coverImg: String - avatarUpload: Upload + avatar: ImageInput locationName: String about: String termsAndConditionsAgreedVersion: String diff --git a/backend/yarn.lock b/backend/yarn.lock index 5b6e98b64..c938959ce 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -4498,14 +4498,14 @@ graphql-redis-subscriptions@^2.2.1: optionalDependencies: ioredis "^4.6.3" -graphql-shield@~7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.2.0.tgz#81b26794370608ad78dfe3833473789fb471fbd8" - integrity sha512-eLdD+gUIKYu77XRcuHs5ewZhiBuRFeWFGxPnJa+g9AkxB7Yi5RSEjEJEx0Drg9GuNvDYpHeW7nPff4v35AT2aQ== +graphql-shield@~7.0.14: + version "7.0.14" + resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.0.14.tgz#3cbbf2722f2e3393fed7f47d866a1324bc3ce76a" + integrity sha512-YVedaL+4pITisSGRqMVeGX8ydOLSTQlHQN6o0Jly7z2cSy1wOzGJIRpfofETJtGLhBnPHHy1otINzuAyjGJO/g== dependencies: "@types/yup" "0.26.32" object-hash "^2.0.3" - yup "^0.28.3" + yup "^0.28.1" graphql-subscriptions@^1.0.0: version "1.1.0" @@ -6684,7 +6684,7 @@ nodemailer-html-to-text@^3.1.0: dependencies: html-to-text "^5.1.1" -nodemailer@^6.4.5: +nodemailer@^6.4.4: version "6.4.5" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.5.tgz#45614c6454d1a947242105eeddae03df87e29916" integrity sha512-NH7aNVQyZLAvGr2+EOto7znvz+qJ02Cb/xpou98ApUt5tEAUSVUxhvHvgV/8I5dhjKTYqUw0nasoKzLNBJKrDQ== @@ -7888,7 +7888,7 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -sanitize-html@~1.22.1: +sanitize-html@~1.22.0: version "1.22.1" resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.22.1.tgz#5b36c92ab27917ddd2775396815c2bc1a6268310" integrity sha512-++IMC00KfMQc45UWZJlhWOlS9eMrME38sFG9GXfR+k6oBo9JXSYQgTOZCl9j3v/smFTRNT9XNwz5DseFdMY+2Q== @@ -9378,7 +9378,7 @@ yargs@^15.0.0: y18n "^4.0.0" yargs-parser "^16.1.0" -yup@^0.28.3: +yup@^0.28.1: version "0.28.3" resolved "https://registry.yarnpkg.com/yup/-/yup-0.28.3.tgz#1ca607405a8adf24a5ac51f54bd09d527555f0ba" integrity sha512-amVkCgFWe5bGjrrUiODkbIzrSwtB8JpZrQYSrfj2YsbRdrV+tn9LquWdZDlfOx2HXyfEA8FGnlwidE/bFDxO7Q== diff --git a/cypress/integration/common/post.js b/cypress/integration/common/post.js index d0298c5a3..cba238a63 100644 --- a/cypress/integration/common/post.js +++ b/cypress/integration/common/post.js @@ -3,8 +3,6 @@ import locales from '../../../webapp/locales' import orderBy from 'lodash/orderBy' const languages = orderBy(locales, 'name') -const narratorAvatar = - "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg"; When("I type in a comment with {int} characters", size => { var c=""; @@ -32,9 +30,11 @@ Then("my comment should be successfully created", () => { Then("I should see my comment", () => { cy.get("article.comment-card p") .should("contain", "Human Connection rocks") + .get(".user-teaser span.slug") + .should("contain", "@peter-pan") // specific enough .get(".user-avatar img") .should("have.attr", "src") - .and("contain", narratorAvatar) + .and("contain", 'https://') // some url .get(".user-teaser > .info > .text") .should("contain", "today at"); }); diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 468ce578a..22a9d016e 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -24,7 +24,6 @@ const narratorParams = { id: 'id-of-peter-pan', name: "Peter Pan", slug: "peter-pan", - avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg", ...termsAndConditionsAgreedVersion, }; diff --git a/cypress/integration/post/DeleteImage.feature b/cypress/integration/post/DeleteImage.feature index a3fa6f9b6..07bfe43b1 100644 --- a/cypress/integration/post/DeleteImage.feature +++ b/cypress/integration/post/DeleteImage.feature @@ -7,7 +7,7 @@ Feature: Delete Teaser Image Given I have a user account Given I am logged in Given we have the following posts in our database: - | authorId | id | title | content | + | authorId | id | title | content | | id-of-peter-pan | p1 | Post to be updated | successfully updated | Scenario: Delete existing image diff --git a/package.json b/package.json index a0e5e3992..9eecb709a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "human-connection", - "version": "0.4.2", + "version": "0.4.1", "description": "Fullstack and API tests with cypress and cucumber for Human Connection", "author": "Human Connection gGmbh", "license": "MIT", diff --git a/scripts/translations/sort.sh b/scripts/translations/sort.sh index aa5737de8..35496f27e 100755 --- a/scripts/translations/sort.sh +++ b/scripts/translations/sort.sh @@ -3,12 +3,6 @@ ROOT_DIR=$(dirname "$0")/../.. tmp=$(mktemp) exit_code=0 -errors=0 - -TEXT_RED="\e[31m" -TEXT_BLUE="\e[34m" -TEXT_RESET="\e[0m" -TEXT_BOLD="\e[1m" for locale_file in $ROOT_DIR/webapp/locales/*.json do @@ -22,13 +16,9 @@ do : # all good else exit_code=$? - echo -e "${TEXT_BOLD}${TEXT_RED}>>> $(basename -- $locale_file) is not sorted by keys <<<${TEXT_RESET}" - errors=1 + echo "$(basename -- $locale_file) is not sorted by keys" fi fi done -[ "$errors" = 1 ] && echo -e "${TEXT_BOLD}${TEXT_BLUE}Please run $0 --fix to sort your locale definitions!${TEXT_RESET}"; - - exit $exit_code diff --git a/webapp/components/Badges.spec.js b/webapp/components/Badges.spec.js index 5273fca21..f81eaafb1 100644 --- a/webapp/components/Badges.spec.js +++ b/webapp/components/Badges.spec.js @@ -2,20 +2,29 @@ import { shallowMount } from '@vue/test-utils' import Badges from './Badges.vue' describe('Badges.vue', () => { - let wrapper + let propsData beforeEach(() => { - wrapper = shallowMount(Badges, {}) + propsData = {} }) - it('renders', () => { - expect(wrapper.is('div')).toBe(true) - }) + describe('shallowMount', () => { + const Wrapper = () => { + return shallowMount(Badges, { propsData }) + } - it('has class "hc-badges"', () => { - expect(wrapper.contains('.hc-badges')).toBe(true) - }) + it('has class "hc-badges"', () => { + expect(Wrapper().contains('.hc-badges')).toBe(true) + }) - // TODO: add similar software tests for other components - // TODO: add more test cases in this file + describe('given a badge', () => { + beforeEach(() => { + propsData.badges = [{ id: '1', icon: '/path/to/some/icon' }] + }) + + it('proxies badge icon, which is just a URL without metadata', () => { + expect(Wrapper().contains('img[src="/api/path/to/some/icon"]')).toBe(true) + }) + }) + }) }) diff --git a/webapp/components/CommentCard/CommentCard.story.js b/webapp/components/CommentCard/CommentCard.story.js index 1749999f3..467a125d5 100644 --- a/webapp/components/CommentCard/CommentCard.story.js +++ b/webapp/components/CommentCard/CommentCard.story.js @@ -17,8 +17,10 @@ const comment = { disabled: false, author: { id: '1', - avatar: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + avatar: { + url: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + }, slug: 'jenny-rostock', name: 'Rainer Unsinn', disabled: false, diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index df95d82f9..585980cc2 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -70,7 +70,7 @@ describe('ContributionForm.vue', () => { }, url: 'someUrlToImage', } - const image = '/uploads/1562010976466-avataaars' + const image = { sensitive: false, url: '/uploads/1562010976466-avataaars', aspectRatio: 1 } beforeEach(() => { mocks = { $t: jest.fn(), @@ -199,10 +199,7 @@ describe('ContributionForm.vue', () => { language: 'en', id: null, categoryIds: ['cat12'], - imageUpload: null, - imageAspectRatio: null, image: null, - imageBlurred: false, }, } postTitleInput = wrapper.find('.ds-input') @@ -233,8 +230,16 @@ describe('ContributionForm.vue', () => { }) it('supports adding a teaser image', async () => { - const spy = jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {}) - expectedParams.variables.imageUpload = imageUpload + expectedParams.variables.image = { + aspectRatio: null, + sensitive: false, + upload: imageUpload, + } + const spy = jest + .spyOn(FileReader.prototype, 'readAsDataURL') + .mockImplementation(function() { + this.onload({ target: { result: 'someUrlToImage' } }) + }) wrapper.find(ImageUploader).vm.$emit('addHeroImage', imageUpload) await wrapper.find('form').trigger('submit') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) @@ -317,7 +322,6 @@ describe('ContributionForm.vue', () => { name: 'Democracy & Politics', }, ], - imageAspectRatio: 1, }, } wrapper = Wrapper() @@ -354,10 +358,9 @@ describe('ContributionForm.vue', () => { language: propsData.contribution.language, id: propsData.contribution.id, categoryIds: ['cat12'], - image, - imageUpload: null, - imageAspectRatio: 1, - imageBlurred: false, + image: { + sensitive: false, + }, }, } }) @@ -383,8 +386,7 @@ describe('ContributionForm.vue', () => { it('supports deleting a teaser image', async () => { expectedParams.variables.image = null - expectedParams.variables.imageAspectRatio = null - propsData.contribution.image = '/uploads/someimage.png' + propsData.contribution.image = { url: '/uploads/someimage.png' } wrapper = Wrapper() wrapper.find('[data-test="delete-button"]').trigger('click') await wrapper.find('form').trigger('submit') diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index 4a163821b..2afee0763 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -106,27 +106,20 @@ export default { }, }, data() { - const { - title, - content, - image, - imageAspectRatio, - imageBlurred, - language, - categories, - } = this.contribution + const { title, content, image, language, categories } = this.contribution const languageOptions = orderBy(locales, 'name').map(locale => { return { label: locale.name, value: locale.code } }) + const { sensitive: imageBlurred = false, aspectRatio: imageAspectRatio = null } = image || {} return { formData: { title: title || '', content: content || '', image: image || null, - imageAspectRatio: imageAspectRatio || null, - imageBlurred: imageBlurred || false, + imageAspectRatio, + imageBlurred, language: languageOptions.find(option => option.value === language) || null, categoryIds: categories ? categories.map(category => category.id) : [], }, @@ -163,16 +156,28 @@ export default { }, methods: { submit() { + let image = null + const { title, content, categoryIds } = this.formData + if (this.formData.image) { + image = { + sensitive: this.formData.imageBlurred, + } + if (this.imageUpload) { + image.upload = this.imageUpload + image.aspectRatio = this.formData.imageAspectRatio + } + } this.loading = true this.$apollo .mutate({ mutation: this.contribution.id ? PostMutations().UpdatePost : PostMutations().CreatePost, variables: { - ...this.formData, + title, + content, + categoryIds, id: this.contribution.id || null, language: this.formData.language.value, - image: this.imageUpload ? null : this.formData.image, - imageUpload: this.imageUpload, + image, }, }) .then(({ data }) => { @@ -198,10 +203,13 @@ export default { if (file) { const reader = new FileReader() reader.onload = ({ target }) => { - this.formData.image = target.result + this.formData.image = { + ...this.formData.image, + url: target.result, + } } - this.imageUpload = file reader.readAsDataURL(file) + this.imageUpload = file } }, addImageAspectRatio(aspectRatio) { diff --git a/webapp/components/PostTeaser/PostTeaser.story.js b/webapp/components/PostTeaser/PostTeaser.story.js index db3350c5b..5fecae4db 100644 --- a/webapp/components/PostTeaser/PostTeaser.story.js +++ b/webapp/components/PostTeaser/PostTeaser.story.js @@ -16,8 +16,10 @@ export const post = { image: null, author: { id: 'u3', - avatar: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + avatar: { + url: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + }, slug: 'jenny-rostock', name: 'Rainer Unsinn', disabled: false, diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue index 851ee4f2c..1626f66a2 100644 --- a/webapp/components/PostTeaser/PostTeaser.vue +++ b/webapp/components/PostTeaser/PostTeaser.vue @@ -7,7 +7,7 @@ :lang="post.language" :class="{ 'disabled-content': post.disabled, - '--blur-image': post.imageBlurred, + '--blur-image': post.image && post.image.sensitive, }" :highlight="isPinned" > @@ -93,8 +93,10 @@ export default { }, }, mounted() { + const { image } = this.post + if (!image) return const width = this.$el.offsetWidth - const height = Math.min(width / this.post.imageAspectRatio, 2000) + const height = Math.min(width / image.aspectRatio, 2000) const imageElement = this.$el.querySelector('.hero-image') if (imageElement) { imageElement.style.height = `${height}px` diff --git a/webapp/components/Upload/index.vue b/webapp/components/Upload/index.vue index c78b4649b..bb37df105 100644 --- a/webapp/components/Upload/index.vue +++ b/webapp/components/Upload/index.vue @@ -2,7 +2,7 @@
{ mutate: jest .fn() .mockResolvedValueOnce({ - data: { UpdateUser: { id: 'upload1', avatar: '/upload/avatar.jpg' } }, + data: { UpdateUser: { id: 'upload1', avatar: { url: '/upload/avatar.jpg' } } }, }) .mockRejectedValue({ message: 'File upload unsuccessful! Whatcha gonna do?', @@ -27,7 +27,7 @@ describe('Upload', () => { const propsData = { user: { - avatar: '/api/generic.jpg', + avatar: { url: '/api/generic.jpg' }, }, } diff --git a/webapp/components/UserTeaser/UserTeaser.story.js b/webapp/components/UserTeaser/UserTeaser.story.js index 73b34cb76..a872b1aa4 100644 --- a/webapp/components/UserTeaser/UserTeaser.story.js +++ b/webapp/components/UserTeaser/UserTeaser.story.js @@ -9,7 +9,9 @@ export const user = { id: 'u6', slug: 'louie', name: 'Louie', - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/designervzm/128.jpg', + avatar: { + url: 'https://s3.amazonaws.com/uifaces/faces/twitter/designervzm/128.jpg', + }, about: 'Illum in et velit soluta voluptatem architecto consequuntur enim placeat. Eum excepturi est ratione rerum in voluptatum corporis. Illum consequatur minus. Modi incidunt velit.', disabled: false, @@ -28,7 +30,9 @@ export const user = { id: 'u3', slug: 'jenny-rostock', name: 'Jenny Rostock', - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/bowbrick/128.jpg', + avatar: { + url: 'https://s3.amazonaws.com/uifaces/faces/twitter/bowbrick/128.jpg', + }, disabled: false, deleted: false, followedByCount: 2, @@ -83,7 +87,7 @@ storiesOf('UserTeaser', module) - + `, })) .add('anonymous', () => ({ diff --git a/webapp/components/_new/generic/UserAvatar/UserAvatar.spec.js b/webapp/components/_new/generic/UserAvatar/UserAvatar.spec.js index 61f9977ef..6d12c5ce2 100644 --- a/webapp/components/_new/generic/UserAvatar/UserAvatar.spec.js +++ b/webapp/components/_new/generic/UserAvatar/UserAvatar.spec.js @@ -66,7 +66,9 @@ describe('UserAvatar.vue', () => { propsData = { user: { name: 'Not Anonymous', - avatar: '/avatar.jpg', + avatar: { + url: '/avatar.jpg', + }, }, } wrapper = Wrapper() @@ -82,7 +84,9 @@ describe('UserAvatar.vue', () => { propsData = { user: { name: 'Not Anonymous', - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg', + avatar: { + url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg', + }, }, } wrapper = Wrapper() diff --git a/webapp/components/generic/SearchableInput/SearchableInput.story.js b/webapp/components/generic/SearchableInput/SearchableInput.story.js index 68feaadba..d969fa976 100644 --- a/webapp/components/generic/SearchableInput/SearchableInput.story.js +++ b/webapp/components/generic/SearchableInput/SearchableInput.story.js @@ -69,32 +69,40 @@ export const searchResults = [ { id: 'u1', __typename: 'User', - avatar: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + avatar: { + url: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + }, name: 'Peter Lustig', slug: 'peter-lustig', }, { id: 'cdbca762-0632-4564-b646-415a0c42d8b8', __typename: 'User', - avatar: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + avatar: { + url: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + }, name: 'Herbert Schultz', slug: 'herbert-schultz', }, { id: 'u2', __typename: 'User', - avatar: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + avatar: { + url: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + }, name: 'Bob der Baumeister', slug: 'bob-der-baumeister', }, { id: '7b654f72-f4da-4315-8bed-39de0859754b', __typename: 'User', - avatar: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + avatar: { + url: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + }, name: 'Tonya Mohr', slug: 'tonya-mohr', }, diff --git a/webapp/graphql/CommentMutations.js b/webapp/graphql/CommentMutations.js index 133b61f18..5c9e1bc72 100644 --- a/webapp/graphql/CommentMutations.js +++ b/webapp/graphql/CommentMutations.js @@ -17,7 +17,9 @@ export default i18n => { id slug name - avatar + avatar { + url + } disabled deleted shoutedCount @@ -47,7 +49,9 @@ export default i18n => { id slug name - avatar + avatar { + url + } disabled deleted } @@ -67,7 +71,9 @@ export default i18n => { id slug name - avatar + avatar { + url + } disabled deleted shoutedCount diff --git a/webapp/graphql/CommentQuery.js b/webapp/graphql/CommentQuery.js index c2daaa943..8765dd141 100644 --- a/webapp/graphql/CommentQuery.js +++ b/webapp/graphql/CommentQuery.js @@ -12,7 +12,9 @@ export default app => { id slug name - avatar + avatar { + url + } disabled deleted shoutedCount diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js index cb7af1624..67dca4228 100644 --- a/webapp/graphql/Fragments.js +++ b/webapp/graphql/Fragments.js @@ -5,7 +5,9 @@ export const userFragment = gql` id slug name - avatar + avatar { + url + } disabled deleted } @@ -44,14 +46,16 @@ export const postFragment = gql` disabled deleted slug - image language - imageBlurred + image { + url + sensitive + aspectRatio + } author { ...user } pinnedAt - imageAspectRatio pinned } ` diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index e754be037..9120b3e87 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -8,25 +8,23 @@ export default () => { $content: String! $language: String $categoryIds: [ID] - $imageUpload: Upload - $imageBlurred: Boolean - $imageAspectRatio: Float + $image: ImageInput ) { CreatePost( title: $title content: $content language: $language categoryIds: $categoryIds - imageUpload: $imageUpload - imageBlurred: $imageBlurred - imageAspectRatio: $imageAspectRatio + image: $image ) { title slug content contentExcerpt language - imageBlurred + image { + sensitive + } } } `, @@ -36,22 +34,16 @@ export default () => { $title: String! $content: String! $language: String - $imageUpload: Upload + $image: ImageInput $categoryIds: [ID] - $image: String - $imageBlurred: Boolean - $imageAspectRatio: Float ) { UpdatePost( id: $id title: $title content: $content language: $language - imageUpload: $imageUpload - categoryIds: $categoryIds image: $image - imageBlurred: $imageBlurred - imageAspectRatio: $imageAspectRatio + categoryIds: $categoryIds ) { id title @@ -59,13 +51,15 @@ export default () => { content contentExcerpt language - imageBlurred + image { + sensitive + aspectRatio + } pinnedBy { id name role } - imageAspectRatio } } `, diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 02f0909e3..70590813f 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -53,7 +53,9 @@ export const minimisedUserQuery = () => { id slug name - avatar + avatar { + url + } } } ` @@ -223,7 +225,7 @@ export const updateUserMutation = () => { $allowEmbedIframes: Boolean $showShoutsPublicly: Boolean $termsAndConditionsAgreedVersion: String - $avatarUpload: Upload + $avatar: ImageInput ) { UpdateUser( id: $id @@ -234,7 +236,7 @@ export const updateUserMutation = () => { allowEmbedIframes: $allowEmbedIframes showShoutsPublicly: $showShoutsPublicly termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion - avatarUpload: $avatarUpload + avatar: $avatar ) { id slug @@ -245,7 +247,9 @@ export const updateUserMutation = () => { showShoutsPublicly locale termsAndConditionsAgreedVersion - avatar + avatar { + url + } } } ` diff --git a/webapp/graphql/settings/BlockedUsers.js b/webapp/graphql/settings/BlockedUsers.js index 94f2121b1..be1765138 100644 --- a/webapp/graphql/settings/BlockedUsers.js +++ b/webapp/graphql/settings/BlockedUsers.js @@ -7,7 +7,9 @@ export const blockedUsers = () => { id name slug - avatar + avatar { + url + } about disabled deleted diff --git a/webapp/graphql/settings/MutedUsers.js b/webapp/graphql/settings/MutedUsers.js index 55ea34769..f498534e3 100644 --- a/webapp/graphql/settings/MutedUsers.js +++ b/webapp/graphql/settings/MutedUsers.js @@ -7,7 +7,9 @@ export const mutedUsers = () => { id name slug - avatar + avatar { + url + } about disabled deleted diff --git a/webapp/package.json b/webapp/package.json index d77256027..e2b7f74b3 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "human-connection-webapp", - "version": "0.4.2", + "version": "0.4.1", "description": "Human Connection Frontend", "authors": [ "Grzegorz Leoniec (appinteractive)", @@ -63,7 +63,7 @@ "@nuxtjs/axios": "~5.9.5", "@nuxtjs/dotenv": "~1.4.1", "@nuxtjs/pwa": "^3.0.0-beta.20", - "@nuxtjs/sentry": "^3.3.1", + "@nuxtjs/sentry": "^3.2.4", "@nuxtjs/style-resources": "~1.0.0", "accounting": "~0.4.1", "apollo-cache-inmemory": "~1.6.5", @@ -99,15 +99,15 @@ "devDependencies": { "@babel/core": "~7.8.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/preset-env": "~7.8.7", + "@babel/preset-env": "~7.8.6", "@storybook/addon-a11y": "^5.3.14", "@storybook/addon-actions": "^5.3.17", "@storybook/addon-notes": "^5.3.17", "@storybook/vue": "~5.3.14", "@vue/cli-shared-utils": "~4.2.3", "@vue/eslint-config-prettier": "~6.0.0", - "@vue/server-test-utils": "~1.0.0-beta.32", - "@vue/test-utils": "~1.0.0-beta.32", + "@vue/server-test-utils": "~1.0.0-beta.31", + "@vue/test-utils": "~1.0.0-beta.31", "async-validator": "^3.2.4", "babel-core": "~7.0.0-bridge.0", "babel-eslint": "~10.1.0", @@ -122,7 +122,7 @@ "eslint-config-standard": "~14.1.0", "eslint-loader": "~3.0.3", "eslint-plugin-import": "~2.20.1", - "eslint-plugin-jest": "~23.8.2", + "eslint-plugin-jest": "~23.8.1", "eslint-plugin-node": "~11.0.0", "eslint-plugin-prettier": "~3.1.2", "eslint-plugin-promise": "~4.2.1", @@ -130,7 +130,7 @@ "eslint-plugin-vue": "~6.2.2", "faker": "^4.1.0", "flush-promises": "^1.0.2", - "fuse.js": "^3.6.1", + "fuse.js": "^3.4.6", "identity-obj-proxy": "^3.0.0", "jest": "~25.1.0", "mutation-observer": "^1.0.3", diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index 2f2f14c1e..8aef52c1d 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -24,7 +24,7 @@