Add ability to reset our password
authorChocobozzz <me@florianbigard.com>
Tue, 30 Jan 2018 12:27:07 +0000 (13:27 +0100)
committerChocobozzz <me@florianbigard.com>
Tue, 30 Jan 2018 12:27:07 +0000 (13:27 +0100)
32 files changed:
.gitignore
client/src/app/app.module.ts
client/src/app/login/login.component.html
client/src/app/login/login.component.scss
client/src/app/login/login.component.ts
client/src/app/reset-password/index.ts [new file with mode: 0644]
client/src/app/reset-password/reset-password-routing.module.ts [new file with mode: 0644]
client/src/app/reset-password/reset-password.component.html [new file with mode: 0644]
client/src/app/reset-password/reset-password.component.scss [new file with mode: 0644]
client/src/app/reset-password/reset-password.component.ts [new file with mode: 0644]
client/src/app/reset-password/reset-password.module.ts [new file with mode: 0644]
client/src/app/shared/users/user.service.ts
client/src/polyfills.ts
client/src/sass/application.scss
config/default.yaml
config/production.yaml.example
package.json
scripts/help.sh
server.ts
server/controllers/api/users.ts
server/helpers/logger.ts
server/initializers/checker.ts
server/initializers/constants.ts
server/lib/emailer.ts [new file with mode: 0644]
server/lib/job-queue/handlers/email.ts [new file with mode: 0644]
server/lib/job-queue/job-queue.ts
server/lib/redis.ts [new file with mode: 0644]
server/middlewares/validators/users.ts
server/models/account/user.ts
shared/models/job.model.ts
tsconfig.json
yarn.lock

index 96e888fd4b9b15e0fbca052fa96e2c937e88e7d6..9dc03a6a1f09451b4f8ec23785e7af1d22645a03 100644 (file)
@@ -7,7 +7,7 @@
 /test6/
 /storage/
 /config/production.yaml
-/config/local*.json
+/config/local*
 /ffmpeg/
 /*.sublime-project
 /*.sublime-workspace
index e69edbc4b1c9696c9b0b0b3f0256e924c60fa0e2..ddcaf3f484db10a4d1937d0e5c2eb23873026540 100644 (file)
@@ -1,19 +1,20 @@
 import { NgModule } from '@angular/core'
 import { BrowserModule } from '@angular/platform-browser'
+import { ResetPasswordModule } from '@app/reset-password'
 
-import { MetaModule, MetaLoader, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
+import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
+
+import { AccountModule } from './account'
 
 import { AppRoutingModule } from './app-routing.module'
 import { AppComponent } from './app.component'
-
-import { AccountModule } from './account'
 import { CoreModule } from './core'
+import { HeaderComponent } from './header'
 import { LoginModule } from './login'
-import { SignupModule } from './signup'
+import { MenuComponent } from './menu'
 import { SharedModule } from './shared'
+import { SignupModule } from './signup'
 import { VideosModule } from './videos'
-import { MenuComponent } from './menu'
-import { HeaderComponent } from './header'
 
 export function metaFactory (): MetaLoader {
   return new MetaStaticLoader({
@@ -46,6 +47,7 @@ export function metaFactory (): MetaLoader {
     AccountModule,
     CoreModule,
     LoginModule,
+    ResetPasswordModule,
     SignupModule,
     SharedModule,
     VideosModule,
index b61b66ec71b9af67a39b583a7d22ff3109d1b4c2..660a082806c65eb9cbc892aa634bb4b05ccbdaed 100644 (file)
 
     <div class="form-group">
       <label for="password">Password</label>
-      <input
-        type="password" name="password" id="password" placeholder="Password" required
-        formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
-      >
+      <div>
+        <input
+          type="password" name="password" id="password" placeholder="Password" required
+          formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
+        >
+        <div class="forgot-password-button" (click)="openForgotPasswordModal()">I forgot my password</div>
+      </div>
       <div *ngIf="formErrors.password" class="form-error">
         {{ formErrors.password }}
       </div>
     <input type="submit" value="Login" [disabled]="!form.valid">
   </form>
 </div>
+
+<div bsModal #forgotPasswordModal="bs-modal" (onShown)="onForgotPasswordModalShown()" class="modal" tabindex="-1">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+      <div class="modal-header">
+        <span class="close" aria-hidden="true" (click)="hideForgotPasswordModal()"></span>
+        <h4 class="modal-title">Forgot your password</h4>
+      </div>
+
+      <div class="modal-body">
+        <div class="form-group">
+          <label for="forgot-password-email">Email</label>
+          <input
+            type="email" id="forgot-password-email" placeholder="Email address" required
+            [(ngModel)]="forgotPasswordEmail" #forgotPasswordEmailInput
+          >
+        </div>
+
+        <div class="form-group inputs">
+          <span class="action-button action-button-cancel" (click)="hideForgotPasswordModal()">
+            Cancel
+          </span>
+
+          <input
+            type="submit" value="Send me an email to reset my password" class="action-button-submit"
+            (click)="askResetPassword()" [disabled]="!forgotPasswordEmailInput.validity.valid"
+          >
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
index efec6b70688326fd5d612f099a7d4e1362aad06a..2cf6991cef2ba38b791facfde84d01f66ff4ce7f 100644 (file)
@@ -10,3 +10,13 @@ input[type=submit] {
   @include peertube-button;
   @include orange-button;
 }
+
+input[type=password] {
+  display: inline-block;
+  margin-right: 5px;
+}
+
+.forgot-password-button {
+  display: inline-block;
+  cursor: pointer;
+}
index e7c9c722632a3dfdb6504a7705ea5fe3c65a7513..22e8c77dd44f4817c6367e5937c80732d6d882ac 100644 (file)
@@ -1,7 +1,9 @@
-import { Component, OnInit } from '@angular/core'
+import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
 import { FormBuilder, FormGroup, Validators } from '@angular/forms'
 import { Router } from '@angular/router'
-
+import { UserService } from '@app/shared'
+import { NotificationsService } from 'angular2-notifications'
+import { ModalDirective } from 'ngx-bootstrap/modal'
 import { AuthService } from '../core'
 import { FormReactive } from '../shared'
 
@@ -12,6 +14,9 @@ import { FormReactive } from '../shared'
 })
 
 export class LoginComponent extends FormReactive implements OnInit {
+  @ViewChild('forgotPasswordModal') forgotPasswordModal: ModalDirective
+  @ViewChild('forgotPasswordEmailInput') forgotPasswordEmailInput: ElementRef
+
   error: string = null
 
   form: FormGroup
@@ -27,9 +32,12 @@ export class LoginComponent extends FormReactive implements OnInit {
       'required': 'Password is required.'
     }
   }
+  forgotPasswordEmail = ''
 
   constructor (
     private authService: AuthService,
+    private userService: UserService,
+    private notificationsService: NotificationsService,
     private formBuilder: FormBuilder,
     private router: Router
   ) {
@@ -60,4 +68,29 @@ export class LoginComponent extends FormReactive implements OnInit {
       err => this.error = err.message
     )
   }
+
+  askResetPassword () {
+    this.userService.askResetPassword(this.forgotPasswordEmail)
+      .subscribe(
+        res => {
+          const message = `An email with the reset password instructions will be sent to ${this.forgotPasswordEmail}.`
+          this.notificationsService.success('Success', message)
+          this.hideForgotPasswordModal()
+        },
+
+        err => this.notificationsService.error('Error', err.message)
+      )
+  }
+
+  onForgotPasswordModalShown () {
+    this.forgotPasswordEmailInput.nativeElement.focus()
+  }
+
+  openForgotPasswordModal () {
+    this.forgotPasswordModal.show()
+  }
+
+  hideForgotPasswordModal () {
+    this.forgotPasswordModal.hide()
+  }
 }
diff --git a/client/src/app/reset-password/index.ts b/client/src/app/reset-password/index.ts
new file mode 100644 (file)
index 0000000..438dc57
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './reset-password-routing.module'
+export * from './reset-password.component'
+export * from './reset-password.module'
diff --git a/client/src/app/reset-password/reset-password-routing.module.ts b/client/src/app/reset-password/reset-password-routing.module.ts
new file mode 100644 (file)
index 0000000..b410695
--- /dev/null
@@ -0,0 +1,25 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+
+import { MetaGuard } from '@ngx-meta/core'
+
+import { ResetPasswordComponent } from './reset-password.component'
+
+const resetPasswordRoutes: Routes = [
+  {
+    path: 'reset-password',
+    component: ResetPasswordComponent,
+    canActivate: [ MetaGuard ],
+    data: {
+      meta: {
+        title: 'Reset password'
+      }
+    }
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(resetPasswordRoutes) ],
+  exports: [ RouterModule ]
+})
+export class ResetPasswordRoutingModule {}
diff --git a/client/src/app/reset-password/reset-password.component.html b/client/src/app/reset-password/reset-password.component.html
new file mode 100644 (file)
index 0000000..d142c52
--- /dev/null
@@ -0,0 +1,33 @@
+<div class="margin-content">
+  <div class="title-page title-page-single">
+    Reset my password
+  </div>
+
+  <div *ngIf="error" class="alert alert-danger">{{ error }}</div>
+
+  <form role="form" (ngSubmit)="resetPassword()" [formGroup]="form">
+    <div class="form-group">
+      <label for="password">Password</label>
+      <input
+        type="password" name="password" id="password" placeholder="Password" required
+        formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
+      >
+      <div *ngIf="formErrors.password" class="form-error">
+        {{ formErrors.password }}
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="password-confirm">Confirm password</label>
+      <input
+        type="password" name="password-confirm" id="password-confirm" placeholder="Confirmed password" required
+        formControlName="password-confirm" [ngClass]="{ 'input-error': formErrors['password-confirm'] }"
+      >
+      <div *ngIf="formErrors['password-confirm']" class="form-error">
+        {{ formErrors['password-confirm'] }}
+      </div>
+    </div>
+
+    <input type="submit" value="Reset my password" [disabled]="!form.valid && isConfirmedPasswordValid()">
+  </form>
+</div>
diff --git a/client/src/app/reset-password/reset-password.component.scss b/client/src/app/reset-password/reset-password.component.scss
new file mode 100644 (file)
index 0000000..efec6b7
--- /dev/null
@@ -0,0 +1,12 @@
+@import '_variables';
+@import '_mixins';
+
+input:not([type=submit]) {
+  @include peertube-input-text(340px);
+  display: block;
+}
+
+input[type=submit] {
+  @include peertube-button;
+  @include orange-button;
+}
diff --git a/client/src/app/reset-password/reset-password.component.ts b/client/src/app/reset-password/reset-password.component.ts
new file mode 100644 (file)
index 0000000..4083747
--- /dev/null
@@ -0,0 +1,79 @@
+import { Component, OnInit } from '@angular/core'
+import { FormBuilder, FormGroup, Validators } from '@angular/forms'
+import { ActivatedRoute, Router } from '@angular/router'
+import { USER_PASSWORD, UserService } from '@app/shared'
+import { NotificationsService } from 'angular2-notifications'
+import { AuthService } from '../core'
+import { FormReactive } from '../shared'
+
+@Component({
+  selector: 'my-login',
+  templateUrl: './reset-password.component.html',
+  styleUrls: [ './reset-password.component.scss' ]
+})
+
+export class ResetPasswordComponent extends FormReactive implements OnInit {
+  form: FormGroup
+  formErrors = {
+    'password': '',
+    'password-confirm': ''
+  }
+  validationMessages = {
+    'password': USER_PASSWORD.MESSAGES,
+    'password-confirm': {
+      'required': 'Confirmation of the password is required.'
+    }
+  }
+
+  private userId: number
+  private verificationString: string
+
+  constructor (
+    private authService: AuthService,
+    private userService: UserService,
+    private notificationsService: NotificationsService,
+    private formBuilder: FormBuilder,
+    private router: Router,
+    private route: ActivatedRoute
+  ) {
+    super()
+  }
+
+  buildForm () {
+    this.form = this.formBuilder.group({
+      password: [ '', USER_PASSWORD.VALIDATORS ],
+      'password-confirm': [ '', Validators.required ]
+    })
+
+    this.form.valueChanges.subscribe(data => this.onValueChanged(data))
+  }
+
+  ngOnInit () {
+    this.buildForm()
+
+    this.userId = this.route.snapshot.queryParams['userId']
+    this.verificationString = this.route.snapshot.queryParams['verificationString']
+
+    if (!this.userId || !this.verificationString) {
+      this.notificationsService.error('Error', 'Unable to find user id or verification string.')
+      this.router.navigate([ '/' ])
+    }
+  }
+
+  resetPassword () {
+    this.userService.resetPassword(this.userId, this.verificationString, this.form.value.password)
+      .subscribe(
+        () => {
+          this.notificationsService.success('Success', 'Your password has been successfully reset!')
+          this.router.navigate([ '/login' ])
+        },
+
+        err => this.notificationsService.error('Error', err.message)
+      )
+  }
+
+  isConfirmedPasswordValid () {
+    const values = this.form.value
+    return values.password === values['password-confirm']
+  }
+}
diff --git a/client/src/app/reset-password/reset-password.module.ts b/client/src/app/reset-password/reset-password.module.ts
new file mode 100644 (file)
index 0000000..c271198
--- /dev/null
@@ -0,0 +1,24 @@
+import { NgModule } from '@angular/core'
+
+import { ResetPasswordRoutingModule } from './reset-password-routing.module'
+import { ResetPasswordComponent } from './reset-password.component'
+import { SharedModule } from '../shared'
+
+@NgModule({
+  imports: [
+    ResetPasswordRoutingModule,
+    SharedModule
+  ],
+
+  declarations: [
+    ResetPasswordComponent
+  ],
+
+  exports: [
+    ResetPasswordComponent
+  ],
+
+  providers: [
+  ]
+})
+export class ResetPasswordModule { }
index 742fb0728243c717a925116af11c6bdf5c9d8c4a..da7b583f4d833683417b0131958ae0a68a065e47 100644 (file)
@@ -5,7 +5,6 @@ import 'rxjs/add/operator/map'
 import { UserCreate, UserUpdateMe } from '../../../../../shared'
 import { environment } from '../../../environments/environment'
 import { RestExtractor } from '../rest'
-import { User } from './user.model'
 
 @Injectable()
 export class UserService {
@@ -54,4 +53,24 @@ export class UserService {
     return this.authHttp.get(url)
       .catch(res => this.restExtractor.handleError(res))
   }
+
+  askResetPassword (email: string) {
+    const url = UserService.BASE_USERS_URL + '/ask-reset-password'
+
+    return this.authHttp.post(url, { email })
+      .map(this.restExtractor.extractDataBool)
+      .catch(res => this.restExtractor.handleError(res))
+  }
+
+  resetPassword (userId: number, verificationString: string, password: string) {
+    const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password`
+    const body = {
+      verificationString,
+      password
+    }
+
+    return this.authHttp.post(url, body)
+      .map(this.restExtractor.extractDataBool)
+      .catch(res => this.restExtractor.handleError(res))
+  }
 }
index c2d7f1d6e83427ae4855f69cb84035ca4a1385a1..fbe104aa0e75e84d678ef8a3d80f4f15e6c4ca81 100644 (file)
  */
 
 /** IE9, IE10 and IE11 requires all of the following polyfills. **/
-// import 'core-js/es6/symbol';
-// import 'core-js/es6/object';
-// import 'core-js/es6/function';
-// import 'core-js/es6/parse-int';
-// import 'core-js/es6/parse-float';
-// import 'core-js/es6/number';
-// import 'core-js/es6/math';
-// import 'core-js/es6/string';
-// import 'core-js/es6/date';
-// import 'core-js/es6/array';
-// import 'core-js/es6/regexp';
-// import 'core-js/es6/map';
-// import 'core-js/es6/weak-map';
-// import 'core-js/es6/set';
+
+// For Google Bot
+import 'core-js/es6/symbol';
+import 'core-js/es6/object';
+import 'core-js/es6/function';
+import 'core-js/es6/parse-int';
+import 'core-js/es6/parse-float';
+import 'core-js/es6/number';
+import 'core-js/es6/math';
+import 'core-js/es6/string';
+import 'core-js/es6/date';
+import 'core-js/es6/array';
+import 'core-js/es6/regexp';
+import 'core-js/es6/map';
+import 'core-js/es6/weak-map';
+import 'core-js/es6/set';
 
 /** IE10 and IE11 requires the following for NgClass support on SVG elements */
 // import 'classlist.js';  // Run `npm install --save classlist.js`.
 
 /** IE10 and IE11 requires the following for the Reflect API. */
-// import 'core-js/es6/reflect';
+
+// For Google Bot
+import 'core-js/es6/reflect';
 
 
 /** Evergreen browsers require these. **/
index 253bb1b3cd533a2d57a4bad64d68493341926330..33d7ce0a58e797c6c422b8f7c4e893bd013f0159 100644 (file)
@@ -19,7 +19,7 @@ $FontPathSourceSansPro: '../../node_modules/npm-font-source-sans-pro/fonts';
 }
 
 body {
-  font-family: 'Source Sans Pro';
+  font-family: 'Source Sans Pro', sans-serif;
   font-weight: $font-regular;
   color: #000;
 }
index fd04b5ce652580888e6d4b945fcce27be7bf1368..691c9e00b3d59647d7e47b6b335e2bd1aaecfe6b 100644 (file)
@@ -19,6 +19,15 @@ redis:
   port: 6379
   auth: null
 
+smtp:
+  hostname: null
+  port: 465
+  username: null
+  password: null
+  tls: true
+  ca_file: null # Used for self signed certificates
+  from_address: 'admin@example.com'
+
 # From the project root directory
 storage:
   avatars: 'storage/avatars/'
@@ -37,7 +46,7 @@ cache:
     size: 1 # Max number of previews you want to cache
 
 admin:
-  email: 'admin@example.com'
+  email: 'admin@example.com' # Your personal email as administrator
 
 signup:
   enabled: false
index a2b3329830577ac95b36dfd3839591a5a2908b3a..04354b75d5c072d9c79f0533f18691c54ab3b499 100644 (file)
@@ -20,6 +20,15 @@ redis:
   port: 6379
   auth: null
 
+smtp:
+  hostname: null
+  port: 465
+  username: null
+  password: null
+  tls: true
+  ca_file: null # Used for self signed certificates
+  from_address: 'admin@example.com'
+
 # From the project root directory
 storage:
   avatars: '/var/www/peertube/storage/avatars/'
index 9a455d2120f8b7326eb559448477c1ea93797bf6..1b06bcba141eafb48701f4bca85db1e302bd29ff 100644 (file)
     "mkdirp": "^0.5.1",
     "morgan": "^1.5.3",
     "multer": "^1.1.0",
+    "nodemailer": "^4.4.2",
     "parse-torrent": "^5.8.0",
     "password-generator": "^2.0.2",
     "pem": "^1.12.3",
     "pg": "^6.4.2",
     "pg-hstore": "^2.3.2",
+    "redis": "^2.8.0",
     "reflect-metadata": "^0.1.10",
     "request": "^2.81.0",
     "rimraf": "^2.5.4",
     "@types/morgan": "^1.7.32",
     "@types/multer": "^1.3.3",
     "@types/node": "^9.3.0",
+    "@types/nodemailer": "^4.3.1",
     "@types/pem": "^1.9.3",
+    "@types/redis": "^2.8.5",
     "@types/request": "^2.0.3",
     "@types/sequelize": "^4.0.55",
     "@types/sharp": "^0.17.6",
index 51f55547e8f4976ae03a2f564753b00d81593fa4..a822d5d2e7c466eae78056ed76c989f386ffb66f 100755 (executable)
@@ -3,12 +3,11 @@
 printf "############# PeerTube help #############\n\n"
 printf "npm run ...\n"
 printf "  build                       -> Build the application for production (alias of build:client:prod)\n"
-printf "  build:server:prod           -> Build the server for production\n"
-printf "  build:client:prod           -> Build the client for production\n"
-printf "  clean                       -> Clean the application\n"
+printf "  build:server                -> Build the server for production\n"
+printf "  build:client                -> Build the client for production\n"
 printf "  clean:client                -> Clean the client build files (dist directory)\n"
-printf "  clean:server:test           -> Clean certificates, logs, uploads and database of the test instances\n"
-printf "  watch:client                -> Watch the client files\n"
+printf "  clean:server:test           -> Clean logs, uploads, database... of the test instances\n"
+printf "  watch:client                -> Watch and compile on the fly the client files\n"
 printf "  danger:clean:dev            -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified in the development environment\n"
 printf "  danger:clean:prod           -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified by the production environment\n"
 printf "  danger:clean:modules        -> /!\ Clean node and typescript modules\n"
@@ -16,8 +15,7 @@ printf "  play                        -> Run 3 fresh nodes so that you can test
 printf "  reset-password -- -u [user] -> Reset the password of user [user]\n"
 printf "  dev                         -> Watch, run the livereload and run the server so that you can develop the application\n"
 printf "  start                       -> Run the server\n"
-printf "  check                       -> Check the server (according to NODE_ENV)\n"
-printf "  upgrade -- [branch]         -> Upgrade the application according to the [branch] parameter\n"
 printf "  update-host                 -> Upgrade scheme/host in torrent files according to the webserver configuration (config/ folder)\n"
+printf "  client-report               -> Open a report of the client dependencies module\n"
 printf "  test                        -> Run the tests\n"
 printf "  help                        -> Print this help\n"
index d0b351c62e0caa22f8ea5d908c5735fe3b572b57..44e93d1a6102324d97703cc9462b89b434305de2 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -53,9 +53,11 @@ migrate()
 
 // ----------- PeerTube modules -----------
 import { installApplication } from './server/initializers'
+import { Emailer } from './server/lib/emailer'
 import { JobQueue } from './server/lib/job-queue'
 import { VideosPreviewCache } from './server/lib/cache'
 import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers'
+import { Redis } from './server/lib/redis'
 import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler'
 import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
 
@@ -169,10 +171,20 @@ function onDatabaseInitDone () {
     .then(() => {
       // ----------- Make the server listening -----------
       server.listen(port, () => {
+        // Emailer initialization and then job queue initialization
+        Emailer.Instance.init()
+        Emailer.Instance.checkConnectionOrDie()
+          .then(() => JobQueue.Instance.init())
+
+        // Caches initializations
         VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE)
+
+        // Enable Schedulers
         BadActorFollowScheduler.Instance.enable()
         RemoveOldJobsScheduler.Instance.enable()
-        JobQueue.Instance.init()
+
+        // Redis initialization
+        Redis.Instance.init()
 
         logger.info('Server listening on port %d', port)
         logger.info('Web server: %s', CONFIG.WEBSERVER.URL)
index 79bb2665d458b697c95578f8c5d8cec3b0c94440..05639fbecb5306e62ed0e4088bffaee38bdf78fd 100644 (file)
@@ -6,17 +6,23 @@ import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRat
 import { unlinkPromise } from '../../helpers/core-utils'
 import { retryTransactionWrapper } from '../../helpers/database-utils'
 import { logger } from '../../helpers/logger'
-import { createReqFiles, getFormattedObjects } from '../../helpers/utils'
+import { createReqFiles, generateRandomString, getFormattedObjects } from '../../helpers/utils'
 import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers'
 import { updateActorAvatarInstance } from '../../lib/activitypub'
 import { sendUpdateUser } from '../../lib/activitypub/send'
+import { Emailer } from '../../lib/emailer'
+import { EmailPayload } from '../../lib/job-queue/handlers/email'
+import { Redis } from '../../lib/redis'
 import { createUserAccountAndChannel } from '../../lib/user'
 import {
   asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort,
   setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator,
   usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator
 } from '../../middlewares'
-import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators'
+import {
+  usersAskResetPasswordValidator, usersResetPasswordValidator, usersUpdateMyAvatarValidator,
+  videosSortValidator
+} from '../../middlewares/validators'
 import { AccountVideoRateModel } from '../../models/account/account-video-rate'
 import { UserModel } from '../../models/account/user'
 import { OAuthTokenModel } from '../../models/oauth/oauth-token'
@@ -106,6 +112,16 @@ usersRouter.delete('/:id',
   asyncMiddleware(removeUser)
 )
 
+usersRouter.post('/ask-reset-password',
+  asyncMiddleware(usersAskResetPasswordValidator),
+  asyncMiddleware(askResetUserPassword)
+)
+
+usersRouter.post('/:id/reset-password',
+  asyncMiddleware(usersResetPasswordValidator),
+  asyncMiddleware(resetUserPassword)
+)
+
 usersRouter.post('/token', token, success)
 // TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route
 
@@ -307,6 +323,25 @@ async function updateUser (req: express.Request, res: express.Response, next: ex
   return res.sendStatus(204)
 }
 
+async function askResetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) {
+  const user = res.locals.user as UserModel
+
+  const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id)
+  const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString
+  await Emailer.Instance.addForgetPasswordEmailJob(user.email, url)
+
+  return res.status(204).end()
+}
+
+async function resetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) {
+  const user = res.locals.user as UserModel
+  user.password = req.body.password
+
+  await user.save()
+
+  return res.status(204).end()
+}
+
 function success (req: express.Request, res: express.Response, next: express.NextFunction) {
   res.end()
 }
index 10e8cabc8ae73d794db4e9e89a00fedc24443712..c353f55daa219b370b9b6e37f7828b50c943b3cf 100644 (file)
@@ -26,6 +26,7 @@ const loggerFormat = winston.format.printf((info) => {
   if (additionalInfos === '{}') additionalInfos = ''
   else additionalInfos = ' ' + additionalInfos
 
+  if (info.message.stack !== undefined) info.message = info.message.stack
   return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}`
 })
 
index 35fab244cf6ca04a248a6b2c8d4db5764415423d..d550fd23f869d3bfbc7b3cfa021ea604bc5da032 100644 (file)
@@ -22,7 +22,8 @@ function checkMissedConfig () {
     'webserver.https', 'webserver.hostname', 'webserver.port',
     'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password',
     'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level',
-    'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', 'user.video_quota'
+    'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads',
+    'user.video_quota', 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address'
   ]
   const miss: string[] = []
 
index 03828f54f0e2b33e2e36459acdcd465018424900..e7b1656e261192ca796b9967f1c7b79722f299bd 100644 (file)
@@ -65,13 +65,15 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = {
   'activitypub-http-broadcast': 5,
   'activitypub-http-unicast': 5,
   'activitypub-http-fetcher': 5,
-  'video-file': 1
+  'video-file': 1,
+  'email': 5
 }
 const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
   'activitypub-http-broadcast': 1,
   'activitypub-http-unicast': 5,
   'activitypub-http-fetcher': 1,
-  'video-file': 1
+  'video-file': 1,
+  'email': 5
 }
 // 2 days
 const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2
@@ -95,9 +97,18 @@ const CONFIG = {
   },
   REDIS: {
     HOSTNAME: config.get<string>('redis.hostname'),
-    PORT: config.get<string>('redis.port'),
+    PORT: config.get<number>('redis.port'),
     AUTH: config.get<string>('redis.auth')
   },
+  SMTP: {
+    HOSTNAME: config.get<string>('smtp.hostname'),
+    PORT: config.get<number>('smtp.port'),
+    USERNAME: config.get<string>('smtp.username'),
+    PASSWORD: config.get<string>('smtp.password'),
+    TLS: config.get<boolean>('smtp.tls'),
+    CA_FILE: config.get<string>('smtp.ca_file'),
+    FROM_ADDRESS: config.get<string>('smtp.from_address')
+  },
   STORAGE: {
     AVATARS_DIR: buildPath(config.get<string>('storage.avatars')),
     LOG_DIR: buildPath(config.get<string>('storage.logs')),
@@ -311,6 +322,8 @@ const PRIVATE_RSA_KEY_SIZE = 2048
 // Password encryption
 const BCRYPT_SALT_SIZE = 10
 
+const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes
+
 // ---------------------------------------------------------------------------
 
 // Express static paths (router)
@@ -408,6 +421,7 @@ export {
   VIDEO_LICENCES,
   VIDEO_RATE_TYPES,
   VIDEO_MIMETYPE_EXT,
+  USER_PASSWORD_RESET_LIFETIME,
   AVATAR_MIMETYPE_EXT,
   SCHEDULER_INTERVAL,
   JOB_COMPLETED_LIFETIME
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
new file mode 100644 (file)
index 0000000..f5b6864
--- /dev/null
@@ -0,0 +1,106 @@
+import { createTransport, Transporter } from 'nodemailer'
+import { isTestInstance } from '../helpers/core-utils'
+import { logger } from '../helpers/logger'
+import { CONFIG } from '../initializers'
+import { JobQueue } from './job-queue'
+import { EmailPayload } from './job-queue/handlers/email'
+import { readFileSync } from 'fs'
+
+class Emailer {
+
+  private static instance: Emailer
+  private initialized = false
+  private transporter: Transporter
+
+  private constructor () {}
+
+  init () {
+    // Already initialized
+    if (this.initialized === true) return
+    this.initialized = true
+
+    if (CONFIG.SMTP.HOSTNAME && CONFIG.SMTP.PORT) {
+      logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT)
+
+      let tls
+      if (CONFIG.SMTP.CA_FILE) {
+        tls = {
+          ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ]
+        }
+      }
+
+      this.transporter = createTransport({
+        host: CONFIG.SMTP.HOSTNAME,
+        port: CONFIG.SMTP.PORT,
+        secure: CONFIG.SMTP.TLS,
+        tls,
+        auth: {
+          user: CONFIG.SMTP.USERNAME,
+          pass: CONFIG.SMTP.PASSWORD
+        }
+      })
+    } else {
+      if (!isTestInstance()) {
+        logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!')
+      }
+    }
+  }
+
+  async checkConnectionOrDie () {
+    if (!this.transporter) return
+
+    try {
+      const success = await this.transporter.verify()
+      if (success !== true) this.dieOnConnectionFailure()
+
+      logger.info('Successfully connected to SMTP server.')
+    } catch (err) {
+      this.dieOnConnectionFailure(err)
+    }
+  }
+
+  addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) {
+    const text = `Hi dear user,\n\n` +
+      `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` +
+      `Please follow this link to reset it: ${resetPasswordUrl}.\n\n` +
+      `If you are not the person who initiated this request, please ignore this email.\n\n` +
+      `Cheers,\n` +
+      `PeerTube.`
+
+    const emailPayload: EmailPayload = {
+      to: [ to ],
+      subject: 'Reset your PeerTube password',
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
+  sendMail (to: string[], subject: string, text: string) {
+    if (!this.transporter) {
+      throw new Error('Cannot send mail because SMTP is not configured.')
+    }
+
+    return this.transporter.sendMail({
+      from: CONFIG.SMTP.FROM_ADDRESS,
+      to: to.join(','),
+      subject,
+      text
+    })
+  }
+
+  private dieOnConnectionFailure (err?: Error) {
+    logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, err)
+    process.exit(-1)
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  Emailer
+}
diff --git a/server/lib/job-queue/handlers/email.ts b/server/lib/job-queue/handlers/email.ts
new file mode 100644 (file)
index 0000000..9d76861
--- /dev/null
@@ -0,0 +1,22 @@
+import * as kue from 'kue'
+import { logger } from '../../../helpers/logger'
+import { Emailer } from '../../emailer'
+
+export type EmailPayload = {
+  to: string[]
+  subject: string
+  text: string
+}
+
+async function processEmail (job: kue.Job) {
+  const payload = job.data as EmailPayload
+  logger.info('Processing email in job %d.', job.id)
+
+  return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  processEmail
+}
index 7a2b6c78d43883bf4fc9c5a10ae7050c62b3b51b..3f176f896064ba19e3ed3c3999054e28a9775824 100644 (file)
@@ -5,19 +5,22 @@ import { CONFIG, JOB_ATTEMPTS, JOB_COMPLETED_LIFETIME, JOB_CONCURRENCY } from '.
 import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast'
 import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher'
 import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast'
+import { EmailPayload, processEmail } from './handlers/email'
 import { processVideoFile, VideoFilePayload } from './handlers/video-file'
 
 type CreateJobArgument =
   { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
   { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } |
   { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } |
-  { type: 'video-file', payload: VideoFilePayload }
+  { type: 'video-file', payload: VideoFilePayload } |
+  { type: 'email', payload: EmailPayload }
 
 const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = {
   'activitypub-http-broadcast': processActivityPubHttpBroadcast,
   'activitypub-http-unicast': processActivityPubHttpUnicast,
   'activitypub-http-fetcher': processActivityPubHttpFetcher,
-  'video-file': processVideoFile
+  'video-file': processVideoFile,
+  'email': processEmail
 }
 
 class JobQueue {
@@ -43,6 +46,8 @@ class JobQueue {
       }
     })
 
+    this.jobQueue.setMaxListeners(15)
+
     this.jobQueue.on('error', err => {
       logger.error('Error in job queue.', err)
       process.exit(-1)
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
new file mode 100644 (file)
index 0000000..4240cc1
--- /dev/null
@@ -0,0 +1,84 @@
+import { createClient, RedisClient } from 'redis'
+import { logger } from '../helpers/logger'
+import { generateRandomString } from '../helpers/utils'
+import { CONFIG, USER_PASSWORD_RESET_LIFETIME } from '../initializers'
+
+class Redis {
+
+  private static instance: Redis
+  private initialized = false
+  private client: RedisClient
+  private prefix: string
+
+  private constructor () {}
+
+  init () {
+    // Already initialized
+    if (this.initialized === true) return
+    this.initialized = true
+
+    this.client = createClient({
+      host: CONFIG.REDIS.HOSTNAME,
+      port: CONFIG.REDIS.PORT
+    })
+
+    this.client.on('error', err => {
+      logger.error('Error in Redis client.', err)
+      process.exit(-1)
+    })
+
+    if (CONFIG.REDIS.AUTH) {
+      this.client.auth(CONFIG.REDIS.AUTH)
+    }
+
+    this.prefix = 'redis-' + CONFIG.WEBSERVER.HOST + '-'
+  }
+
+  async setResetPasswordVerificationString (userId: number) {
+    const generatedString = await generateRandomString(32)
+
+    await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_RESET_LIFETIME)
+
+    return generatedString
+  }
+
+  async getResetPasswordLink (userId: number) {
+    return this.getValue(this.generateResetPasswordKey(userId))
+  }
+
+  private getValue (key: string) {
+    return new Promise<string>((res, rej) => {
+      this.client.get(this.prefix + key, (err, value) => {
+        if (err) return rej(err)
+
+        return res(value)
+      })
+    })
+  }
+
+  private setValue (key: string, value: string, expirationMilliseconds: number) {
+    return new Promise<void>((res, rej) => {
+      this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => {
+        if (err) return rej(err)
+
+        if (ok !== 'OK') return rej(new Error('Redis result is not OK.'))
+
+        return res()
+      })
+    })
+  }
+
+  private generateResetPasswordKey (userId: number) {
+    return 'reset-password-' + userId
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  Redis
+}
index b6591c9e1a99c9d4e2857ac2b14dc5e81c8d8b9b..5f44c3b999874f1b5c58e58bbf76c0a769ba53f8 100644 (file)
@@ -1,18 +1,25 @@
+import * as Bluebird from 'bluebird'
 import * as express from 'express'
 import 'express-validator'
 import { body, param } from 'express-validator/check'
+import { omit } from 'lodash'
 import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
 import {
-  isAvatarFile, isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid,
+  isAvatarFile,
+  isUserAutoPlayVideoValid,
+  isUserDisplayNSFWValid,
+  isUserPasswordValid,
+  isUserRoleValid,
+  isUserUsernameValid,
   isUserVideoQuotaValid
 } from '../../helpers/custom-validators/users'
 import { isVideoExist } from '../../helpers/custom-validators/videos'
 import { logger } from '../../helpers/logger'
 import { isSignupAllowed } from '../../helpers/utils'
 import { CONSTRAINTS_FIELDS } from '../../initializers'
+import { Redis } from '../../lib/redis'
 import { UserModel } from '../../models/account/user'
 import { areValidationErrors } from './utils'
-import { omit } from 'lodash'
 
 const usersAddValidator = [
   body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'),
@@ -167,6 +174,49 @@ const ensureUserRegistrationAllowed = [
   }
 ]
 
+const usersAskResetPasswordValidator = [
+  body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    const exists = await checkUserEmailExist(req.body.email, res, false)
+    if (!exists) {
+      logger.debug('User with email %s does not exist (asking reset password).', req.body.email)
+      // Do not leak our emails
+      return res.status(204).end()
+    }
+
+    return next()
+  }
+]
+
+const usersResetPasswordValidator = [
+  param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
+  body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'),
+  body('password').custom(isUserPasswordValid).withMessage('Should have a valid password'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking usersResetPassword parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+    if (!await checkUserIdExist(req.params.id, res)) return
+
+    const user = res.locals.user as UserModel
+    const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id)
+
+    if (redisVerificationString !== req.body.verificationString) {
+      return res
+        .status(403)
+        .send({ error: 'Invalid verification string.' })
+        .end
+    }
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -178,24 +228,19 @@ export {
   usersVideoRatingValidator,
   ensureUserRegistrationAllowed,
   usersGetValidator,
-  usersUpdateMyAvatarValidator
+  usersUpdateMyAvatarValidator,
+  usersAskResetPasswordValidator,
+  usersResetPasswordValidator
 }
 
 // ---------------------------------------------------------------------------
 
-async function checkUserIdExist (id: number, res: express.Response) {
-  const user = await UserModel.loadById(id)
-
-  if (!user) {
-    res.status(404)
-              .send({ error: 'User not found' })
-              .end()
-
-    return false
-  }
+function checkUserIdExist (id: number, res: express.Response) {
+  return checkUserExist(() => UserModel.loadById(id), res)
+}
 
-  res.locals.user = user
-  return true
+function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
+  return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
 }
 
 async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
@@ -210,3 +255,21 @@ async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email:
 
   return true
 }
+
+async function checkUserExist (finder: () => Bluebird<UserModel>, res: express.Response, abortResponse = true) {
+  const user = await finder()
+
+  if (!user) {
+    if (abortResponse === true) {
+      res.status(404)
+        .send({ error: 'User not found' })
+        .end()
+    }
+
+    return false
+  }
+
+  res.locals.user = user
+
+  return true
+}
index 809e821bd389bbee3cf9a9b2d99ac841657017cc..026a8c9a0d6841d83180cdcf1d3b25625f401cf2 100644 (file)
@@ -161,6 +161,16 @@ export class UserModel extends Model<UserModel> {
     return UserModel.scope('withVideoChannel').findOne(query)
   }
 
+  static loadByEmail (email: string) {
+    const query = {
+      where: {
+        email
+      }
+    }
+
+    return UserModel.findOne(query)
+  }
+
   static loadByUsernameOrEmail (username: string, email?: string) {
     if (!email) email = username
 
index 1a25600f37b15b6029ad0c4f691ed02d445fa195..5ebb75a5c08ba74aed9172901c708b2d9032fd79 100644 (file)
@@ -3,7 +3,8 @@ export type JobState = 'active' | 'complete' | 'failed' | 'inactive' | 'delayed'
 export type JobType = 'activitypub-http-unicast' |
   'activitypub-http-broadcast' |
   'activitypub-http-fetcher' |
-  'video-file'
+  'video-file' |
+  'email'
 
 export interface Job {
   id: number
index 1c1472aae470e72fc477486e71a9c584cd5d77e9..70d2c51c2aa374740cf92e02cacf81f3bfd04351 100644 (file)
@@ -19,6 +19,8 @@
   },
   "exclude": [
     "node_modules",
+    "dist",
+    "storage",
     "client",
     "test1",
     "test2",
index a3f6fce8a79c0f6ebb6b1c9529e44c307414ecb1..b69b33ed717438ad0e5306ee0288b4d93cbc349f 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   version "6.0.41"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.41.tgz#578cf53aaec65887bcaf16792f8722932e8ff8ea"
 
+"@types/nodemailer@^4.3.1":
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-4.3.1.tgz#e3985c1b7c7bbbb2a886108b89f1c7ce9a690654"
+  dependencies:
+    "@types/node" "*"
+
 "@types/parse-torrent-file@*":
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.1.tgz#056a6c18f3fac0cd7c6c74540f00496a3225976b"
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.3.tgz#0c864c8b79e43fef6367db895f60fd1edd10e86c"
 
-"@types/redis@*":
+"@types/redis@*", "@types/redis@^2.8.5":
   version "2.8.5"
   resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.5.tgz#c4a31a63e95434202eb84908290528ad8510b149"
   dependencies:
@@ -4274,6 +4280,10 @@ node-sass@^4.0.0:
     stdout-stream "^1.4.0"
     "true-case-path" "^1.0.2"
 
+nodemailer@^4.4.2:
+  version "4.4.2"
+  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.4.2.tgz#f215fb88e8a1052f9f93083909e116d2b79fc8de"
+
 nodemon@^1.11.0:
   version "1.14.11"
   resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.14.11.tgz#cc0009dd8d82f126f3aba50ace7e753827a8cebc"
@@ -5149,7 +5159,7 @@ redis-commands@^1.2.0:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.3.1.tgz#81d826f45fa9c8b2011f4cd7a0fe597d241d442b"
 
-redis-parser@^2.0.0:
+redis-parser@^2.0.0, redis-parser@^2.6.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b"
 
@@ -5157,6 +5167,14 @@ redis@^0.12.1:
   version "0.12.1"
   resolved "https://registry.yarnpkg.com/redis/-/redis-0.12.1.tgz#64df76ad0fc8acebaebd2a0645e8a48fac49185e"
 
+redis@^2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
+  dependencies:
+    double-ended-queue "^2.1.0-0"
+    redis-commands "^1.2.0"
+    redis-parser "^2.6.0"
+
 redis@~2.6.0-2:
   version "2.6.5"
   resolved "https://registry.yarnpkg.com/redis/-/redis-2.6.5.tgz#87c1eff4a489f94b70871f3d08b6988f23a95687"