diff options
32 files changed, 739 insertions, 65 deletions
diff --git a/.gitignore b/.gitignore index 96e888fd4..9dc03a6a1 100644 --- a/.gitignore +++ b/.gitignore | |||
@@ -7,7 +7,7 @@ | |||
7 | /test6/ | 7 | /test6/ |
8 | /storage/ | 8 | /storage/ |
9 | /config/production.yaml | 9 | /config/production.yaml |
10 | /config/local*.json | 10 | /config/local* |
11 | /ffmpeg/ | 11 | /ffmpeg/ |
12 | /*.sublime-project | 12 | /*.sublime-project |
13 | /*.sublime-workspace | 13 | /*.sublime-workspace |
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index e69edbc4b..ddcaf3f48 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts | |||
@@ -1,19 +1,20 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { BrowserModule } from '@angular/platform-browser' | 2 | import { BrowserModule } from '@angular/platform-browser' |
3 | import { ResetPasswordModule } from '@app/reset-password' | ||
3 | 4 | ||
4 | import { MetaModule, MetaLoader, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' | 5 | import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' |
6 | |||
7 | import { AccountModule } from './account' | ||
5 | 8 | ||
6 | import { AppRoutingModule } from './app-routing.module' | 9 | import { AppRoutingModule } from './app-routing.module' |
7 | import { AppComponent } from './app.component' | 10 | import { AppComponent } from './app.component' |
8 | |||
9 | import { AccountModule } from './account' | ||
10 | import { CoreModule } from './core' | 11 | import { CoreModule } from './core' |
12 | import { HeaderComponent } from './header' | ||
11 | import { LoginModule } from './login' | 13 | import { LoginModule } from './login' |
12 | import { SignupModule } from './signup' | 14 | import { MenuComponent } from './menu' |
13 | import { SharedModule } from './shared' | 15 | import { SharedModule } from './shared' |
16 | import { SignupModule } from './signup' | ||
14 | import { VideosModule } from './videos' | 17 | import { VideosModule } from './videos' |
15 | import { MenuComponent } from './menu' | ||
16 | import { HeaderComponent } from './header' | ||
17 | 18 | ||
18 | export function metaFactory (): MetaLoader { | 19 | export function metaFactory (): MetaLoader { |
19 | return new MetaStaticLoader({ | 20 | return new MetaStaticLoader({ |
@@ -46,6 +47,7 @@ export function metaFactory (): MetaLoader { | |||
46 | AccountModule, | 47 | AccountModule, |
47 | CoreModule, | 48 | CoreModule, |
48 | LoginModule, | 49 | LoginModule, |
50 | ResetPasswordModule, | ||
49 | SignupModule, | 51 | SignupModule, |
50 | SharedModule, | 52 | SharedModule, |
51 | VideosModule, | 53 | VideosModule, |
diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html index b61b66ec7..660a08280 100644 --- a/client/src/app/login/login.component.html +++ b/client/src/app/login/login.component.html | |||
@@ -19,10 +19,13 @@ | |||
19 | 19 | ||
20 | <div class="form-group"> | 20 | <div class="form-group"> |
21 | <label for="password">Password</label> | 21 | <label for="password">Password</label> |
22 | <input | 22 | <div> |
23 | type="password" name="password" id="password" placeholder="Password" required | 23 | <input |
24 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | 24 | type="password" name="password" id="password" placeholder="Password" required |
25 | > | 25 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" |
26 | > | ||
27 | <div class="forgot-password-button" (click)="openForgotPasswordModal()">I forgot my password</div> | ||
28 | </div> | ||
26 | <div *ngIf="formErrors.password" class="form-error"> | 29 | <div *ngIf="formErrors.password" class="form-error"> |
27 | {{ formErrors.password }} | 30 | {{ formErrors.password }} |
28 | </div> | 31 | </div> |
@@ -31,3 +34,36 @@ | |||
31 | <input type="submit" value="Login" [disabled]="!form.valid"> | 34 | <input type="submit" value="Login" [disabled]="!form.valid"> |
32 | </form> | 35 | </form> |
33 | </div> | 36 | </div> |
37 | |||
38 | <div bsModal #forgotPasswordModal="bs-modal" (onShown)="onForgotPasswordModalShown()" class="modal" tabindex="-1"> | ||
39 | <div class="modal-dialog"> | ||
40 | <div class="modal-content"> | ||
41 | |||
42 | <div class="modal-header"> | ||
43 | <span class="close" aria-hidden="true" (click)="hideForgotPasswordModal()"></span> | ||
44 | <h4 class="modal-title">Forgot your password</h4> | ||
45 | </div> | ||
46 | |||
47 | <div class="modal-body"> | ||
48 | <div class="form-group"> | ||
49 | <label for="forgot-password-email">Email</label> | ||
50 | <input | ||
51 | type="email" id="forgot-password-email" placeholder="Email address" required | ||
52 | [(ngModel)]="forgotPasswordEmail" #forgotPasswordEmailInput | ||
53 | > | ||
54 | </div> | ||
55 | |||
56 | <div class="form-group inputs"> | ||
57 | <span class="action-button action-button-cancel" (click)="hideForgotPasswordModal()"> | ||
58 | Cancel | ||
59 | </span> | ||
60 | |||
61 | <input | ||
62 | type="submit" value="Send me an email to reset my password" class="action-button-submit" | ||
63 | (click)="askResetPassword()" [disabled]="!forgotPasswordEmailInput.validity.valid" | ||
64 | > | ||
65 | </div> | ||
66 | </div> | ||
67 | </div> | ||
68 | </div> | ||
69 | </div> | ||
diff --git a/client/src/app/login/login.component.scss b/client/src/app/login/login.component.scss index efec6b706..2cf6991ce 100644 --- a/client/src/app/login/login.component.scss +++ b/client/src/app/login/login.component.scss | |||
@@ -10,3 +10,13 @@ input[type=submit] { | |||
10 | @include peertube-button; | 10 | @include peertube-button; |
11 | @include orange-button; | 11 | @include orange-button; |
12 | } | 12 | } |
13 | |||
14 | input[type=password] { | ||
15 | display: inline-block; | ||
16 | margin-right: 5px; | ||
17 | } | ||
18 | |||
19 | .forgot-password-button { | ||
20 | display: inline-block; | ||
21 | cursor: pointer; | ||
22 | } | ||
diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts index e7c9c7226..22e8c77dd 100644 --- a/client/src/app/login/login.component.ts +++ b/client/src/app/login/login.component.ts | |||
@@ -1,7 +1,9 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' |
2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms' | 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms' |
3 | import { Router } from '@angular/router' | 3 | import { Router } from '@angular/router' |
4 | 4 | import { UserService } from '@app/shared' | |
5 | import { NotificationsService } from 'angular2-notifications' | ||
6 | import { ModalDirective } from 'ngx-bootstrap/modal' | ||
5 | import { AuthService } from '../core' | 7 | import { AuthService } from '../core' |
6 | import { FormReactive } from '../shared' | 8 | import { FormReactive } from '../shared' |
7 | 9 | ||
@@ -12,6 +14,9 @@ import { FormReactive } from '../shared' | |||
12 | }) | 14 | }) |
13 | 15 | ||
14 | export class LoginComponent extends FormReactive implements OnInit { | 16 | export class LoginComponent extends FormReactive implements OnInit { |
17 | @ViewChild('forgotPasswordModal') forgotPasswordModal: ModalDirective | ||
18 | @ViewChild('forgotPasswordEmailInput') forgotPasswordEmailInput: ElementRef | ||
19 | |||
15 | error: string = null | 20 | error: string = null |
16 | 21 | ||
17 | form: FormGroup | 22 | form: FormGroup |
@@ -27,9 +32,12 @@ export class LoginComponent extends FormReactive implements OnInit { | |||
27 | 'required': 'Password is required.' | 32 | 'required': 'Password is required.' |
28 | } | 33 | } |
29 | } | 34 | } |
35 | forgotPasswordEmail = '' | ||
30 | 36 | ||
31 | constructor ( | 37 | constructor ( |
32 | private authService: AuthService, | 38 | private authService: AuthService, |
39 | private userService: UserService, | ||
40 | private notificationsService: NotificationsService, | ||
33 | private formBuilder: FormBuilder, | 41 | private formBuilder: FormBuilder, |
34 | private router: Router | 42 | private router: Router |
35 | ) { | 43 | ) { |
@@ -60,4 +68,29 @@ export class LoginComponent extends FormReactive implements OnInit { | |||
60 | err => this.error = err.message | 68 | err => this.error = err.message |
61 | ) | 69 | ) |
62 | } | 70 | } |
71 | |||
72 | askResetPassword () { | ||
73 | this.userService.askResetPassword(this.forgotPasswordEmail) | ||
74 | .subscribe( | ||
75 | res => { | ||
76 | const message = `An email with the reset password instructions will be sent to ${this.forgotPasswordEmail}.` | ||
77 | this.notificationsService.success('Success', message) | ||
78 | this.hideForgotPasswordModal() | ||
79 | }, | ||
80 | |||
81 | err => this.notificationsService.error('Error', err.message) | ||
82 | ) | ||
83 | } | ||
84 | |||
85 | onForgotPasswordModalShown () { | ||
86 | this.forgotPasswordEmailInput.nativeElement.focus() | ||
87 | } | ||
88 | |||
89 | openForgotPasswordModal () { | ||
90 | this.forgotPasswordModal.show() | ||
91 | } | ||
92 | |||
93 | hideForgotPasswordModal () { | ||
94 | this.forgotPasswordModal.hide() | ||
95 | } | ||
63 | } | 96 | } |
diff --git a/client/src/app/reset-password/index.ts b/client/src/app/reset-password/index.ts new file mode 100644 index 000000000..438dc576a --- /dev/null +++ b/client/src/app/reset-password/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './reset-password-routing.module' | ||
2 | export * from './reset-password.component' | ||
3 | 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 index 000000000..b41069568 --- /dev/null +++ b/client/src/app/reset-password/reset-password-routing.module.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | |||
4 | import { MetaGuard } from '@ngx-meta/core' | ||
5 | |||
6 | import { ResetPasswordComponent } from './reset-password.component' | ||
7 | |||
8 | const resetPasswordRoutes: Routes = [ | ||
9 | { | ||
10 | path: 'reset-password', | ||
11 | component: ResetPasswordComponent, | ||
12 | canActivate: [ MetaGuard ], | ||
13 | data: { | ||
14 | meta: { | ||
15 | title: 'Reset password' | ||
16 | } | ||
17 | } | ||
18 | } | ||
19 | ] | ||
20 | |||
21 | @NgModule({ | ||
22 | imports: [ RouterModule.forChild(resetPasswordRoutes) ], | ||
23 | exports: [ RouterModule ] | ||
24 | }) | ||
25 | 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 index 000000000..d142c523f --- /dev/null +++ b/client/src/app/reset-password/reset-password.component.html | |||
@@ -0,0 +1,33 @@ | |||
1 | <div class="margin-content"> | ||
2 | <div class="title-page title-page-single"> | ||
3 | Reset my password | ||
4 | </div> | ||
5 | |||
6 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | ||
7 | |||
8 | <form role="form" (ngSubmit)="resetPassword()" [formGroup]="form"> | ||
9 | <div class="form-group"> | ||
10 | <label for="password">Password</label> | ||
11 | <input | ||
12 | type="password" name="password" id="password" placeholder="Password" required | ||
13 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | ||
14 | > | ||
15 | <div *ngIf="formErrors.password" class="form-error"> | ||
16 | {{ formErrors.password }} | ||
17 | </div> | ||
18 | </div> | ||
19 | |||
20 | <div class="form-group"> | ||
21 | <label for="password-confirm">Confirm password</label> | ||
22 | <input | ||
23 | type="password" name="password-confirm" id="password-confirm" placeholder="Confirmed password" required | ||
24 | formControlName="password-confirm" [ngClass]="{ 'input-error': formErrors['password-confirm'] }" | ||
25 | > | ||
26 | <div *ngIf="formErrors['password-confirm']" class="form-error"> | ||
27 | {{ formErrors['password-confirm'] }} | ||
28 | </div> | ||
29 | </div> | ||
30 | |||
31 | <input type="submit" value="Reset my password" [disabled]="!form.valid && isConfirmedPasswordValid()"> | ||
32 | </form> | ||
33 | </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 index 000000000..efec6b706 --- /dev/null +++ b/client/src/app/reset-password/reset-password.component.scss | |||
@@ -0,0 +1,12 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | input:not([type=submit]) { | ||
5 | @include peertube-input-text(340px); | ||
6 | display: block; | ||
7 | } | ||
8 | |||
9 | input[type=submit] { | ||
10 | @include peertube-button; | ||
11 | @include orange-button; | ||
12 | } | ||
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 index 000000000..408374779 --- /dev/null +++ b/client/src/app/reset-password/reset-password.component.ts | |||
@@ -0,0 +1,79 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms' | ||
3 | import { ActivatedRoute, Router } from '@angular/router' | ||
4 | import { USER_PASSWORD, UserService } from '@app/shared' | ||
5 | import { NotificationsService } from 'angular2-notifications' | ||
6 | import { AuthService } from '../core' | ||
7 | import { FormReactive } from '../shared' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-login', | ||
11 | templateUrl: './reset-password.component.html', | ||
12 | styleUrls: [ './reset-password.component.scss' ] | ||
13 | }) | ||
14 | |||
15 | export class ResetPasswordComponent extends FormReactive implements OnInit { | ||
16 | form: FormGroup | ||
17 | formErrors = { | ||
18 | 'password': '', | ||
19 | 'password-confirm': '' | ||
20 | } | ||
21 | validationMessages = { | ||
22 | 'password': USER_PASSWORD.MESSAGES, | ||
23 | 'password-confirm': { | ||
24 | 'required': 'Confirmation of the password is required.' | ||
25 | } | ||
26 | } | ||
27 | |||
28 | private userId: number | ||
29 | private verificationString: string | ||
30 | |||
31 | constructor ( | ||
32 | private authService: AuthService, | ||
33 | private userService: UserService, | ||
34 | private notificationsService: NotificationsService, | ||
35 | private formBuilder: FormBuilder, | ||
36 | private router: Router, | ||
37 | private route: ActivatedRoute | ||
38 | ) { | ||
39 | super() | ||
40 | } | ||
41 | |||
42 | buildForm () { | ||
43 | this.form = this.formBuilder.group({ | ||
44 | password: [ '', USER_PASSWORD.VALIDATORS ], | ||
45 | 'password-confirm': [ '', Validators.required ] | ||
46 | }) | ||
47 | |||
48 | this.form.valueChanges.subscribe(data => this.onValueChanged(data)) | ||
49 | } | ||
50 | |||
51 | ngOnInit () { | ||
52 | this.buildForm() | ||
53 | |||
54 | this.userId = this.route.snapshot.queryParams['userId'] | ||
55 | this.verificationString = this.route.snapshot.queryParams['verificationString'] | ||
56 | |||
57 | if (!this.userId || !this.verificationString) { | ||
58 | this.notificationsService.error('Error', 'Unable to find user id or verification string.') | ||
59 | this.router.navigate([ '/' ]) | ||
60 | } | ||
61 | } | ||
62 | |||
63 | resetPassword () { | ||
64 | this.userService.resetPassword(this.userId, this.verificationString, this.form.value.password) | ||
65 | .subscribe( | ||
66 | () => { | ||
67 | this.notificationsService.success('Success', 'Your password has been successfully reset!') | ||
68 | this.router.navigate([ '/login' ]) | ||
69 | }, | ||
70 | |||
71 | err => this.notificationsService.error('Error', err.message) | ||
72 | ) | ||
73 | } | ||
74 | |||
75 | isConfirmedPasswordValid () { | ||
76 | const values = this.form.value | ||
77 | return values.password === values['password-confirm'] | ||
78 | } | ||
79 | } | ||
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 index 000000000..c2711981a --- /dev/null +++ b/client/src/app/reset-password/reset-password.module.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | |||
3 | import { ResetPasswordRoutingModule } from './reset-password-routing.module' | ||
4 | import { ResetPasswordComponent } from './reset-password.component' | ||
5 | import { SharedModule } from '../shared' | ||
6 | |||
7 | @NgModule({ | ||
8 | imports: [ | ||
9 | ResetPasswordRoutingModule, | ||
10 | SharedModule | ||
11 | ], | ||
12 | |||
13 | declarations: [ | ||
14 | ResetPasswordComponent | ||
15 | ], | ||
16 | |||
17 | exports: [ | ||
18 | ResetPasswordComponent | ||
19 | ], | ||
20 | |||
21 | providers: [ | ||
22 | ] | ||
23 | }) | ||
24 | export class ResetPasswordModule { } | ||
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index 742fb0728..da7b583f4 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts | |||
@@ -5,7 +5,6 @@ import 'rxjs/add/operator/map' | |||
5 | import { UserCreate, UserUpdateMe } from '../../../../../shared' | 5 | import { UserCreate, UserUpdateMe } from '../../../../../shared' |
6 | import { environment } from '../../../environments/environment' | 6 | import { environment } from '../../../environments/environment' |
7 | import { RestExtractor } from '../rest' | 7 | import { RestExtractor } from '../rest' |
8 | import { User } from './user.model' | ||
9 | 8 | ||
10 | @Injectable() | 9 | @Injectable() |
11 | export class UserService { | 10 | export class UserService { |
@@ -54,4 +53,24 @@ export class UserService { | |||
54 | return this.authHttp.get(url) | 53 | return this.authHttp.get(url) |
55 | .catch(res => this.restExtractor.handleError(res)) | 54 | .catch(res => this.restExtractor.handleError(res)) |
56 | } | 55 | } |
56 | |||
57 | askResetPassword (email: string) { | ||
58 | const url = UserService.BASE_USERS_URL + '/ask-reset-password' | ||
59 | |||
60 | return this.authHttp.post(url, { email }) | ||
61 | .map(this.restExtractor.extractDataBool) | ||
62 | .catch(res => this.restExtractor.handleError(res)) | ||
63 | } | ||
64 | |||
65 | resetPassword (userId: number, verificationString: string, password: string) { | ||
66 | const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password` | ||
67 | const body = { | ||
68 | verificationString, | ||
69 | password | ||
70 | } | ||
71 | |||
72 | return this.authHttp.post(url, body) | ||
73 | .map(this.restExtractor.extractDataBool) | ||
74 | .catch(res => this.restExtractor.handleError(res)) | ||
75 | } | ||
57 | } | 76 | } |
diff --git a/client/src/polyfills.ts b/client/src/polyfills.ts index c2d7f1d6e..fbe104aa0 100644 --- a/client/src/polyfills.ts +++ b/client/src/polyfills.ts | |||
@@ -19,26 +19,30 @@ | |||
19 | */ | 19 | */ |
20 | 20 | ||
21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ |
22 | // import 'core-js/es6/symbol'; | 22 | |
23 | // import 'core-js/es6/object'; | 23 | // For Google Bot |
24 | // import 'core-js/es6/function'; | 24 | import 'core-js/es6/symbol'; |
25 | // import 'core-js/es6/parse-int'; | 25 | import 'core-js/es6/object'; |
26 | // import 'core-js/es6/parse-float'; | 26 | import 'core-js/es6/function'; |
27 | // import 'core-js/es6/number'; | 27 | import 'core-js/es6/parse-int'; |
28 | // import 'core-js/es6/math'; | 28 | import 'core-js/es6/parse-float'; |
29 | // import 'core-js/es6/string'; | 29 | import 'core-js/es6/number'; |
30 | // import 'core-js/es6/date'; | 30 | import 'core-js/es6/math'; |
31 | // import 'core-js/es6/array'; | 31 | import 'core-js/es6/string'; |
32 | // import 'core-js/es6/regexp'; | 32 | import 'core-js/es6/date'; |
33 | // import 'core-js/es6/map'; | 33 | import 'core-js/es6/array'; |
34 | // import 'core-js/es6/weak-map'; | 34 | import 'core-js/es6/regexp'; |
35 | // import 'core-js/es6/set'; | 35 | import 'core-js/es6/map'; |
36 | import 'core-js/es6/weak-map'; | ||
37 | import 'core-js/es6/set'; | ||
36 | 38 | ||
37 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ | 39 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ |
38 | // import 'classlist.js'; // Run `npm install --save classlist.js`. | 40 | // import 'classlist.js'; // Run `npm install --save classlist.js`. |
39 | 41 | ||
40 | /** IE10 and IE11 requires the following for the Reflect API. */ | 42 | /** IE10 and IE11 requires the following for the Reflect API. */ |
41 | // import 'core-js/es6/reflect'; | 43 | |
44 | // For Google Bot | ||
45 | import 'core-js/es6/reflect'; | ||
42 | 46 | ||
43 | 47 | ||
44 | /** Evergreen browsers require these. **/ | 48 | /** Evergreen browsers require these. **/ |
diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index 253bb1b3c..33d7ce0a5 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss | |||
@@ -19,7 +19,7 @@ $FontPathSourceSansPro: '../../node_modules/npm-font-source-sans-pro/fonts'; | |||
19 | } | 19 | } |
20 | 20 | ||
21 | body { | 21 | body { |
22 | font-family: 'Source Sans Pro'; | 22 | font-family: 'Source Sans Pro', sans-serif; |
23 | font-weight: $font-regular; | 23 | font-weight: $font-regular; |
24 | color: #000; | 24 | color: #000; |
25 | } | 25 | } |
diff --git a/config/default.yaml b/config/default.yaml index fd04b5ce6..691c9e00b 100644 --- a/config/default.yaml +++ b/config/default.yaml | |||
@@ -19,6 +19,15 @@ redis: | |||
19 | port: 6379 | 19 | port: 6379 |
20 | auth: null | 20 | auth: null |
21 | 21 | ||
22 | smtp: | ||
23 | hostname: null | ||
24 | port: 465 | ||
25 | username: null | ||
26 | password: null | ||
27 | tls: true | ||
28 | ca_file: null # Used for self signed certificates | ||
29 | from_address: 'admin@example.com' | ||
30 | |||
22 | # From the project root directory | 31 | # From the project root directory |
23 | storage: | 32 | storage: |
24 | avatars: 'storage/avatars/' | 33 | avatars: 'storage/avatars/' |
@@ -37,7 +46,7 @@ cache: | |||
37 | size: 1 # Max number of previews you want to cache | 46 | size: 1 # Max number of previews you want to cache |
38 | 47 | ||
39 | admin: | 48 | admin: |
40 | email: 'admin@example.com' | 49 | email: 'admin@example.com' # Your personal email as administrator |
41 | 50 | ||
42 | signup: | 51 | signup: |
43 | enabled: false | 52 | enabled: false |
diff --git a/config/production.yaml.example b/config/production.yaml.example index a2b332983..04354b75d 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example | |||
@@ -20,6 +20,15 @@ redis: | |||
20 | port: 6379 | 20 | port: 6379 |
21 | auth: null | 21 | auth: null |
22 | 22 | ||
23 | smtp: | ||
24 | hostname: null | ||
25 | port: 465 | ||
26 | username: null | ||
27 | password: null | ||
28 | tls: true | ||
29 | ca_file: null # Used for self signed certificates | ||
30 | from_address: 'admin@example.com' | ||
31 | |||
23 | # From the project root directory | 32 | # From the project root directory |
24 | storage: | 33 | storage: |
25 | avatars: '/var/www/peertube/storage/avatars/' | 34 | avatars: '/var/www/peertube/storage/avatars/' |
diff --git a/package.json b/package.json index 9a455d212..1b06bcba1 100644 --- a/package.json +++ b/package.json | |||
@@ -76,11 +76,13 @@ | |||
76 | "mkdirp": "^0.5.1", | 76 | "mkdirp": "^0.5.1", |
77 | "morgan": "^1.5.3", | 77 | "morgan": "^1.5.3", |
78 | "multer": "^1.1.0", | 78 | "multer": "^1.1.0", |
79 | "nodemailer": "^4.4.2", | ||
79 | "parse-torrent": "^5.8.0", | 80 | "parse-torrent": "^5.8.0", |
80 | "password-generator": "^2.0.2", | 81 | "password-generator": "^2.0.2", |
81 | "pem": "^1.12.3", | 82 | "pem": "^1.12.3", |
82 | "pg": "^6.4.2", | 83 | "pg": "^6.4.2", |
83 | "pg-hstore": "^2.3.2", | 84 | "pg-hstore": "^2.3.2", |
85 | "redis": "^2.8.0", | ||
84 | "reflect-metadata": "^0.1.10", | 86 | "reflect-metadata": "^0.1.10", |
85 | "request": "^2.81.0", | 87 | "request": "^2.81.0", |
86 | "rimraf": "^2.5.4", | 88 | "rimraf": "^2.5.4", |
@@ -112,7 +114,9 @@ | |||
112 | "@types/morgan": "^1.7.32", | 114 | "@types/morgan": "^1.7.32", |
113 | "@types/multer": "^1.3.3", | 115 | "@types/multer": "^1.3.3", |
114 | "@types/node": "^9.3.0", | 116 | "@types/node": "^9.3.0", |
117 | "@types/nodemailer": "^4.3.1", | ||
115 | "@types/pem": "^1.9.3", | 118 | "@types/pem": "^1.9.3", |
119 | "@types/redis": "^2.8.5", | ||
116 | "@types/request": "^2.0.3", | 120 | "@types/request": "^2.0.3", |
117 | "@types/sequelize": "^4.0.55", | 121 | "@types/sequelize": "^4.0.55", |
118 | "@types/sharp": "^0.17.6", | 122 | "@types/sharp": "^0.17.6", |
diff --git a/scripts/help.sh b/scripts/help.sh index 51f55547e..a822d5d2e 100755 --- a/scripts/help.sh +++ b/scripts/help.sh | |||
@@ -3,12 +3,11 @@ | |||
3 | printf "############# PeerTube help #############\n\n" | 3 | printf "############# PeerTube help #############\n\n" |
4 | printf "npm run ...\n" | 4 | printf "npm run ...\n" |
5 | printf " build -> Build the application for production (alias of build:client:prod)\n" | 5 | printf " build -> Build the application for production (alias of build:client:prod)\n" |
6 | printf " build:server:prod -> Build the server for production\n" | 6 | printf " build:server -> Build the server for production\n" |
7 | printf " build:client:prod -> Build the client for production\n" | 7 | printf " build:client -> Build the client for production\n" |
8 | printf " clean -> Clean the application\n" | ||
9 | printf " clean:client -> Clean the client build files (dist directory)\n" | 8 | printf " clean:client -> Clean the client build files (dist directory)\n" |
10 | printf " clean:server:test -> Clean certificates, logs, uploads and database of the test instances\n" | 9 | printf " clean:server:test -> Clean logs, uploads, database... of the test instances\n" |
11 | printf " watch:client -> Watch the client files\n" | 10 | printf " watch:client -> Watch and compile on the fly the client files\n" |
12 | printf " danger:clean:dev -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified in the development environment\n" | 11 | printf " danger:clean:dev -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified in the development environment\n" |
13 | printf " danger:clean:prod -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified by the production environment\n" | 12 | printf " danger:clean:prod -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified by the production environment\n" |
14 | printf " danger:clean:modules -> /!\ Clean node and typescript modules\n" | 13 | 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 | |||
16 | printf " reset-password -- -u [user] -> Reset the password of user [user]\n" | 15 | printf " reset-password -- -u [user] -> Reset the password of user [user]\n" |
17 | printf " dev -> Watch, run the livereload and run the server so that you can develop the application\n" | 16 | printf " dev -> Watch, run the livereload and run the server so that you can develop the application\n" |
18 | printf " start -> Run the server\n" | 17 | printf " start -> Run the server\n" |
19 | printf " check -> Check the server (according to NODE_ENV)\n" | ||
20 | printf " upgrade -- [branch] -> Upgrade the application according to the [branch] parameter\n" | ||
21 | printf " update-host -> Upgrade scheme/host in torrent files according to the webserver configuration (config/ folder)\n" | 18 | printf " update-host -> Upgrade scheme/host in torrent files according to the webserver configuration (config/ folder)\n" |
19 | printf " client-report -> Open a report of the client dependencies module\n" | ||
22 | printf " test -> Run the tests\n" | 20 | printf " test -> Run the tests\n" |
23 | printf " help -> Print this help\n" | 21 | printf " help -> Print this help\n" |
@@ -53,9 +53,11 @@ migrate() | |||
53 | 53 | ||
54 | // ----------- PeerTube modules ----------- | 54 | // ----------- PeerTube modules ----------- |
55 | import { installApplication } from './server/initializers' | 55 | import { installApplication } from './server/initializers' |
56 | import { Emailer } from './server/lib/emailer' | ||
56 | import { JobQueue } from './server/lib/job-queue' | 57 | import { JobQueue } from './server/lib/job-queue' |
57 | import { VideosPreviewCache } from './server/lib/cache' | 58 | import { VideosPreviewCache } from './server/lib/cache' |
58 | import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers' | 59 | import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers' |
60 | import { Redis } from './server/lib/redis' | ||
59 | import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler' | 61 | import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler' |
60 | import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler' | 62 | import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler' |
61 | 63 | ||
@@ -169,10 +171,20 @@ function onDatabaseInitDone () { | |||
169 | .then(() => { | 171 | .then(() => { |
170 | // ----------- Make the server listening ----------- | 172 | // ----------- Make the server listening ----------- |
171 | server.listen(port, () => { | 173 | server.listen(port, () => { |
174 | // Emailer initialization and then job queue initialization | ||
175 | Emailer.Instance.init() | ||
176 | Emailer.Instance.checkConnectionOrDie() | ||
177 | .then(() => JobQueue.Instance.init()) | ||
178 | |||
179 | // Caches initializations | ||
172 | VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE) | 180 | VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE) |
181 | |||
182 | // Enable Schedulers | ||
173 | BadActorFollowScheduler.Instance.enable() | 183 | BadActorFollowScheduler.Instance.enable() |
174 | RemoveOldJobsScheduler.Instance.enable() | 184 | RemoveOldJobsScheduler.Instance.enable() |
175 | JobQueue.Instance.init() | 185 | |
186 | // Redis initialization | ||
187 | Redis.Instance.init() | ||
176 | 188 | ||
177 | logger.info('Server listening on port %d', port) | 189 | logger.info('Server listening on port %d', port) |
178 | logger.info('Web server: %s', CONFIG.WEBSERVER.URL) | 190 | logger.info('Web server: %s', CONFIG.WEBSERVER.URL) |
diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts index 79bb2665d..05639fbec 100644 --- a/server/controllers/api/users.ts +++ b/server/controllers/api/users.ts | |||
@@ -6,17 +6,23 @@ import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRat | |||
6 | import { unlinkPromise } from '../../helpers/core-utils' | 6 | import { unlinkPromise } from '../../helpers/core-utils' |
7 | import { retryTransactionWrapper } from '../../helpers/database-utils' | 7 | import { retryTransactionWrapper } from '../../helpers/database-utils' |
8 | import { logger } from '../../helpers/logger' | 8 | import { logger } from '../../helpers/logger' |
9 | import { createReqFiles, getFormattedObjects } from '../../helpers/utils' | 9 | import { createReqFiles, generateRandomString, getFormattedObjects } from '../../helpers/utils' |
10 | import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers' | 10 | import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers' |
11 | import { updateActorAvatarInstance } from '../../lib/activitypub' | 11 | import { updateActorAvatarInstance } from '../../lib/activitypub' |
12 | import { sendUpdateUser } from '../../lib/activitypub/send' | 12 | import { sendUpdateUser } from '../../lib/activitypub/send' |
13 | import { Emailer } from '../../lib/emailer' | ||
14 | import { EmailPayload } from '../../lib/job-queue/handlers/email' | ||
15 | import { Redis } from '../../lib/redis' | ||
13 | import { createUserAccountAndChannel } from '../../lib/user' | 16 | import { createUserAccountAndChannel } from '../../lib/user' |
14 | import { | 17 | import { |
15 | asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort, | 18 | asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort, |
16 | setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, | 19 | setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, |
17 | usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator | 20 | usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator |
18 | } from '../../middlewares' | 21 | } from '../../middlewares' |
19 | import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators' | 22 | import { |
23 | usersAskResetPasswordValidator, usersResetPasswordValidator, usersUpdateMyAvatarValidator, | ||
24 | videosSortValidator | ||
25 | } from '../../middlewares/validators' | ||
20 | import { AccountVideoRateModel } from '../../models/account/account-video-rate' | 26 | import { AccountVideoRateModel } from '../../models/account/account-video-rate' |
21 | import { UserModel } from '../../models/account/user' | 27 | import { UserModel } from '../../models/account/user' |
22 | import { OAuthTokenModel } from '../../models/oauth/oauth-token' | 28 | import { OAuthTokenModel } from '../../models/oauth/oauth-token' |
@@ -106,6 +112,16 @@ usersRouter.delete('/:id', | |||
106 | asyncMiddleware(removeUser) | 112 | asyncMiddleware(removeUser) |
107 | ) | 113 | ) |
108 | 114 | ||
115 | usersRouter.post('/ask-reset-password', | ||
116 | asyncMiddleware(usersAskResetPasswordValidator), | ||
117 | asyncMiddleware(askResetUserPassword) | ||
118 | ) | ||
119 | |||
120 | usersRouter.post('/:id/reset-password', | ||
121 | asyncMiddleware(usersResetPasswordValidator), | ||
122 | asyncMiddleware(resetUserPassword) | ||
123 | ) | ||
124 | |||
109 | usersRouter.post('/token', token, success) | 125 | usersRouter.post('/token', token, success) |
110 | // TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route | 126 | // TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route |
111 | 127 | ||
@@ -307,6 +323,25 @@ async function updateUser (req: express.Request, res: express.Response, next: ex | |||
307 | return res.sendStatus(204) | 323 | return res.sendStatus(204) |
308 | } | 324 | } |
309 | 325 | ||
326 | async function askResetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) { | ||
327 | const user = res.locals.user as UserModel | ||
328 | |||
329 | const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id) | ||
330 | const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString | ||
331 | await Emailer.Instance.addForgetPasswordEmailJob(user.email, url) | ||
332 | |||
333 | return res.status(204).end() | ||
334 | } | ||
335 | |||
336 | async function resetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) { | ||
337 | const user = res.locals.user as UserModel | ||
338 | user.password = req.body.password | ||
339 | |||
340 | await user.save() | ||
341 | |||
342 | return res.status(204).end() | ||
343 | } | ||
344 | |||
310 | function success (req: express.Request, res: express.Response, next: express.NextFunction) { | 345 | function success (req: express.Request, res: express.Response, next: express.NextFunction) { |
311 | res.end() | 346 | res.end() |
312 | } | 347 | } |
diff --git a/server/helpers/logger.ts b/server/helpers/logger.ts index 10e8cabc8..c353f55da 100644 --- a/server/helpers/logger.ts +++ b/server/helpers/logger.ts | |||
@@ -26,6 +26,7 @@ const loggerFormat = winston.format.printf((info) => { | |||
26 | if (additionalInfos === '{}') additionalInfos = '' | 26 | if (additionalInfos === '{}') additionalInfos = '' |
27 | else additionalInfos = ' ' + additionalInfos | 27 | else additionalInfos = ' ' + additionalInfos |
28 | 28 | ||
29 | if (info.message.stack !== undefined) info.message = info.message.stack | ||
29 | return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}` | 30 | return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}` |
30 | }) | 31 | }) |
31 | 32 | ||
diff --git a/server/initializers/checker.ts b/server/initializers/checker.ts index 35fab244c..d550fd23f 100644 --- a/server/initializers/checker.ts +++ b/server/initializers/checker.ts | |||
@@ -22,7 +22,8 @@ function checkMissedConfig () { | |||
22 | 'webserver.https', 'webserver.hostname', 'webserver.port', | 22 | 'webserver.https', 'webserver.hostname', 'webserver.port', |
23 | 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', | 23 | 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', |
24 | 'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level', | 24 | 'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level', |
25 | 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', 'user.video_quota' | 25 | 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', |
26 | 'user.video_quota', 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address' | ||
26 | ] | 27 | ] |
27 | const miss: string[] = [] | 28 | const miss: string[] = [] |
28 | 29 | ||
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 03828f54f..e7b1656e2 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -65,13 +65,15 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = { | |||
65 | 'activitypub-http-broadcast': 5, | 65 | 'activitypub-http-broadcast': 5, |
66 | 'activitypub-http-unicast': 5, | 66 | 'activitypub-http-unicast': 5, |
67 | 'activitypub-http-fetcher': 5, | 67 | 'activitypub-http-fetcher': 5, |
68 | 'video-file': 1 | 68 | 'video-file': 1, |
69 | 'email': 5 | ||
69 | } | 70 | } |
70 | const JOB_CONCURRENCY: { [ id in JobType ]: number } = { | 71 | const JOB_CONCURRENCY: { [ id in JobType ]: number } = { |
71 | 'activitypub-http-broadcast': 1, | 72 | 'activitypub-http-broadcast': 1, |
72 | 'activitypub-http-unicast': 5, | 73 | 'activitypub-http-unicast': 5, |
73 | 'activitypub-http-fetcher': 1, | 74 | 'activitypub-http-fetcher': 1, |
74 | 'video-file': 1 | 75 | 'video-file': 1, |
76 | 'email': 5 | ||
75 | } | 77 | } |
76 | // 2 days | 78 | // 2 days |
77 | const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 | 79 | const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 |
@@ -95,9 +97,18 @@ const CONFIG = { | |||
95 | }, | 97 | }, |
96 | REDIS: { | 98 | REDIS: { |
97 | HOSTNAME: config.get<string>('redis.hostname'), | 99 | HOSTNAME: config.get<string>('redis.hostname'), |
98 | PORT: config.get<string>('redis.port'), | 100 | PORT: config.get<number>('redis.port'), |
99 | AUTH: config.get<string>('redis.auth') | 101 | AUTH: config.get<string>('redis.auth') |
100 | }, | 102 | }, |
103 | SMTP: { | ||
104 | HOSTNAME: config.get<string>('smtp.hostname'), | ||
105 | PORT: config.get<number>('smtp.port'), | ||
106 | USERNAME: config.get<string>('smtp.username'), | ||
107 | PASSWORD: config.get<string>('smtp.password'), | ||
108 | TLS: config.get<boolean>('smtp.tls'), | ||
109 | CA_FILE: config.get<string>('smtp.ca_file'), | ||
110 | FROM_ADDRESS: config.get<string>('smtp.from_address') | ||
111 | }, | ||
101 | STORAGE: { | 112 | STORAGE: { |
102 | AVATARS_DIR: buildPath(config.get<string>('storage.avatars')), | 113 | AVATARS_DIR: buildPath(config.get<string>('storage.avatars')), |
103 | LOG_DIR: buildPath(config.get<string>('storage.logs')), | 114 | LOG_DIR: buildPath(config.get<string>('storage.logs')), |
@@ -311,6 +322,8 @@ const PRIVATE_RSA_KEY_SIZE = 2048 | |||
311 | // Password encryption | 322 | // Password encryption |
312 | const BCRYPT_SALT_SIZE = 10 | 323 | const BCRYPT_SALT_SIZE = 10 |
313 | 324 | ||
325 | const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes | ||
326 | |||
314 | // --------------------------------------------------------------------------- | 327 | // --------------------------------------------------------------------------- |
315 | 328 | ||
316 | // Express static paths (router) | 329 | // Express static paths (router) |
@@ -408,6 +421,7 @@ export { | |||
408 | VIDEO_LICENCES, | 421 | VIDEO_LICENCES, |
409 | VIDEO_RATE_TYPES, | 422 | VIDEO_RATE_TYPES, |
410 | VIDEO_MIMETYPE_EXT, | 423 | VIDEO_MIMETYPE_EXT, |
424 | USER_PASSWORD_RESET_LIFETIME, | ||
411 | AVATAR_MIMETYPE_EXT, | 425 | AVATAR_MIMETYPE_EXT, |
412 | SCHEDULER_INTERVAL, | 426 | SCHEDULER_INTERVAL, |
413 | JOB_COMPLETED_LIFETIME | 427 | JOB_COMPLETED_LIFETIME |
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts new file mode 100644 index 000000000..f5b68640e --- /dev/null +++ b/server/lib/emailer.ts | |||
@@ -0,0 +1,106 @@ | |||
1 | import { createTransport, Transporter } from 'nodemailer' | ||
2 | import { isTestInstance } from '../helpers/core-utils' | ||
3 | import { logger } from '../helpers/logger' | ||
4 | import { CONFIG } from '../initializers' | ||
5 | import { JobQueue } from './job-queue' | ||
6 | import { EmailPayload } from './job-queue/handlers/email' | ||
7 | import { readFileSync } from 'fs' | ||
8 | |||
9 | class Emailer { | ||
10 | |||
11 | private static instance: Emailer | ||
12 | private initialized = false | ||
13 | private transporter: Transporter | ||
14 | |||
15 | private constructor () {} | ||
16 | |||
17 | init () { | ||
18 | // Already initialized | ||
19 | if (this.initialized === true) return | ||
20 | this.initialized = true | ||
21 | |||
22 | if (CONFIG.SMTP.HOSTNAME && CONFIG.SMTP.PORT) { | ||
23 | logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT) | ||
24 | |||
25 | let tls | ||
26 | if (CONFIG.SMTP.CA_FILE) { | ||
27 | tls = { | ||
28 | ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ] | ||
29 | } | ||
30 | } | ||
31 | |||
32 | this.transporter = createTransport({ | ||
33 | host: CONFIG.SMTP.HOSTNAME, | ||
34 | port: CONFIG.SMTP.PORT, | ||
35 | secure: CONFIG.SMTP.TLS, | ||
36 | tls, | ||
37 | auth: { | ||
38 | user: CONFIG.SMTP.USERNAME, | ||
39 | pass: CONFIG.SMTP.PASSWORD | ||
40 | } | ||
41 | }) | ||
42 | } else { | ||
43 | if (!isTestInstance()) { | ||
44 | logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!') | ||
45 | } | ||
46 | } | ||
47 | } | ||
48 | |||
49 | async checkConnectionOrDie () { | ||
50 | if (!this.transporter) return | ||
51 | |||
52 | try { | ||
53 | const success = await this.transporter.verify() | ||
54 | if (success !== true) this.dieOnConnectionFailure() | ||
55 | |||
56 | logger.info('Successfully connected to SMTP server.') | ||
57 | } catch (err) { | ||
58 | this.dieOnConnectionFailure(err) | ||
59 | } | ||
60 | } | ||
61 | |||
62 | addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { | ||
63 | const text = `Hi dear user,\n\n` + | ||
64 | `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + | ||
65 | `Please follow this link to reset it: ${resetPasswordUrl}.\n\n` + | ||
66 | `If you are not the person who initiated this request, please ignore this email.\n\n` + | ||
67 | `Cheers,\n` + | ||
68 | `PeerTube.` | ||
69 | |||
70 | const emailPayload: EmailPayload = { | ||
71 | to: [ to ], | ||
72 | subject: 'Reset your PeerTube password', | ||
73 | text | ||
74 | } | ||
75 | |||
76 | return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) | ||
77 | } | ||
78 | |||
79 | sendMail (to: string[], subject: string, text: string) { | ||
80 | if (!this.transporter) { | ||
81 | throw new Error('Cannot send mail because SMTP is not configured.') | ||
82 | } | ||
83 | |||
84 | return this.transporter.sendMail({ | ||
85 | from: CONFIG.SMTP.FROM_ADDRESS, | ||
86 | to: to.join(','), | ||
87 | subject, | ||
88 | text | ||
89 | }) | ||
90 | } | ||
91 | |||
92 | private dieOnConnectionFailure (err?: Error) { | ||
93 | logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, err) | ||
94 | process.exit(-1) | ||
95 | } | ||
96 | |||
97 | static get Instance () { | ||
98 | return this.instance || (this.instance = new this()) | ||
99 | } | ||
100 | } | ||
101 | |||
102 | // --------------------------------------------------------------------------- | ||
103 | |||
104 | export { | ||
105 | Emailer | ||
106 | } | ||
diff --git a/server/lib/job-queue/handlers/email.ts b/server/lib/job-queue/handlers/email.ts new file mode 100644 index 000000000..9d7686116 --- /dev/null +++ b/server/lib/job-queue/handlers/email.ts | |||
@@ -0,0 +1,22 @@ | |||
1 | import * as kue from 'kue' | ||
2 | import { logger } from '../../../helpers/logger' | ||
3 | import { Emailer } from '../../emailer' | ||
4 | |||
5 | export type EmailPayload = { | ||
6 | to: string[] | ||
7 | subject: string | ||
8 | text: string | ||
9 | } | ||
10 | |||
11 | async function processEmail (job: kue.Job) { | ||
12 | const payload = job.data as EmailPayload | ||
13 | logger.info('Processing email in job %d.', job.id) | ||
14 | |||
15 | return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text) | ||
16 | } | ||
17 | |||
18 | // --------------------------------------------------------------------------- | ||
19 | |||
20 | export { | ||
21 | processEmail | ||
22 | } | ||
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 7a2b6c78d..3f176f896 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts | |||
@@ -5,19 +5,22 @@ import { CONFIG, JOB_ATTEMPTS, JOB_COMPLETED_LIFETIME, JOB_CONCURRENCY } from '. | |||
5 | import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast' | 5 | import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast' |
6 | import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' | 6 | import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' |
7 | import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' | 7 | import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' |
8 | import { EmailPayload, processEmail } from './handlers/email' | ||
8 | import { processVideoFile, VideoFilePayload } from './handlers/video-file' | 9 | import { processVideoFile, VideoFilePayload } from './handlers/video-file' |
9 | 10 | ||
10 | type CreateJobArgument = | 11 | type CreateJobArgument = |
11 | { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | | 12 | { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | |
12 | { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } | | 13 | { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } | |
13 | { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } | | 14 | { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } | |
14 | { type: 'video-file', payload: VideoFilePayload } | 15 | { type: 'video-file', payload: VideoFilePayload } | |
16 | { type: 'email', payload: EmailPayload } | ||
15 | 17 | ||
16 | const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = { | 18 | const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = { |
17 | 'activitypub-http-broadcast': processActivityPubHttpBroadcast, | 19 | 'activitypub-http-broadcast': processActivityPubHttpBroadcast, |
18 | 'activitypub-http-unicast': processActivityPubHttpUnicast, | 20 | 'activitypub-http-unicast': processActivityPubHttpUnicast, |
19 | 'activitypub-http-fetcher': processActivityPubHttpFetcher, | 21 | 'activitypub-http-fetcher': processActivityPubHttpFetcher, |
20 | 'video-file': processVideoFile | 22 | 'video-file': processVideoFile, |
23 | 'email': processEmail | ||
21 | } | 24 | } |
22 | 25 | ||
23 | class JobQueue { | 26 | class JobQueue { |
@@ -43,6 +46,8 @@ class JobQueue { | |||
43 | } | 46 | } |
44 | }) | 47 | }) |
45 | 48 | ||
49 | this.jobQueue.setMaxListeners(15) | ||
50 | |||
46 | this.jobQueue.on('error', err => { | 51 | this.jobQueue.on('error', err => { |
47 | logger.error('Error in job queue.', err) | 52 | logger.error('Error in job queue.', err) |
48 | process.exit(-1) | 53 | process.exit(-1) |
diff --git a/server/lib/redis.ts b/server/lib/redis.ts new file mode 100644 index 000000000..4240cc162 --- /dev/null +++ b/server/lib/redis.ts | |||
@@ -0,0 +1,84 @@ | |||
1 | import { createClient, RedisClient } from 'redis' | ||
2 | import { logger } from '../helpers/logger' | ||
3 | import { generateRandomString } from '../helpers/utils' | ||
4 | import { CONFIG, USER_PASSWORD_RESET_LIFETIME } from '../initializers' | ||
5 | |||
6 | class Redis { | ||
7 | |||
8 | private static instance: Redis | ||
9 | private initialized = false | ||
10 | private client: RedisClient | ||
11 | private prefix: string | ||
12 | |||
13 | private constructor () {} | ||
14 | |||
15 | init () { | ||
16 | // Already initialized | ||
17 | if (this.initialized === true) return | ||
18 | this.initialized = true | ||
19 | |||
20 | this.client = createClient({ | ||
21 | host: CONFIG.REDIS.HOSTNAME, | ||
22 | port: CONFIG.REDIS.PORT | ||
23 | }) | ||
24 | |||
25 | this.client.on('error', err => { | ||
26 | logger.error('Error in Redis client.', err) | ||
27 | process.exit(-1) | ||
28 | }) | ||
29 | |||
30 | if (CONFIG.REDIS.AUTH) { | ||
31 | this.client.auth(CONFIG.REDIS.AUTH) | ||
32 | } | ||
33 | |||
34 | this.prefix = 'redis-' + CONFIG.WEBSERVER.HOST + '-' | ||
35 | } | ||
36 | |||
37 | async setResetPasswordVerificationString (userId: number) { | ||
38 | const generatedString = await generateRandomString(32) | ||
39 | |||
40 | await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_RESET_LIFETIME) | ||
41 | |||
42 | return generatedString | ||
43 | } | ||
44 | |||
45 | async getResetPasswordLink (userId: number) { | ||
46 | return this.getValue(this.generateResetPasswordKey(userId)) | ||
47 | } | ||
48 | |||
49 | private getValue (key: string) { | ||
50 | return new Promise<string>((res, rej) => { | ||
51 | this.client.get(this.prefix + key, (err, value) => { | ||
52 | if (err) return rej(err) | ||
53 | |||
54 | return res(value) | ||
55 | }) | ||
56 | }) | ||
57 | } | ||
58 | |||
59 | private setValue (key: string, value: string, expirationMilliseconds: number) { | ||
60 | return new Promise<void>((res, rej) => { | ||
61 | this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => { | ||
62 | if (err) return rej(err) | ||
63 | |||
64 | if (ok !== 'OK') return rej(new Error('Redis result is not OK.')) | ||
65 | |||
66 | return res() | ||
67 | }) | ||
68 | }) | ||
69 | } | ||
70 | |||
71 | private generateResetPasswordKey (userId: number) { | ||
72 | return 'reset-password-' + userId | ||
73 | } | ||
74 | |||
75 | static get Instance () { | ||
76 | return this.instance || (this.instance = new this()) | ||
77 | } | ||
78 | } | ||
79 | |||
80 | // --------------------------------------------------------------------------- | ||
81 | |||
82 | export { | ||
83 | Redis | ||
84 | } | ||
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index b6591c9e1..5f44c3b99 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts | |||
@@ -1,18 +1,25 @@ | |||
1 | import * as Bluebird from 'bluebird' | ||
1 | import * as express from 'express' | 2 | import * as express from 'express' |
2 | import 'express-validator' | 3 | import 'express-validator' |
3 | import { body, param } from 'express-validator/check' | 4 | import { body, param } from 'express-validator/check' |
5 | import { omit } from 'lodash' | ||
4 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | 6 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' |
5 | import { | 7 | import { |
6 | isAvatarFile, isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, | 8 | isAvatarFile, |
9 | isUserAutoPlayVideoValid, | ||
10 | isUserDisplayNSFWValid, | ||
11 | isUserPasswordValid, | ||
12 | isUserRoleValid, | ||
13 | isUserUsernameValid, | ||
7 | isUserVideoQuotaValid | 14 | isUserVideoQuotaValid |
8 | } from '../../helpers/custom-validators/users' | 15 | } from '../../helpers/custom-validators/users' |
9 | import { isVideoExist } from '../../helpers/custom-validators/videos' | 16 | import { isVideoExist } from '../../helpers/custom-validators/videos' |
10 | import { logger } from '../../helpers/logger' | 17 | import { logger } from '../../helpers/logger' |
11 | import { isSignupAllowed } from '../../helpers/utils' | 18 | import { isSignupAllowed } from '../../helpers/utils' |
12 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 19 | import { CONSTRAINTS_FIELDS } from '../../initializers' |
20 | import { Redis } from '../../lib/redis' | ||
13 | import { UserModel } from '../../models/account/user' | 21 | import { UserModel } from '../../models/account/user' |
14 | import { areValidationErrors } from './utils' | 22 | import { areValidationErrors } from './utils' |
15 | import { omit } from 'lodash' | ||
16 | 23 | ||
17 | const usersAddValidator = [ | 24 | const usersAddValidator = [ |
18 | body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'), | 25 | body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'), |
@@ -167,6 +174,49 @@ const ensureUserRegistrationAllowed = [ | |||
167 | } | 174 | } |
168 | ] | 175 | ] |
169 | 176 | ||
177 | const usersAskResetPasswordValidator = [ | ||
178 | body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'), | ||
179 | |||
180 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
181 | logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body }) | ||
182 | |||
183 | if (areValidationErrors(req, res)) return | ||
184 | const exists = await checkUserEmailExist(req.body.email, res, false) | ||
185 | if (!exists) { | ||
186 | logger.debug('User with email %s does not exist (asking reset password).', req.body.email) | ||
187 | // Do not leak our emails | ||
188 | return res.status(204).end() | ||
189 | } | ||
190 | |||
191 | return next() | ||
192 | } | ||
193 | ] | ||
194 | |||
195 | const usersResetPasswordValidator = [ | ||
196 | param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), | ||
197 | body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'), | ||
198 | body('password').custom(isUserPasswordValid).withMessage('Should have a valid password'), | ||
199 | |||
200 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
201 | logger.debug('Checking usersResetPassword parameters', { parameters: req.params }) | ||
202 | |||
203 | if (areValidationErrors(req, res)) return | ||
204 | if (!await checkUserIdExist(req.params.id, res)) return | ||
205 | |||
206 | const user = res.locals.user as UserModel | ||
207 | const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id) | ||
208 | |||
209 | if (redisVerificationString !== req.body.verificationString) { | ||
210 | return res | ||
211 | .status(403) | ||
212 | .send({ error: 'Invalid verification string.' }) | ||
213 | .end | ||
214 | } | ||
215 | |||
216 | return next() | ||
217 | } | ||
218 | ] | ||
219 | |||
170 | // --------------------------------------------------------------------------- | 220 | // --------------------------------------------------------------------------- |
171 | 221 | ||
172 | export { | 222 | export { |
@@ -178,24 +228,19 @@ export { | |||
178 | usersVideoRatingValidator, | 228 | usersVideoRatingValidator, |
179 | ensureUserRegistrationAllowed, | 229 | ensureUserRegistrationAllowed, |
180 | usersGetValidator, | 230 | usersGetValidator, |
181 | usersUpdateMyAvatarValidator | 231 | usersUpdateMyAvatarValidator, |
232 | usersAskResetPasswordValidator, | ||
233 | usersResetPasswordValidator | ||
182 | } | 234 | } |
183 | 235 | ||
184 | // --------------------------------------------------------------------------- | 236 | // --------------------------------------------------------------------------- |
185 | 237 | ||
186 | async function checkUserIdExist (id: number, res: express.Response) { | 238 | function checkUserIdExist (id: number, res: express.Response) { |
187 | const user = await UserModel.loadById(id) | 239 | return checkUserExist(() => UserModel.loadById(id), res) |
188 | 240 | } | |
189 | if (!user) { | ||
190 | res.status(404) | ||
191 | .send({ error: 'User not found' }) | ||
192 | .end() | ||
193 | |||
194 | return false | ||
195 | } | ||
196 | 241 | ||
197 | res.locals.user = user | 242 | function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { |
198 | return true | 243 | return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) |
199 | } | 244 | } |
200 | 245 | ||
201 | async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { | 246 | async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { |
@@ -210,3 +255,21 @@ async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: | |||
210 | 255 | ||
211 | return true | 256 | return true |
212 | } | 257 | } |
258 | |||
259 | async function checkUserExist (finder: () => Bluebird<UserModel>, res: express.Response, abortResponse = true) { | ||
260 | const user = await finder() | ||
261 | |||
262 | if (!user) { | ||
263 | if (abortResponse === true) { | ||
264 | res.status(404) | ||
265 | .send({ error: 'User not found' }) | ||
266 | .end() | ||
267 | } | ||
268 | |||
269 | return false | ||
270 | } | ||
271 | |||
272 | res.locals.user = user | ||
273 | |||
274 | return true | ||
275 | } | ||
diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 809e821bd..026a8c9a0 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts | |||
@@ -161,6 +161,16 @@ export class UserModel extends Model<UserModel> { | |||
161 | return UserModel.scope('withVideoChannel').findOne(query) | 161 | return UserModel.scope('withVideoChannel').findOne(query) |
162 | } | 162 | } |
163 | 163 | ||
164 | static loadByEmail (email: string) { | ||
165 | const query = { | ||
166 | where: { | ||
167 | |||
168 | } | ||
169 | } | ||
170 | |||
171 | return UserModel.findOne(query) | ||
172 | } | ||
173 | |||
164 | static loadByUsernameOrEmail (username: string, email?: string) { | 174 | static loadByUsernameOrEmail (username: string, email?: string) { |
165 | if (!email) email = username | 175 | if (!email) email = username |
166 | 176 | ||
diff --git a/shared/models/job.model.ts b/shared/models/job.model.ts index 1a25600f3..5ebb75a5c 100644 --- a/shared/models/job.model.ts +++ b/shared/models/job.model.ts | |||
@@ -3,7 +3,8 @@ export type JobState = 'active' | 'complete' | 'failed' | 'inactive' | 'delayed' | |||
3 | export type JobType = 'activitypub-http-unicast' | | 3 | export type JobType = 'activitypub-http-unicast' | |
4 | 'activitypub-http-broadcast' | | 4 | 'activitypub-http-broadcast' | |
5 | 'activitypub-http-fetcher' | | 5 | 'activitypub-http-fetcher' | |
6 | 'video-file' | 6 | 'video-file' | |
7 | 'email' | ||
7 | 8 | ||
8 | export interface Job { | 9 | export interface Job { |
9 | id: number | 10 | id: number |
diff --git a/tsconfig.json b/tsconfig.json index 1c1472aae..70d2c51c2 100644 --- a/tsconfig.json +++ b/tsconfig.json | |||
@@ -19,6 +19,8 @@ | |||
19 | }, | 19 | }, |
20 | "exclude": [ | 20 | "exclude": [ |
21 | "node_modules", | 21 | "node_modules", |
22 | "dist", | ||
23 | "storage", | ||
22 | "client", | 24 | "client", |
23 | "test1", | 25 | "test1", |
24 | "test2", | 26 | "test2", |
@@ -134,6 +134,12 @@ | |||
134 | version "6.0.41" | 134 | version "6.0.41" |
135 | resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.41.tgz#578cf53aaec65887bcaf16792f8722932e8ff8ea" | 135 | resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.41.tgz#578cf53aaec65887bcaf16792f8722932e8ff8ea" |
136 | 136 | ||
137 | "@types/nodemailer@^4.3.1": | ||
138 | version "4.3.1" | ||
139 | resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-4.3.1.tgz#e3985c1b7c7bbbb2a886108b89f1c7ce9a690654" | ||
140 | dependencies: | ||
141 | "@types/node" "*" | ||
142 | |||
137 | "@types/parse-torrent-file@*": | 143 | "@types/parse-torrent-file@*": |
138 | version "4.0.1" | 144 | version "4.0.1" |
139 | resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.1.tgz#056a6c18f3fac0cd7c6c74540f00496a3225976b" | 145 | resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.1.tgz#056a6c18f3fac0cd7c6c74540f00496a3225976b" |
@@ -152,7 +158,7 @@ | |||
152 | version "1.9.3" | 158 | version "1.9.3" |
153 | resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.3.tgz#0c864c8b79e43fef6367db895f60fd1edd10e86c" | 159 | resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.3.tgz#0c864c8b79e43fef6367db895f60fd1edd10e86c" |
154 | 160 | ||
155 | "@types/redis@*": | 161 | "@types/redis@*", "@types/redis@^2.8.5": |
156 | version "2.8.5" | 162 | version "2.8.5" |
157 | resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.5.tgz#c4a31a63e95434202eb84908290528ad8510b149" | 163 | resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.5.tgz#c4a31a63e95434202eb84908290528ad8510b149" |
158 | dependencies: | 164 | dependencies: |
@@ -4274,6 +4280,10 @@ node-sass@^4.0.0: | |||
4274 | stdout-stream "^1.4.0" | 4280 | stdout-stream "^1.4.0" |
4275 | "true-case-path" "^1.0.2" | 4281 | "true-case-path" "^1.0.2" |
4276 | 4282 | ||
4283 | nodemailer@^4.4.2: | ||
4284 | version "4.4.2" | ||
4285 | resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.4.2.tgz#f215fb88e8a1052f9f93083909e116d2b79fc8de" | ||
4286 | |||
4277 | nodemon@^1.11.0: | 4287 | nodemon@^1.11.0: |
4278 | version "1.14.11" | 4288 | version "1.14.11" |
4279 | resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.14.11.tgz#cc0009dd8d82f126f3aba50ace7e753827a8cebc" | 4289 | resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.14.11.tgz#cc0009dd8d82f126f3aba50ace7e753827a8cebc" |
@@ -5149,7 +5159,7 @@ redis-commands@^1.2.0: | |||
5149 | version "1.3.1" | 5159 | version "1.3.1" |
5150 | resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.3.1.tgz#81d826f45fa9c8b2011f4cd7a0fe597d241d442b" | 5160 | resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.3.1.tgz#81d826f45fa9c8b2011f4cd7a0fe597d241d442b" |
5151 | 5161 | ||
5152 | redis-parser@^2.0.0: | 5162 | redis-parser@^2.0.0, redis-parser@^2.6.0: |
5153 | version "2.6.0" | 5163 | version "2.6.0" |
5154 | resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b" | 5164 | resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b" |
5155 | 5165 | ||
@@ -5157,6 +5167,14 @@ redis@^0.12.1: | |||
5157 | version "0.12.1" | 5167 | version "0.12.1" |
5158 | resolved "https://registry.yarnpkg.com/redis/-/redis-0.12.1.tgz#64df76ad0fc8acebaebd2a0645e8a48fac49185e" | 5168 | resolved "https://registry.yarnpkg.com/redis/-/redis-0.12.1.tgz#64df76ad0fc8acebaebd2a0645e8a48fac49185e" |
5159 | 5169 | ||
5170 | redis@^2.8.0: | ||
5171 | version "2.8.0" | ||
5172 | resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02" | ||
5173 | dependencies: | ||
5174 | double-ended-queue "^2.1.0-0" | ||
5175 | redis-commands "^1.2.0" | ||
5176 | redis-parser "^2.6.0" | ||
5177 | |||
5160 | redis@~2.6.0-2: | 5178 | redis@~2.6.0-2: |
5161 | version "2.6.5" | 5179 | version "2.6.5" |
5162 | resolved "https://registry.yarnpkg.com/redis/-/redis-2.6.5.tgz#87c1eff4a489f94b70871f3d08b6988f23a95687" | 5180 | resolved "https://registry.yarnpkg.com/redis/-/redis-2.6.5.tgz#87c1eff4a489f94b70871f3d08b6988f23a95687" |