فهرست منبع

feat: comments - default provider create (wip) + permissions

NGPixel 5 سال پیش
والد
کامیت
1222355046

+ 1 - 1
client/components/admin/admin-groups-edit-permissions.vue

@@ -126,7 +126,7 @@ export default {
               permission: 'write:comments',
               hint: 'Can post new comments, as specified in the Page Rules',
               warning: false,
-              restrictedForSystem: true,
+              restrictedForSystem: false,
               disabled: false
             },
             {

+ 138 - 10
client/components/comments.vue

@@ -1,5 +1,5 @@
 <template lang="pug">
-  div(v-intersect.once.quiet='onIntersect')
+  div(v-intersect.once='onIntersect')
     v-textarea#discussion-new(
       outlined
       flat
@@ -11,11 +11,37 @@
       v-model='newcomment'
       color='blue-grey darken-2'
       :background-color='$vuetify.theme.dark ? `grey darken-5` : `white`'
+      v-if='permissions.write'
     )
-    .d-flex.align-center.pt-3
+    v-row.mt-2(dense, v-if='!isAuthenticated && permissions.write')
+      v-col(cols='12', lg='6')
+        v-text-field(
+          outlined
+          color='blue-grey darken-2'
+          :background-color='$vuetify.theme.dark ? `grey darken-5` : `white`'
+          placeholder='Your Name'
+          hide-details
+          dense
+          autocomplete='name'
+          v-model='guestName'
+        )
+      v-col(cols='12', lg='6')
+        v-text-field(
+          outlined
+          color='blue-grey darken-2'
+          :background-color='$vuetify.theme.dark ? `grey darken-5` : `white`'
+          placeholder='Your Email Address'
+          hide-details
+          type='email'
+          dense
+          autocomplete='email'
+          v-model='guestEmail'
+        )
+    .d-flex.align-center.pt-3(v-if='permissions.write')
       v-icon.mr-1(color='blue-grey') mdi-language-markdown-outline
       .caption.blue-grey--text Markdown Format
       v-spacer
+      .caption.mr-3(v-if='isAuthenticated') Posting as #[strong {{userDisplayName}}]
       v-btn(
         dark
         color='blue-grey darken-2'
@@ -24,7 +50,7 @@
         )
         v-icon(left) mdi-comment
         span.text-none Post Comment
-    v-divider.mt-3
+    v-divider.mt-3(v-if='permissions.write')
     .pa-5.d-flex.align-center.justify-center(v-if='isLoading')
       v-progress-circular(
         indeterminate
@@ -48,15 +74,18 @@
             v-img(src='http://i.pravatar.cc/64')
         v-card.elevation-1
           v-card-text
-            .caption: strong John Smith
+            .caption: strong {{cm.authorName}}
             .overline.grey--text 3 minutes ago
             .mt-3 {{cm.render}}
-    .pt-5.text-center.body-2.blue-grey--text(v-else) Be the first to comment.
+    .pt-5.text-center.body-2.blue-grey--text(v-else-if='permissions.write') Be the first to comment.
+    .text-center.body-2.blue-grey--text(v-else) No comments yet.
 </template>
 
 <script>
 import gql from 'graphql-tag'
 import { get } from 'vuex-pathify'
+import validate from 'validate.js'
+import _ from 'lodash'
 
 export default {
   data () {
@@ -64,19 +93,118 @@ export default {
       newcomment: '',
       isLoading: true,
       canFetch: false,
-      comments: []
+      comments: [],
+      guestName: '',
+      guestEmail: ''
     }
   },
   computed: {
-    pageId: get('page/id')
+    pageId: get('page/id'),
+    permissions: get('page/commentsPermissions'),
+    isAuthenticated: get('user/authenticated'),
+    userDisplayName: get('user/name')
   },
   methods: {
-    onIntersect () {
-      this.isLoading = true
-      this.canFetch = true
+    onIntersect (entries, observer, isIntersecting) {
+      if (isIntersecting) {
+        this.isLoading = true
+        this.canFetch = true
+      }
     },
     async postComment () {
+      let rules = {
+        comment: {
+          presence: {
+            allowEmpty: false
+          },
+          length: {
+            minimum: 2
+          }
+        }
+      }
+      if (!this.isAuthenticated && this.permissions.write) {
+        rules.name = {
+          presence: {
+            allowEmpty: false
+          },
+          length: {
+            minimum: 2,
+            maximum: 255
+          }
+        }
+        rules.email = {
+          presence: {
+            allowEmpty: false
+          },
+          email: true
+        }
+      }
+      const validationResults = validate({
+        comment: this.newcomment,
+        name: this.guestName,
+        email: this.guestEmail
+      }, rules, { format: 'flat' })
+
+      if (validationResults) {
+        this.$store.commit('showNotification', {
+          style: 'red',
+          message: validationResults[0],
+          icon: 'alert'
+        })
+        return
+      }
 
+      const resp = await this.$apollo.mutate({
+        mutation: gql`
+          mutation (
+            $pageId: Int!
+            $replyTo: Int
+            $content: String!
+            $guestName: String
+            $guestEmail: String
+          ) {
+            comments {
+              create (
+                pageId: $pageId
+                replyTo: $replyTo
+                content: $content
+                guestName: $guestName
+                guestEmail: $guestEmail
+              ) {
+                responseResult {
+                  succeeded
+                  errorCode
+                  slug
+                  message
+                }
+              }
+            }
+          }
+        `,
+        variables: {
+          pageId: this.pageId,
+          replyTo: 0,
+          content: this.newcomment,
+          guestName: this.guestName,
+          guestEmail: this.guestEmail
+        }
+      })
+
+      if (_.get(resp, 'data.comments.create.responseResult.succeeded', false)) {
+        this.$store.commit('showNotification', {
+          style: 'success',
+          message: 'New comment posted successfully.',
+          icon: 'check'
+        })
+
+        this.newcomment = ''
+      } else {
+        this.$store.commit('showNotification', {
+          style: 'red',
+          message: _.get(resp, 'data.comments.create.responseResult.message', 'An unexpected error occured.'),
+          icon: 'alert'
+        })
+      }
     }
   },
   apollo: {

+ 3 - 3
client/store/page.js

@@ -15,9 +15,9 @@ const state = {
   title: '',
   updatedAt: '',
   mode: '',
-  comments: {
-    view: false,
-    post: false,
+  commentsPermissions: {
+    read: false,
+    write: false,
     manage: false
   },
   commentsCount: 0

+ 5 - 4
client/themes/default/components/page.vue

@@ -90,7 +90,7 @@
                   )
                   v-icon(:color='$vuetify.theme.dark ? `teal lighten-3` : `teal`', size='20') mdi-tag-multiple
 
-            v-card.mb-5(v-if='commentsEnabled')
+            v-card.mb-5(v-if='commentsEnabled && commentsPerms.read')
               .pa-5
                 .overline.pb-2.blue-grey--text.d-flex.align-center(:class='$vuetify.theme.dark ? `text--lighten-3` : `text--darken-2`')
                   span Talk
@@ -113,7 +113,7 @@
                     small
                     )
                     span.blue-grey--text(:class='$vuetify.theme.dark ? `text--lighten-1` : `text--darken-2`') View Discussion
-                  v-tooltip(right, v-if='isAuthenticated')
+                  v-tooltip(right, v-if='commentsPerms.write')
                     template(v-slot:activator='{ on }')
                       v-btn.ml-2(
                         @click='goToComments(true)'
@@ -261,7 +261,7 @@
               span {{$t('common:page.editPage')}}
             .contents(ref='container')
               slot(name='contents')
-            .comments-container#discussion
+            .comments-container#discussion(v-if='commentsEnabled && commentsPerms.read')
               .comments-header
                 v-icon.mr-2(dark) mdi-comment-text-outline
                 span Comments
@@ -446,6 +446,7 @@ export default {
   computed: {
     isAuthenticated: get('user/authenticated'),
     commentsCount: get('page/commentsCount'),
+    commentsPerms: get('page/commentsPermissions'),
     rating: {
       get () {
         return 3.5
@@ -491,7 +492,7 @@ export default {
     this.$store.set('page/title', this.title)
     this.$store.set('page/updatedAt', this.updatedAt)
     if (this.commentsPermissions) {
-      this.$store.set('page/comments', JSON.parse(atob(this.commentsPermissions)))
+      this.$store.set('page/commentsPermissions', JSON.parse(atob(this.commentsPermissions)))
     }
 
     this.$store.set('page/mode', 'view')

+ 1 - 0
package.json

@@ -43,6 +43,7 @@
     "@root/keypairs": "0.9.0",
     "@root/pem": "1.0.4",
     "acme": "3.0.3",
+    "akismet-api": "5.0.0",
     "algoliasearch": "4.2.0",
     "apollo-fetch": "0.7.0",
     "apollo-server": "2.13.1",

+ 13 - 1
server/controllers/common.js

@@ -447,12 +447,24 @@ router.get('/*', async (req, res, next) => {
             })
           }
 
+          // -> Comments Permissions
+          const commentsPermissions = WIKI.config.features.featurePageComments ? {
+            read: WIKI.auth.checkAccess(req.user, ['read:comments'], pageArgs),
+            write: WIKI.auth.checkAccess(req.user, ['write:comments'], pageArgs),
+            manage: WIKI.auth.checkAccess(req.user, ['manage:comments'], pageArgs)
+          } : {
+            read: false,
+            write: false,
+            manage: false
+          }
+
           // -> Render view
           res.render('page', {
             page,
             sidebar,
             injectCode,
-            comments: WIKI.data.commentProvider
+            comments: WIKI.data.commentProvider,
+            commentsPermissions
           })
         }
       } else if (pageArgs.path === 'home') {

+ 8 - 0
server/db/migrations-sqlite/2.4.61.js

@@ -0,0 +1,8 @@
+exports.up = knex => {
+  return knex.schema
+    .alterTable('comments', table => {
+      table.integer('replyTo').unsigned().notNullable().defaultTo(0)
+    })
+}
+
+exports.down = knex => { }

+ 8 - 0
server/db/migrations/2.4.61.js

@@ -0,0 +1,8 @@
+exports.up = knex => {
+  return knex.schema
+    .alterTable('comments', table => {
+      table.integer('replyTo').unsigned().notNullable().defaultTo(0)
+    })
+}
+
+exports.down = knex => { }

+ 25 - 0
server/graph/resolvers/comment.js

@@ -11,6 +11,9 @@ module.exports = {
     async comments() { return {} }
   },
   CommentQuery: {
+    /**
+     * Fetch list of Comments Providers
+     */
     async providers(obj, args, context, info) {
       const providers = await WIKI.models.commentProviders.getProviders()
       return providers.map(provider => {
@@ -33,11 +36,33 @@ module.exports = {
         }
       })
     },
+    /**
+     * Fetch list of comments for a page
+     */
     async list (obj, args, context) {
       return []
     }
   },
   CommentMutation: {
+    /**
+     * Create New Comment
+     */
+    async create (obj, args, context) {
+      try {
+        // WIKI.data.commentProvider.create({
+        //   ...args,
+        //   user: context.req.user
+        // })
+        return {
+          responseResult: graphHelper.generateSuccess('New comment posted successfully')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
+    /**
+     * Update Comments Providers
+     */
     async updateProviders(obj, args, context) {
       try {
         for (let provider of args.providers) {

+ 6 - 0
server/graph/schemas/comment.graphql

@@ -39,12 +39,18 @@ type CommentMutation {
     pageId: Int!
     replyTo: Int
     content: String!
+    guestName: String
+    guestEmail: String
   ): DefaultResponse @auth(requires: ["write:comments", "manage:system"])
 
   update(
     id: Int!
     content: String!
   ): DefaultResponse @auth(requires: ["write:comments", "manage:comments", "manage:system"])
+
+  delete(
+    id: Int!
+  ): DefaultResponse @auth(requires: ["manage:comments", "manage:system"])
 }
 
 # -----------------------------------------------

+ 55 - 0
server/models/comments.js

@@ -0,0 +1,55 @@
+const Model = require('objection').Model
+
+/**
+ * Comments model
+ */
+module.exports = class Comment extends Model {
+  static get tableName() { return 'comments' }
+
+  static get jsonSchema () {
+    return {
+      type: 'object',
+      required: [],
+
+      properties: {
+        id: {type: 'integer'},
+        content: {type: 'string'},
+        render: {type: 'string'},
+        name: {type: 'string'},
+        email: {type: 'string'},
+        ip: {type: 'string'},
+        createdAt: {type: 'string'},
+        updatedAt: {type: 'string'}
+      }
+    }
+  }
+
+  static get relationMappings() {
+    return {
+      author: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: require('./users'),
+        join: {
+          from: 'comments.authorId',
+          to: 'users.id'
+        }
+      },
+      page: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: require('./pages'),
+        join: {
+          from: 'comments.pageId',
+          to: 'pages.id'
+        }
+      }
+    }
+  }
+
+  $beforeUpdate() {
+    this.updatedAt = new Date().toISOString()
+  }
+  $beforeInsert() {
+    this.createdAt = new Date().toISOString()
+    this.updatedAt = new Date().toISOString()
+  }
+}

+ 105 - 1
server/modules/comments/default/comment.js

@@ -1,11 +1,115 @@
+const md = require('markdown-it')
+const mdEmoji = require('markdown-it-emoji')
+const { JSDOM } = require('jsdom')
+const createDOMPurify = require('dompurify')
+const _ = require('lodash')
+const { AkismetClient } = require('akismet-api')
+
 /* global WIKI */
 
+const window = new JSDOM('').window
+const DOMPurify = createDOMPurify(window)
+
+md.use(mdEmoji)
+
+let akismetClient = null
+
 // ------------------------------------
 // Default Comment Provider
 // ------------------------------------
 
 module.exports = {
-  add (args) {
+  /**
+   * Init
+   */
+  async init (config) {
+    if (WIKI.data.commentProvider.config.akismet && WIKI.data.commentProvider.config.akismet.length > 2) {
+      akismetClient = new AkismetClient({
+        key: WIKI.data.commentProvider.config.akismet,
+        blog: WIKI.config.host,
+        lang: WIKI.config.lang.namespacing ? WIKI.config.lang.namespaces.join(', ') : WIKI.config.lang.code,
+        charset: 'UTF-8'
+      })
+      try {
+        const isValid = await akismetClient.verifyKey()
+        if (!isValid) {
+          WIKI.logger.warn('Akismet Key is invalid!')
+        }
+      } catch (err) {
+        WIKI.logger.warn('Unable to verify Akismet Key: ' + err.message)
+      }
+    } else {
+      akismetClient = null
+    }
+  },
+  /**
+   * Create New Comment
+   */
+  async create ({ page, replyTo, content, user }) {
+    // -> Render Markdown
+    const mkdown = md({
+      html: false,
+      breaks: true,
+      linkify: true,
+      highlight(str, lang) {
+        return `<pre><code class="language-${lang}">${_.escape(str)}</code></pre>`
+      }
+    })
+
+    // -> Build New Comment
+    const newComment = {
+      content,
+      render: DOMPurify.sanitize(mkdown.render(content)),
+      replyTo,
+      pageId: page.id,
+      authorId: user.id,
+      name: user.name,
+      email: user.email,
+      ip: user.ip
+    }
+
+    // Check for Spam with Akismet
+    if (akismetClient) {
+      let userRole = 'user'
+      if (user.groups.indexOf(1) >= 0) {
+        userRole = 'administrator'
+      } else if (user.groups.indexOf(2) >= 0) {
+        userRole = 'guest'
+      }
+
+      let isSpam = false
+      try {
+        isSpam = await akismetClient.checkSpam({
+          ip: user.ip,
+          useragent: user.agentagent,
+          content,
+          name: user.name,
+          email: user.email,
+          permalink: `${WIKI.config.host}/${page.localeCode}/${page.path}`,
+          permalinkDate: page.updatedAt,
+          type: (replyTo > 0) ? 'reply' : 'comment',
+          role: userRole
+        })
+      } catch (err) {
+        WIKI.logger.warn('Akismet Comment Validation: [ FAILED ]')
+        WIKI.logger.warn(err)
+      }
+
+      if (isSpam) {
+        throw new Error('Comment was rejected because it is marked as spam.')
+      }
+    }
+
+    // Save Comment
+    await WIKI.models.comments.query().insert(newComment)
+  },
+  async update ({ id, content, user, ip }) {
+
+  },
+  async remove ({ id, user, ip }) {
+
+  },
+  async count ({ pageId }) {
 
   }
 }

+ 1 - 1
server/views/page.pug

@@ -26,7 +26,7 @@ block body
       sidebar=Buffer.from(JSON.stringify(sidebar)).toString('base64')
       nav-mode=config.nav.mode
       comments-enabled=config.features.featurePageComments
-      comments-provider=comments.key
+      comments-permissions=Buffer.from(JSON.stringify(commentsPermissions)).toString('base64')
       comments-external=comments.codeTemplate
       )
       template(slot='contents')

+ 43 - 3
yarn.lock

@@ -3172,6 +3172,14 @@ ajv@^6.12.0:
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
+akismet-api@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/akismet-api/-/akismet-api-5.0.0.tgz#35fe88815486b552bde8c04e3f3256f430b46ce7"
+  integrity sha512-GdFa93txqJM3gGaeXJs0gAXNCIpcBFnCs8ylmPDGCRUecqkfl4+n9AqE0izQv5hWwvM1b47JQjD3pj0BR+zK5Q==
+  dependencies:
+    bluebird "^3.1.1"
+    superagent "^5.1.1"
+
 algoliasearch@4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.2.0.tgz#dd81a1a0c57eb9f74af6db70b0c11f256692d1e6"
@@ -5229,7 +5237,7 @@ commondir@^1.0.1:
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
   integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
 
-component-emitter@^1.2.0, component-emitter@^1.2.1:
+component-emitter@^1.2.0, component-emitter@^1.2.1, component-emitter@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
   integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
@@ -5389,7 +5397,7 @@ cookie@0.4.0:
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
   integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
 
-cookiejar@^2.1.0:
+cookiejar@^2.1.0, cookiejar@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c"
   integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==
@@ -7368,6 +7376,11 @@ fast-safe-stringify@^2.0.4:
   resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2"
   integrity sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==
 
+fast-safe-stringify@^2.0.7:
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
+  integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==
+
 fb-watchman@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58"
@@ -7653,6 +7666,11 @@ formidable@^1.2.0:
   resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.1.tgz#70fb7ca0290ee6ff961090415f4b3df3d2082659"
   integrity sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==
 
+formidable@^1.2.1:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9"
+  integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==
+
 forwarded@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
@@ -10410,7 +10428,7 @@ mermaid@8.5.0:
     moment-mini "^2.22.1"
     scope-css "^1.2.1"
 
-methods@^1.1.1, methods@~1.1.2:
+methods@^1.1.1, methods@^1.1.2, methods@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
   integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
@@ -13523,6 +13541,11 @@ qs@^6.5.1:
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.8.0.tgz#87b763f0d37ca54200334cd57bb2ef8f68a1d081"
   integrity sha512-tPSkj8y92PfZVbinY1n84i1Qdx75lZjMQYx9WZhnkofyxzw2r7Ho39G3/aEvSUdebxpnnM4LZJCtvE/Aq3+s9w==
 
+qs@^6.9.1:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687"
+  integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==
+
 qs@~6.5.2:
   version "6.5.2"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
@@ -15230,6 +15253,23 @@ superagent@^3.8.3:
     qs "^6.5.1"
     readable-stream "^2.3.5"
 
+superagent@^5.1.1:
+  version "5.2.2"
+  resolved "https://registry.yarnpkg.com/superagent/-/superagent-5.2.2.tgz#6ff726c5642795b2c27009e92687c8e69a6bb07d"
+  integrity sha512-pMWBUnIllK4ZTw7p/UaobiQPwAO5w/1NRRTDpV0FTVNmECztsxKspj3ZWEordVEaqpZtmOQJJna4yTLyC/q7PQ==
+  dependencies:
+    component-emitter "^1.3.0"
+    cookiejar "^2.1.2"
+    debug "^4.1.1"
+    fast-safe-stringify "^2.0.7"
+    form-data "^3.0.0"
+    formidable "^1.2.1"
+    methods "^1.1.2"
+    mime "^2.4.4"
+    qs "^6.9.1"
+    readable-stream "^3.4.0"
+    semver "^6.3.0"
+
 supports-color@6.1.0, supports-color@^6.1.0:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"