Add ability to reset our password
This commit is contained in:
parent
80d1057bfc
commit
ecb4e35f4e
32 changed files with 741 additions and 67 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -7,7 +7,7 @@
|
||||||
/test6/
|
/test6/
|
||||||
/storage/
|
/storage/
|
||||||
/config/production.yaml
|
/config/production.yaml
|
||||||
/config/local*.json
|
/config/local*
|
||||||
/ffmpeg/
|
/ffmpeg/
|
||||||
/*.sublime-project
|
/*.sublime-project
|
||||||
/*.sublime-workspace
|
/*.sublime-workspace
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { BrowserModule } from '@angular/platform-browser'
|
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 { AppRoutingModule } from './app-routing.module'
|
||||||
import { AppComponent } from './app.component'
|
import { AppComponent } from './app.component'
|
||||||
|
|
||||||
import { AccountModule } from './account'
|
|
||||||
import { CoreModule } from './core'
|
import { CoreModule } from './core'
|
||||||
import { LoginModule } from './login'
|
|
||||||
import { SignupModule } from './signup'
|
|
||||||
import { SharedModule } from './shared'
|
|
||||||
import { VideosModule } from './videos'
|
|
||||||
import { MenuComponent } from './menu'
|
|
||||||
import { HeaderComponent } from './header'
|
import { HeaderComponent } from './header'
|
||||||
|
import { LoginModule } from './login'
|
||||||
|
import { MenuComponent } from './menu'
|
||||||
|
import { SharedModule } from './shared'
|
||||||
|
import { SignupModule } from './signup'
|
||||||
|
import { VideosModule } from './videos'
|
||||||
|
|
||||||
export function metaFactory (): MetaLoader {
|
export function metaFactory (): MetaLoader {
|
||||||
return new MetaStaticLoader({
|
return new MetaStaticLoader({
|
||||||
|
@ -46,6 +47,7 @@ export function metaFactory (): MetaLoader {
|
||||||
AccountModule,
|
AccountModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
LoginModule,
|
LoginModule,
|
||||||
|
ResetPasswordModule,
|
||||||
SignupModule,
|
SignupModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
VideosModule,
|
VideosModule,
|
||||||
|
|
|
@ -19,10 +19,13 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
|
<div>
|
||||||
<input
|
<input
|
||||||
type="password" name="password" id="password" placeholder="Password" required
|
type="password" name="password" id="password" placeholder="Password" required
|
||||||
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
|
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">
|
<div *ngIf="formErrors.password" class="form-error">
|
||||||
{{ formErrors.password }}
|
{{ formErrors.password }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -31,3 +34,36 @@
|
||||||
<input type="submit" value="Login" [disabled]="!form.valid">
|
<input type="submit" value="Login" [disabled]="!form.valid">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
|
|
|
@ -10,3 +10,13 @@ input[type=submit] {
|
||||||
@include peertube-button;
|
@include peertube-button;
|
||||||
@include orange-button;
|
@include orange-button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type=password] {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-button {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
|
@ -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 { FormBuilder, FormGroup, Validators } from '@angular/forms'
|
||||||
import { Router } from '@angular/router'
|
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 { AuthService } from '../core'
|
||||||
import { FormReactive } from '../shared'
|
import { FormReactive } from '../shared'
|
||||||
|
|
||||||
|
@ -12,6 +14,9 @@ import { FormReactive } from '../shared'
|
||||||
})
|
})
|
||||||
|
|
||||||
export class LoginComponent extends FormReactive implements OnInit {
|
export class LoginComponent extends FormReactive implements OnInit {
|
||||||
|
@ViewChild('forgotPasswordModal') forgotPasswordModal: ModalDirective
|
||||||
|
@ViewChild('forgotPasswordEmailInput') forgotPasswordEmailInput: ElementRef
|
||||||
|
|
||||||
error: string = null
|
error: string = null
|
||||||
|
|
||||||
form: FormGroup
|
form: FormGroup
|
||||||
|
@ -27,9 +32,12 @@ export class LoginComponent extends FormReactive implements OnInit {
|
||||||
'required': 'Password is required.'
|
'required': 'Password is required.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
forgotPasswordEmail = ''
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
|
private userService: UserService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private router: Router
|
private router: Router
|
||||||
) {
|
) {
|
||||||
|
@ -60,4 +68,29 @@ export class LoginComponent extends FormReactive implements OnInit {
|
||||||
err => this.error = err.message
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
3
client/src/app/reset-password/index.ts
Normal file
3
client/src/app/reset-password/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './reset-password-routing.module'
|
||||||
|
export * from './reset-password.component'
|
||||||
|
export * from './reset-password.module'
|
|
@ -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 {}
|
33
client/src/app/reset-password/reset-password.component.html
Normal file
33
client/src/app/reset-password/reset-password.component.html
Normal file
|
@ -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>
|
12
client/src/app/reset-password/reset-password.component.scss
Normal file
12
client/src/app/reset-password/reset-password.component.scss
Normal file
|
@ -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;
|
||||||
|
}
|
79
client/src/app/reset-password/reset-password.component.ts
Normal file
79
client/src/app/reset-password/reset-password.component.ts
Normal file
|
@ -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']
|
||||||
|
}
|
||||||
|
}
|
24
client/src/app/reset-password/reset-password.module.ts
Normal file
24
client/src/app/reset-password/reset-password.module.ts
Normal file
|
@ -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 { }
|
|
@ -5,7 +5,6 @@ import 'rxjs/add/operator/map'
|
||||||
import { UserCreate, UserUpdateMe } from '../../../../../shared'
|
import { UserCreate, UserUpdateMe } from '../../../../../shared'
|
||||||
import { environment } from '../../../environments/environment'
|
import { environment } from '../../../environments/environment'
|
||||||
import { RestExtractor } from '../rest'
|
import { RestExtractor } from '../rest'
|
||||||
import { User } from './user.model'
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
@ -54,4 +53,24 @@ export class UserService {
|
||||||
return this.authHttp.get(url)
|
return this.authHttp.get(url)
|
||||||
.catch(res => this.restExtractor.handleError(res))
|
.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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,26 +19,30 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
|
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
|
||||||
// import 'core-js/es6/symbol';
|
|
||||||
// import 'core-js/es6/object';
|
// For Google Bot
|
||||||
// import 'core-js/es6/function';
|
import 'core-js/es6/symbol';
|
||||||
// import 'core-js/es6/parse-int';
|
import 'core-js/es6/object';
|
||||||
// import 'core-js/es6/parse-float';
|
import 'core-js/es6/function';
|
||||||
// import 'core-js/es6/number';
|
import 'core-js/es6/parse-int';
|
||||||
// import 'core-js/es6/math';
|
import 'core-js/es6/parse-float';
|
||||||
// import 'core-js/es6/string';
|
import 'core-js/es6/number';
|
||||||
// import 'core-js/es6/date';
|
import 'core-js/es6/math';
|
||||||
// import 'core-js/es6/array';
|
import 'core-js/es6/string';
|
||||||
// import 'core-js/es6/regexp';
|
import 'core-js/es6/date';
|
||||||
// import 'core-js/es6/map';
|
import 'core-js/es6/array';
|
||||||
// import 'core-js/es6/weak-map';
|
import 'core-js/es6/regexp';
|
||||||
// import 'core-js/es6/set';
|
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 */
|
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
|
||||||
// import 'classlist.js'; // Run `npm install --save classlist.js`.
|
// import 'classlist.js'; // Run `npm install --save classlist.js`.
|
||||||
|
|
||||||
/** IE10 and IE11 requires the following for the Reflect API. */
|
/** 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. **/
|
/** Evergreen browsers require these. **/
|
||||||
|
|
|
@ -19,7 +19,7 @@ $FontPathSourceSansPro: '../../node_modules/npm-font-source-sans-pro/fonts';
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Source Sans Pro';
|
font-family: 'Source Sans Pro', sans-serif;
|
||||||
font-weight: $font-regular;
|
font-weight: $font-regular;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,15 @@ redis:
|
||||||
port: 6379
|
port: 6379
|
||||||
auth: null
|
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
|
# From the project root directory
|
||||||
storage:
|
storage:
|
||||||
avatars: 'storage/avatars/'
|
avatars: 'storage/avatars/'
|
||||||
|
@ -37,7 +46,7 @@ cache:
|
||||||
size: 1 # Max number of previews you want to cache
|
size: 1 # Max number of previews you want to cache
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
email: 'admin@example.com'
|
email: 'admin@example.com' # Your personal email as administrator
|
||||||
|
|
||||||
signup:
|
signup:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
|
@ -20,6 +20,15 @@ redis:
|
||||||
port: 6379
|
port: 6379
|
||||||
auth: null
|
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
|
# From the project root directory
|
||||||
storage:
|
storage:
|
||||||
avatars: '/var/www/peertube/storage/avatars/'
|
avatars: '/var/www/peertube/storage/avatars/'
|
||||||
|
|
|
@ -76,11 +76,13 @@
|
||||||
"mkdirp": "^0.5.1",
|
"mkdirp": "^0.5.1",
|
||||||
"morgan": "^1.5.3",
|
"morgan": "^1.5.3",
|
||||||
"multer": "^1.1.0",
|
"multer": "^1.1.0",
|
||||||
|
"nodemailer": "^4.4.2",
|
||||||
"parse-torrent": "^5.8.0",
|
"parse-torrent": "^5.8.0",
|
||||||
"password-generator": "^2.0.2",
|
"password-generator": "^2.0.2",
|
||||||
"pem": "^1.12.3",
|
"pem": "^1.12.3",
|
||||||
"pg": "^6.4.2",
|
"pg": "^6.4.2",
|
||||||
"pg-hstore": "^2.3.2",
|
"pg-hstore": "^2.3.2",
|
||||||
|
"redis": "^2.8.0",
|
||||||
"reflect-metadata": "^0.1.10",
|
"reflect-metadata": "^0.1.10",
|
||||||
"request": "^2.81.0",
|
"request": "^2.81.0",
|
||||||
"rimraf": "^2.5.4",
|
"rimraf": "^2.5.4",
|
||||||
|
@ -112,7 +114,9 @@
|
||||||
"@types/morgan": "^1.7.32",
|
"@types/morgan": "^1.7.32",
|
||||||
"@types/multer": "^1.3.3",
|
"@types/multer": "^1.3.3",
|
||||||
"@types/node": "^9.3.0",
|
"@types/node": "^9.3.0",
|
||||||
|
"@types/nodemailer": "^4.3.1",
|
||||||
"@types/pem": "^1.9.3",
|
"@types/pem": "^1.9.3",
|
||||||
|
"@types/redis": "^2.8.5",
|
||||||
"@types/request": "^2.0.3",
|
"@types/request": "^2.0.3",
|
||||||
"@types/sequelize": "^4.0.55",
|
"@types/sequelize": "^4.0.55",
|
||||||
"@types/sharp": "^0.17.6",
|
"@types/sharp": "^0.17.6",
|
||||||
|
|
|
@ -3,12 +3,11 @@
|
||||||
printf "############# PeerTube help #############\n\n"
|
printf "############# PeerTube help #############\n\n"
|
||||||
printf "npm run ...\n"
|
printf "npm run ...\n"
|
||||||
printf " build -> Build the application for production (alias of build:client:prod)\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:server -> Build the server for production\n"
|
||||||
printf " build:client:prod -> Build the client for production\n"
|
printf " build:client -> Build the client for production\n"
|
||||||
printf " clean -> Clean the application\n"
|
|
||||||
printf " clean:client -> Clean the client build files (dist directory)\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 " clean:server:test -> Clean logs, uploads, database... of the test instances\n"
|
||||||
printf " watch:client -> Watch the client files\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: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: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"
|
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 " 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 " dev -> Watch, run the livereload and run the server so that you can develop the application\n"
|
||||||
printf " start -> Run the server\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 " 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 " test -> Run the tests\n"
|
||||||
printf " help -> Print this help\n"
|
printf " help -> Print this help\n"
|
||||||
|
|
14
server.ts
14
server.ts
|
@ -53,9 +53,11 @@ migrate()
|
||||||
|
|
||||||
// ----------- PeerTube modules -----------
|
// ----------- PeerTube modules -----------
|
||||||
import { installApplication } from './server/initializers'
|
import { installApplication } from './server/initializers'
|
||||||
|
import { Emailer } from './server/lib/emailer'
|
||||||
import { JobQueue } from './server/lib/job-queue'
|
import { JobQueue } from './server/lib/job-queue'
|
||||||
import { VideosPreviewCache } from './server/lib/cache'
|
import { VideosPreviewCache } from './server/lib/cache'
|
||||||
import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers'
|
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 { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler'
|
||||||
import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
|
import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
|
||||||
|
|
||||||
|
@ -169,10 +171,20 @@ function onDatabaseInitDone () {
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// ----------- Make the server listening -----------
|
// ----------- Make the server listening -----------
|
||||||
server.listen(port, () => {
|
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)
|
VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE)
|
||||||
|
|
||||||
|
// Enable Schedulers
|
||||||
BadActorFollowScheduler.Instance.enable()
|
BadActorFollowScheduler.Instance.enable()
|
||||||
RemoveOldJobsScheduler.Instance.enable()
|
RemoveOldJobsScheduler.Instance.enable()
|
||||||
JobQueue.Instance.init()
|
|
||||||
|
// Redis initialization
|
||||||
|
Redis.Instance.init()
|
||||||
|
|
||||||
logger.info('Server listening on port %d', port)
|
logger.info('Server listening on port %d', port)
|
||||||
logger.info('Web server: %s', CONFIG.WEBSERVER.URL)
|
logger.info('Web server: %s', CONFIG.WEBSERVER.URL)
|
||||||
|
|
|
@ -6,17 +6,23 @@ import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRat
|
||||||
import { unlinkPromise } from '../../helpers/core-utils'
|
import { unlinkPromise } from '../../helpers/core-utils'
|
||||||
import { retryTransactionWrapper } from '../../helpers/database-utils'
|
import { retryTransactionWrapper } from '../../helpers/database-utils'
|
||||||
import { logger } from '../../helpers/logger'
|
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 { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers'
|
||||||
import { updateActorAvatarInstance } from '../../lib/activitypub'
|
import { updateActorAvatarInstance } from '../../lib/activitypub'
|
||||||
import { sendUpdateUser } from '../../lib/activitypub/send'
|
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 { createUserAccountAndChannel } from '../../lib/user'
|
||||||
import {
|
import {
|
||||||
asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort,
|
asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort,
|
||||||
setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator,
|
setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator,
|
||||||
usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator
|
usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator
|
||||||
} from '../../middlewares'
|
} 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 { AccountVideoRateModel } from '../../models/account/account-video-rate'
|
||||||
import { UserModel } from '../../models/account/user'
|
import { UserModel } from '../../models/account/user'
|
||||||
import { OAuthTokenModel } from '../../models/oauth/oauth-token'
|
import { OAuthTokenModel } from '../../models/oauth/oauth-token'
|
||||||
|
@ -106,6 +112,16 @@ usersRouter.delete('/:id',
|
||||||
asyncMiddleware(removeUser)
|
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)
|
usersRouter.post('/token', token, success)
|
||||||
// TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route
|
// 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)
|
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) {
|
function success (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ const loggerFormat = winston.format.printf((info) => {
|
||||||
if (additionalInfos === '{}') additionalInfos = ''
|
if (additionalInfos === '{}') additionalInfos = ''
|
||||||
else 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}`
|
return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,8 @@ function checkMissedConfig () {
|
||||||
'webserver.https', 'webserver.hostname', 'webserver.port',
|
'webserver.https', 'webserver.hostname', 'webserver.port',
|
||||||
'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password',
|
'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password',
|
||||||
'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level',
|
'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[] = []
|
const miss: string[] = []
|
||||||
|
|
||||||
|
|
|
@ -65,13 +65,15 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = {
|
||||||
'activitypub-http-broadcast': 5,
|
'activitypub-http-broadcast': 5,
|
||||||
'activitypub-http-unicast': 5,
|
'activitypub-http-unicast': 5,
|
||||||
'activitypub-http-fetcher': 5,
|
'activitypub-http-fetcher': 5,
|
||||||
'video-file': 1
|
'video-file': 1,
|
||||||
|
'email': 5
|
||||||
}
|
}
|
||||||
const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
|
const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
|
||||||
'activitypub-http-broadcast': 1,
|
'activitypub-http-broadcast': 1,
|
||||||
'activitypub-http-unicast': 5,
|
'activitypub-http-unicast': 5,
|
||||||
'activitypub-http-fetcher': 1,
|
'activitypub-http-fetcher': 1,
|
||||||
'video-file': 1
|
'video-file': 1,
|
||||||
|
'email': 5
|
||||||
}
|
}
|
||||||
// 2 days
|
// 2 days
|
||||||
const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2
|
const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2
|
||||||
|
@ -95,9 +97,18 @@ const CONFIG = {
|
||||||
},
|
},
|
||||||
REDIS: {
|
REDIS: {
|
||||||
HOSTNAME: config.get<string>('redis.hostname'),
|
HOSTNAME: config.get<string>('redis.hostname'),
|
||||||
PORT: config.get<string>('redis.port'),
|
PORT: config.get<number>('redis.port'),
|
||||||
AUTH: config.get<string>('redis.auth')
|
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: {
|
STORAGE: {
|
||||||
AVATARS_DIR: buildPath(config.get<string>('storage.avatars')),
|
AVATARS_DIR: buildPath(config.get<string>('storage.avatars')),
|
||||||
LOG_DIR: buildPath(config.get<string>('storage.logs')),
|
LOG_DIR: buildPath(config.get<string>('storage.logs')),
|
||||||
|
@ -311,6 +322,8 @@ const PRIVATE_RSA_KEY_SIZE = 2048
|
||||||
// Password encryption
|
// Password encryption
|
||||||
const BCRYPT_SALT_SIZE = 10
|
const BCRYPT_SALT_SIZE = 10
|
||||||
|
|
||||||
|
const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Express static paths (router)
|
// Express static paths (router)
|
||||||
|
@ -408,6 +421,7 @@ export {
|
||||||
VIDEO_LICENCES,
|
VIDEO_LICENCES,
|
||||||
VIDEO_RATE_TYPES,
|
VIDEO_RATE_TYPES,
|
||||||
VIDEO_MIMETYPE_EXT,
|
VIDEO_MIMETYPE_EXT,
|
||||||
|
USER_PASSWORD_RESET_LIFETIME,
|
||||||
AVATAR_MIMETYPE_EXT,
|
AVATAR_MIMETYPE_EXT,
|
||||||
SCHEDULER_INTERVAL,
|
SCHEDULER_INTERVAL,
|
||||||
JOB_COMPLETED_LIFETIME
|
JOB_COMPLETED_LIFETIME
|
||||||
|
|
106
server/lib/emailer.ts
Normal file
106
server/lib/emailer.ts
Normal file
|
@ -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
|
||||||
|
}
|
22
server/lib/job-queue/handlers/email.ts
Normal file
22
server/lib/job-queue/handlers/email.ts
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -5,19 +5,22 @@ import { CONFIG, JOB_ATTEMPTS, JOB_COMPLETED_LIFETIME, JOB_CONCURRENCY } from '.
|
||||||
import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast'
|
import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast'
|
||||||
import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher'
|
import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher'
|
||||||
import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast'
|
import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast'
|
||||||
|
import { EmailPayload, processEmail } from './handlers/email'
|
||||||
import { processVideoFile, VideoFilePayload } from './handlers/video-file'
|
import { processVideoFile, VideoFilePayload } from './handlers/video-file'
|
||||||
|
|
||||||
type CreateJobArgument =
|
type CreateJobArgument =
|
||||||
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
|
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
|
||||||
{ type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } |
|
{ type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } |
|
||||||
{ type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } |
|
{ 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>} = {
|
const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = {
|
||||||
'activitypub-http-broadcast': processActivityPubHttpBroadcast,
|
'activitypub-http-broadcast': processActivityPubHttpBroadcast,
|
||||||
'activitypub-http-unicast': processActivityPubHttpUnicast,
|
'activitypub-http-unicast': processActivityPubHttpUnicast,
|
||||||
'activitypub-http-fetcher': processActivityPubHttpFetcher,
|
'activitypub-http-fetcher': processActivityPubHttpFetcher,
|
||||||
'video-file': processVideoFile
|
'video-file': processVideoFile,
|
||||||
|
'email': processEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
class JobQueue {
|
class JobQueue {
|
||||||
|
@ -43,6 +46,8 @@ class JobQueue {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.jobQueue.setMaxListeners(15)
|
||||||
|
|
||||||
this.jobQueue.on('error', err => {
|
this.jobQueue.on('error', err => {
|
||||||
logger.error('Error in job queue.', err)
|
logger.error('Error in job queue.', err)
|
||||||
process.exit(-1)
|
process.exit(-1)
|
||||||
|
|
84
server/lib/redis.ts
Normal file
84
server/lib/redis.ts
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -1,18 +1,25 @@
|
||||||
|
import * as Bluebird from 'bluebird'
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import 'express-validator'
|
import 'express-validator'
|
||||||
import { body, param } from 'express-validator/check'
|
import { body, param } from 'express-validator/check'
|
||||||
|
import { omit } from 'lodash'
|
||||||
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
|
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
|
||||||
import {
|
import {
|
||||||
isAvatarFile, isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid,
|
isAvatarFile,
|
||||||
|
isUserAutoPlayVideoValid,
|
||||||
|
isUserDisplayNSFWValid,
|
||||||
|
isUserPasswordValid,
|
||||||
|
isUserRoleValid,
|
||||||
|
isUserUsernameValid,
|
||||||
isUserVideoQuotaValid
|
isUserVideoQuotaValid
|
||||||
} from '../../helpers/custom-validators/users'
|
} from '../../helpers/custom-validators/users'
|
||||||
import { isVideoExist } from '../../helpers/custom-validators/videos'
|
import { isVideoExist } from '../../helpers/custom-validators/videos'
|
||||||
import { logger } from '../../helpers/logger'
|
import { logger } from '../../helpers/logger'
|
||||||
import { isSignupAllowed } from '../../helpers/utils'
|
import { isSignupAllowed } from '../../helpers/utils'
|
||||||
import { CONSTRAINTS_FIELDS } from '../../initializers'
|
import { CONSTRAINTS_FIELDS } from '../../initializers'
|
||||||
|
import { Redis } from '../../lib/redis'
|
||||||
import { UserModel } from '../../models/account/user'
|
import { UserModel } from '../../models/account/user'
|
||||||
import { areValidationErrors } from './utils'
|
import { areValidationErrors } from './utils'
|
||||||
import { omit } from 'lodash'
|
|
||||||
|
|
||||||
const usersAddValidator = [
|
const usersAddValidator = [
|
||||||
body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'),
|
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 {
|
export {
|
||||||
|
@ -178,24 +228,19 @@ export {
|
||||||
usersVideoRatingValidator,
|
usersVideoRatingValidator,
|
||||||
ensureUserRegistrationAllowed,
|
ensureUserRegistrationAllowed,
|
||||||
usersGetValidator,
|
usersGetValidator,
|
||||||
usersUpdateMyAvatarValidator
|
usersUpdateMyAvatarValidator,
|
||||||
|
usersAskResetPasswordValidator,
|
||||||
|
usersResetPasswordValidator
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function checkUserIdExist (id: number, res: express.Response) {
|
function checkUserIdExist (id: number, res: express.Response) {
|
||||||
const user = await UserModel.loadById(id)
|
return checkUserExist(() => UserModel.loadById(id), res)
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
|
||||||
res.status(404)
|
return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
|
||||||
.send({ error: 'User not found' })
|
|
||||||
.end()
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
res.locals.user = user
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
|
async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
|
||||||
|
@ -210,3 +255,21 @@ async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email:
|
||||||
|
|
||||||
return true
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -161,6 +161,16 @@ export class UserModel extends Model<UserModel> {
|
||||||
return UserModel.scope('withVideoChannel').findOne(query)
|
return UserModel.scope('withVideoChannel').findOne(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static loadByEmail (email: string) {
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserModel.findOne(query)
|
||||||
|
}
|
||||||
|
|
||||||
static loadByUsernameOrEmail (username: string, email?: string) {
|
static loadByUsernameOrEmail (username: string, email?: string) {
|
||||||
if (!email) email = username
|
if (!email) email = username
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,8 @@ export type JobState = 'active' | 'complete' | 'failed' | 'inactive' | 'delayed'
|
||||||
export type JobType = 'activitypub-http-unicast' |
|
export type JobType = 'activitypub-http-unicast' |
|
||||||
'activitypub-http-broadcast' |
|
'activitypub-http-broadcast' |
|
||||||
'activitypub-http-fetcher' |
|
'activitypub-http-fetcher' |
|
||||||
'video-file'
|
'video-file' |
|
||||||
|
'email'
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
id: number
|
id: number
|
||||||
|
|
|
@ -19,6 +19,8 @@
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"storage",
|
||||||
"client",
|
"client",
|
||||||
"test1",
|
"test1",
|
||||||
"test2",
|
"test2",
|
||||||
|
|
22
yarn.lock
22
yarn.lock
|
@ -134,6 +134,12 @@
|
||||||
version "6.0.41"
|
version "6.0.41"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.41.tgz#578cf53aaec65887bcaf16792f8722932e8ff8ea"
|
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@*":
|
"@types/parse-torrent-file@*":
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.1.tgz#056a6c18f3fac0cd7c6c74540f00496a3225976b"
|
resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.1.tgz#056a6c18f3fac0cd7c6c74540f00496a3225976b"
|
||||||
|
@ -152,7 +158,7 @@
|
||||||
version "1.9.3"
|
version "1.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.3.tgz#0c864c8b79e43fef6367db895f60fd1edd10e86c"
|
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"
|
version "2.8.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.5.tgz#c4a31a63e95434202eb84908290528ad8510b149"
|
resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.5.tgz#c4a31a63e95434202eb84908290528ad8510b149"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -4274,6 +4280,10 @@ node-sass@^4.0.0:
|
||||||
stdout-stream "^1.4.0"
|
stdout-stream "^1.4.0"
|
||||||
"true-case-path" "^1.0.2"
|
"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:
|
nodemon@^1.11.0:
|
||||||
version "1.14.11"
|
version "1.14.11"
|
||||||
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.14.11.tgz#cc0009dd8d82f126f3aba50ace7e753827a8cebc"
|
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"
|
version "1.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.3.1.tgz#81d826f45fa9c8b2011f4cd7a0fe597d241d442b"
|
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"
|
version "2.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b"
|
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"
|
version "0.12.1"
|
||||||
resolved "https://registry.yarnpkg.com/redis/-/redis-0.12.1.tgz#64df76ad0fc8acebaebd2a0645e8a48fac49185e"
|
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:
|
redis@~2.6.0-2:
|
||||||
version "2.6.5"
|
version "2.6.5"
|
||||||
resolved "https://registry.yarnpkg.com/redis/-/redis-2.6.5.tgz#87c1eff4a489f94b70871f3d08b6988f23a95687"
|
resolved "https://registry.yarnpkg.com/redis/-/redis-2.6.5.tgz#87c1eff4a489f94b70871f3d08b6988f23a95687"
|
||||||
|
|
Loading…
Reference in a new issue