@@ -7,7 +7,7 @@ | |||
/test6/ | |||
/storage/ | |||
/config/production.yaml | |||
/config/local*.json | |||
/config/local* | |||
/ffmpeg/ | |||
/*.sublime-project | |||
/*.sublime-workspace | |||
@@ -1,19 +1,20 @@ | |||
import { NgModule } from '@angular/core' | |||
import { BrowserModule } from '@angular/platform-browser' | |||
import { ResetPasswordModule } from '@app/reset-password' | |||
import { MetaModule, MetaLoader, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' | |||
import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' | |||
import { AccountModule } from './account' | |||
import { AppRoutingModule } from './app-routing.module' | |||
import { AppComponent } from './app.component' | |||
import { AccountModule } from './account' | |||
import { CoreModule } from './core' | |||
import { HeaderComponent } from './header' | |||
import { LoginModule } from './login' | |||
import { SignupModule } from './signup' | |||
import { MenuComponent } from './menu' | |||
import { SharedModule } from './shared' | |||
import { SignupModule } from './signup' | |||
import { VideosModule } from './videos' | |||
import { MenuComponent } from './menu' | |||
import { HeaderComponent } from './header' | |||
export function metaFactory (): MetaLoader { | |||
return new MetaStaticLoader({ | |||
@@ -46,6 +47,7 @@ export function metaFactory (): MetaLoader { | |||
AccountModule, | |||
CoreModule, | |||
LoginModule, | |||
ResetPasswordModule, | |||
SignupModule, | |||
SharedModule, | |||
VideosModule, | |||
@@ -19,10 +19,13 @@ | |||
<div class="form-group"> | |||
<label for="password">Password</label> | |||
<input | |||
type="password" name="password" id="password" placeholder="Password" required | |||
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | |||
> | |||
<div> | |||
<input | |||
type="password" name="password" id="password" placeholder="Password" required | |||
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | |||
> | |||
<div class="forgot-password-button" (click)="openForgotPasswordModal()">I forgot my password</div> | |||
</div> | |||
<div *ngIf="formErrors.password" class="form-error"> | |||
{{ formErrors.password }} | |||
</div> | |||
@@ -31,3 +34,36 @@ | |||
<input type="submit" value="Login" [disabled]="!form.valid"> | |||
</form> | |||
</div> | |||
<div bsModal #forgotPasswordModal="bs-modal" (onShown)="onForgotPasswordModalShown()" class="modal" tabindex="-1"> | |||
<div class="modal-dialog"> | |||
<div class="modal-content"> | |||
<div class="modal-header"> | |||
<span class="close" aria-hidden="true" (click)="hideForgotPasswordModal()"></span> | |||
<h4 class="modal-title">Forgot your password</h4> | |||
</div> | |||
<div class="modal-body"> | |||
<div class="form-group"> | |||
<label for="forgot-password-email">Email</label> | |||
<input | |||
type="email" id="forgot-password-email" placeholder="Email address" required | |||
[(ngModel)]="forgotPasswordEmail" #forgotPasswordEmailInput | |||
> | |||
</div> | |||
<div class="form-group inputs"> | |||
<span class="action-button action-button-cancel" (click)="hideForgotPasswordModal()"> | |||
Cancel | |||
</span> | |||
<input | |||
type="submit" value="Send me an email to reset my password" class="action-button-submit" | |||
(click)="askResetPassword()" [disabled]="!forgotPasswordEmailInput.validity.valid" | |||
> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> |
@@ -10,3 +10,13 @@ input[type=submit] { | |||
@include peertube-button; | |||
@include orange-button; | |||
} | |||
input[type=password] { | |||
display: inline-block; | |||
margin-right: 5px; | |||
} | |||
.forgot-password-button { | |||
display: inline-block; | |||
cursor: pointer; | |||
} |
@@ -1,7 +1,9 @@ | |||
import { Component, OnInit } from '@angular/core' | |||
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' | |||
import { FormBuilder, FormGroup, Validators } from '@angular/forms' | |||
import { Router } from '@angular/router' | |||
import { UserService } from '@app/shared' | |||
import { NotificationsService } from 'angular2-notifications' | |||
import { ModalDirective } from 'ngx-bootstrap/modal' | |||
import { AuthService } from '../core' | |||
import { FormReactive } from '../shared' | |||
@@ -12,6 +14,9 @@ import { FormReactive } from '../shared' | |||
}) | |||
export class LoginComponent extends FormReactive implements OnInit { | |||
@ViewChild('forgotPasswordModal') forgotPasswordModal: ModalDirective | |||
@ViewChild('forgotPasswordEmailInput') forgotPasswordEmailInput: ElementRef | |||
error: string = null | |||
form: FormGroup | |||
@@ -27,9 +32,12 @@ export class LoginComponent extends FormReactive implements OnInit { | |||
'required': 'Password is required.' | |||
} | |||
} | |||
forgotPasswordEmail = '' | |||
constructor ( | |||
private authService: AuthService, | |||
private userService: UserService, | |||
private notificationsService: NotificationsService, | |||
private formBuilder: FormBuilder, | |||
private router: Router | |||
) { | |||
@@ -60,4 +68,29 @@ export class LoginComponent extends FormReactive implements OnInit { | |||
err => this.error = err.message | |||
) | |||
} | |||
askResetPassword () { | |||
this.userService.askResetPassword(this.forgotPasswordEmail) | |||
.subscribe( | |||
res => { | |||
const message = `An email with the reset password instructions will be sent to ${this.forgotPasswordEmail}.` | |||
this.notificationsService.success('Success', message) | |||
this.hideForgotPasswordModal() | |||
}, | |||
err => this.notificationsService.error('Error', err.message) | |||
) | |||
} | |||
onForgotPasswordModalShown () { | |||
this.forgotPasswordEmailInput.nativeElement.focus() | |||
} | |||
openForgotPasswordModal () { | |||
this.forgotPasswordModal.show() | |||
} | |||
hideForgotPasswordModal () { | |||
this.forgotPasswordModal.hide() | |||
} | |||
} |
@@ -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 {} |
@@ -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> |
@@ -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; | |||
} |
@@ -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'] | |||
} | |||
} |
@@ -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 { environment } from '../../../environments/environment' | |||
import { RestExtractor } from '../rest' | |||
import { User } from './user.model' | |||
@Injectable() | |||
export class UserService { | |||
@@ -54,4 +53,24 @@ export class UserService { | |||
return this.authHttp.get(url) | |||
.catch(res => this.restExtractor.handleError(res)) | |||
} | |||
askResetPassword (email: string) { | |||
const url = UserService.BASE_USERS_URL + '/ask-reset-password' | |||
return this.authHttp.post(url, { email }) | |||
.map(this.restExtractor.extractDataBool) | |||
.catch(res => this.restExtractor.handleError(res)) | |||
} | |||
resetPassword (userId: number, verificationString: string, password: string) { | |||
const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password` | |||
const body = { | |||
verificationString, | |||
password | |||
} | |||
return this.authHttp.post(url, body) | |||
.map(this.restExtractor.extractDataBool) | |||
.catch(res => this.restExtractor.handleError(res)) | |||
} | |||
} |
@@ -19,26 +19,30 @@ | |||
*/ | |||
/** IE9, IE10 and IE11 requires all of the following polyfills. **/ | |||
// import 'core-js/es6/symbol'; | |||
// import 'core-js/es6/object'; | |||
// import 'core-js/es6/function'; | |||
// import 'core-js/es6/parse-int'; | |||
// import 'core-js/es6/parse-float'; | |||
// import 'core-js/es6/number'; | |||
// import 'core-js/es6/math'; | |||
// import 'core-js/es6/string'; | |||
// import 'core-js/es6/date'; | |||
// import 'core-js/es6/array'; | |||
// import 'core-js/es6/regexp'; | |||
// import 'core-js/es6/map'; | |||
// import 'core-js/es6/weak-map'; | |||
// import 'core-js/es6/set'; | |||
// For Google Bot | |||
import 'core-js/es6/symbol'; | |||
import 'core-js/es6/object'; | |||
import 'core-js/es6/function'; | |||
import 'core-js/es6/parse-int'; | |||
import 'core-js/es6/parse-float'; | |||
import 'core-js/es6/number'; | |||
import 'core-js/es6/math'; | |||
import 'core-js/es6/string'; | |||
import 'core-js/es6/date'; | |||
import 'core-js/es6/array'; | |||
import 'core-js/es6/regexp'; | |||
import 'core-js/es6/map'; | |||
import 'core-js/es6/weak-map'; | |||
import 'core-js/es6/set'; | |||
/** IE10 and IE11 requires the following for NgClass support on SVG elements */ | |||
// import 'classlist.js'; // Run `npm install --save classlist.js`. | |||
/** IE10 and IE11 requires the following for the Reflect API. */ | |||
// import 'core-js/es6/reflect'; | |||
// For Google Bot | |||
import 'core-js/es6/reflect'; | |||
/** Evergreen browsers require these. **/ | |||
@@ -19,7 +19,7 @@ $FontPathSourceSansPro: '../../node_modules/npm-font-source-sans-pro/fonts'; | |||
} | |||
body { | |||
font-family: 'Source Sans Pro'; | |||
font-family: 'Source Sans Pro', sans-serif; | |||
font-weight: $font-regular; | |||
color: #000; | |||
} | |||
@@ -19,6 +19,15 @@ redis: | |||
port: 6379 | |||
auth: null | |||
smtp: | |||
hostname: null | |||
port: 465 | |||
username: null | |||
password: null | |||
tls: true | |||
ca_file: null # Used for self signed certificates | |||
from_address: 'admin@example.com' | |||
# From the project root directory | |||
storage: | |||
avatars: 'storage/avatars/' | |||
@@ -37,7 +46,7 @@ cache: | |||
size: 1 # Max number of previews you want to cache | |||
admin: | |||
email: 'admin@example.com' | |||
email: 'admin@example.com' # Your personal email as administrator | |||
signup: | |||
enabled: false | |||
@@ -20,6 +20,15 @@ redis: | |||
port: 6379 | |||
auth: null | |||
smtp: | |||
hostname: null | |||
port: 465 | |||
username: null | |||
password: null | |||
tls: true | |||
ca_file: null # Used for self signed certificates | |||
from_address: 'admin@example.com' | |||
# From the project root directory | |||
storage: | |||
avatars: '/var/www/peertube/storage/avatars/' | |||
@@ -76,11 +76,13 @@ | |||
"mkdirp": "^0.5.1", | |||
"morgan": "^1.5.3", | |||
"multer": "^1.1.0", | |||
"nodemailer": "^4.4.2", | |||
"parse-torrent": "^5.8.0", | |||
"password-generator": "^2.0.2", | |||
"pem": "^1.12.3", | |||
"pg": "^6.4.2", | |||
"pg-hstore": "^2.3.2", | |||
"redis": "^2.8.0", | |||
"reflect-metadata": "^0.1.10", | |||
"request": "^2.81.0", | |||
"rimraf": "^2.5.4", | |||
@@ -112,7 +114,9 @@ | |||
"@types/morgan": "^1.7.32", | |||
"@types/multer": "^1.3.3", | |||
"@types/node": "^9.3.0", | |||
"@types/nodemailer": "^4.3.1", | |||
"@types/pem": "^1.9.3", | |||
"@types/redis": "^2.8.5", | |||
"@types/request": "^2.0.3", | |||
"@types/sequelize": "^4.0.55", | |||
"@types/sharp": "^0.17.6", | |||
@@ -3,12 +3,11 @@ | |||
printf "############# PeerTube help #############\n\n" | |||
printf "npm run ...\n" | |||
printf " build -> Build the application for production (alias of build:client:prod)\n" | |||
printf " build:server:prod -> Build the server for production\n" | |||
printf " build:client:prod -> Build the client for production\n" | |||
printf " clean -> Clean the application\n" | |||
printf " build:server -> Build the server for production\n" | |||
printf " build:client -> Build the client for production\n" | |||
printf " clean:client -> Clean the client build files (dist directory)\n" | |||
printf " clean:server:test -> Clean certificates, logs, uploads and database of the test instances\n" | |||
printf " watch:client -> Watch the client files\n" | |||
printf " clean:server:test -> Clean logs, uploads, database... of the test instances\n" | |||
printf " watch:client -> Watch and compile on the fly the client files\n" | |||
printf " danger:clean:dev -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified in the development environment\n" | |||
printf " danger:clean:prod -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified by the production environment\n" | |||
printf " danger:clean:modules -> /!\ Clean node and typescript modules\n" | |||
@@ -16,8 +15,7 @@ printf " play -> Run 3 fresh nodes so that you can test | |||
printf " reset-password -- -u [user] -> Reset the password of user [user]\n" | |||
printf " dev -> Watch, run the livereload and run the server so that you can develop the application\n" | |||
printf " start -> Run the server\n" | |||
printf " check -> Check the server (according to NODE_ENV)\n" | |||
printf " upgrade -- [branch] -> Upgrade the application according to the [branch] parameter\n" | |||
printf " update-host -> Upgrade scheme/host in torrent files according to the webserver configuration (config/ folder)\n" | |||
printf " client-report -> Open a report of the client dependencies module\n" | |||
printf " test -> Run the tests\n" | |||
printf " help -> Print this help\n" |
@@ -53,9 +53,11 @@ migrate() | |||
// ----------- PeerTube modules ----------- | |||
import { installApplication } from './server/initializers' | |||
import { Emailer } from './server/lib/emailer' | |||
import { JobQueue } from './server/lib/job-queue' | |||
import { VideosPreviewCache } from './server/lib/cache' | |||
import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers' | |||
import { Redis } from './server/lib/redis' | |||
import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler' | |||
import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler' | |||
@@ -169,10 +171,20 @@ function onDatabaseInitDone () { | |||
.then(() => { | |||
// ----------- Make the server listening ----------- | |||
server.listen(port, () => { | |||
// Emailer initialization and then job queue initialization | |||
Emailer.Instance.init() | |||
Emailer.Instance.checkConnectionOrDie() | |||
.then(() => JobQueue.Instance.init()) | |||
// Caches initializations | |||
VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE) | |||
// Enable Schedulers | |||
BadActorFollowScheduler.Instance.enable() | |||
RemoveOldJobsScheduler.Instance.enable() | |||
JobQueue.Instance.init() | |||
// Redis initialization | |||
Redis.Instance.init() | |||
logger.info('Server listening on port %d', port) | |||
logger.info('Web server: %s', CONFIG.WEBSERVER.URL) | |||
@@ -6,17 +6,23 @@ import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRat | |||
import { unlinkPromise } from '../../helpers/core-utils' | |||
import { retryTransactionWrapper } from '../../helpers/database-utils' | |||
import { logger } from '../../helpers/logger' | |||
import { createReqFiles, getFormattedObjects } from '../../helpers/utils' | |||
import { createReqFiles, generateRandomString, getFormattedObjects } from '../../helpers/utils' | |||
import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers' | |||
import { updateActorAvatarInstance } from '../../lib/activitypub' | |||
import { sendUpdateUser } from '../../lib/activitypub/send' | |||
import { Emailer } from '../../lib/emailer' | |||
import { EmailPayload } from '../../lib/job-queue/handlers/email' | |||
import { Redis } from '../../lib/redis' | |||
import { createUserAccountAndChannel } from '../../lib/user' | |||
import { | |||
asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort, | |||
setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, | |||
usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator | |||
} from '../../middlewares' | |||
import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators' | |||
import { | |||
usersAskResetPasswordValidator, usersResetPasswordValidator, usersUpdateMyAvatarValidator, | |||
videosSortValidator | |||
} from '../../middlewares/validators' | |||
import { AccountVideoRateModel } from '../../models/account/account-video-rate' | |||
import { UserModel } from '../../models/account/user' | |||
import { OAuthTokenModel } from '../../models/oauth/oauth-token' | |||
@@ -106,6 +112,16 @@ usersRouter.delete('/:id', | |||
asyncMiddleware(removeUser) | |||
) | |||
usersRouter.post('/ask-reset-password', | |||
asyncMiddleware(usersAskResetPasswordValidator), | |||
asyncMiddleware(askResetUserPassword) | |||
) | |||
usersRouter.post('/:id/reset-password', | |||
asyncMiddleware(usersResetPasswordValidator), | |||
asyncMiddleware(resetUserPassword) | |||
) | |||
usersRouter.post('/token', token, success) | |||
// TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route | |||
@@ -307,6 +323,25 @@ async function updateUser (req: express.Request, res: express.Response, next: ex | |||
return res.sendStatus(204) | |||
} | |||
async function askResetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) { | |||
const user = res.locals.user as UserModel | |||
const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id) | |||
const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString | |||
await Emailer.Instance.addForgetPasswordEmailJob(user.email, url) | |||
return res.status(204).end() | |||
} | |||
async function resetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) { | |||
const user = res.locals.user as UserModel | |||
user.password = req.body.password | |||
await user.save() | |||
return res.status(204).end() | |||
} | |||
function success (req: express.Request, res: express.Response, next: express.NextFunction) { | |||
res.end() | |||
} |
@@ -26,6 +26,7 @@ const loggerFormat = winston.format.printf((info) => { | |||
if (additionalInfos === '{}') additionalInfos = '' | |||
else additionalInfos = ' ' + additionalInfos | |||
if (info.message.stack !== undefined) info.message = info.message.stack | |||
return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}` | |||
}) | |||
@@ -22,7 +22,8 @@ function checkMissedConfig () { | |||
'webserver.https', 'webserver.hostname', 'webserver.port', | |||
'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', | |||
'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level', | |||
'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', 'user.video_quota' | |||
'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', | |||
'user.video_quota', 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address' | |||
] | |||
const miss: string[] = [] | |||
@@ -65,13 +65,15 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = { | |||
'activitypub-http-broadcast': 5, | |||
'activitypub-http-unicast': 5, | |||
'activitypub-http-fetcher': 5, | |||
'video-file': 1 | |||
'video-file': 1, | |||
'email': 5 | |||
} | |||
const JOB_CONCURRENCY: { [ id in JobType ]: number } = { | |||
'activitypub-http-broadcast': 1, | |||
'activitypub-http-unicast': 5, | |||
'activitypub-http-fetcher': 1, | |||
'video-file': 1 | |||
'video-file': 1, | |||
'email': 5 | |||
} | |||
// 2 days | |||
const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 | |||
@@ -95,9 +97,18 @@ const CONFIG = { | |||
}, | |||
REDIS: { | |||
HOSTNAME: config.get<string>('redis.hostname'), | |||
PORT: config.get<string>('redis.port'), | |||
PORT: config.get<number>('redis.port'), | |||
AUTH: config.get<string>('redis.auth') | |||
}, | |||
SMTP: { | |||
HOSTNAME: config.get<string>('smtp.hostname'), | |||
PORT: config.get<number>('smtp.port'), | |||
USERNAME: config.get<string>('smtp.username'), | |||
PASSWORD: config.get<string>('smtp.password'), | |||
TLS: config.get<boolean>('smtp.tls'), | |||
CA_FILE: config.get<string>('smtp.ca_file'), | |||
FROM_ADDRESS: config.get<string>('smtp.from_address') | |||
}, | |||
STORAGE: { | |||
AVATARS_DIR: buildPath(config.get<string>('storage.avatars')), | |||
LOG_DIR: buildPath(config.get<string>('storage.logs')), | |||
@@ -311,6 +322,8 @@ const PRIVATE_RSA_KEY_SIZE = 2048 | |||
// Password encryption | |||
const BCRYPT_SALT_SIZE = 10 | |||
const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes | |||
// --------------------------------------------------------------------------- | |||
// Express static paths (router) | |||
@@ -408,6 +421,7 @@ export { | |||
VIDEO_LICENCES, | |||
VIDEO_RATE_TYPES, | |||
VIDEO_MIMETYPE_EXT, | |||
USER_PASSWORD_RESET_LIFETIME, | |||
AVATAR_MIMETYPE_EXT, | |||
SCHEDULER_INTERVAL, | |||
JOB_COMPLETED_LIFETIME | |||
@@ -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 | |||
} |
@@ -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 { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' | |||
import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' | |||
import { EmailPayload, processEmail } from './handlers/email' | |||
import { processVideoFile, VideoFilePayload } from './handlers/video-file' | |||
type CreateJobArgument = | |||
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | | |||
{ type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } | | |||
{ type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } | | |||
{ type: 'video-file', payload: VideoFilePayload } | |||
{ type: 'video-file', payload: VideoFilePayload } | | |||
{ type: 'email', payload: EmailPayload } | |||
const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = { | |||
'activitypub-http-broadcast': processActivityPubHttpBroadcast, | |||
'activitypub-http-unicast': processActivityPubHttpUnicast, | |||
'activitypub-http-fetcher': processActivityPubHttpFetcher, | |||
'video-file': processVideoFile | |||
'video-file': processVideoFile, | |||
'email': processEmail | |||
} | |||
class JobQueue { | |||
@@ -43,6 +46,8 @@ class JobQueue { | |||
} | |||
}) | |||
this.jobQueue.setMaxListeners(15) | |||
this.jobQueue.on('error', err => { | |||
logger.error('Error in job queue.', err) | |||
process.exit(-1) | |||
@@ -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 'express-validator' | |||
import { body, param } from 'express-validator/check' | |||
import { omit } from 'lodash' | |||
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | |||
import { | |||
isAvatarFile, isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, | |||
isAvatarFile, | |||
isUserAutoPlayVideoValid, | |||
isUserDisplayNSFWValid, | |||
isUserPasswordValid, | |||
isUserRoleValid, | |||
isUserUsernameValid, | |||
isUserVideoQuotaValid | |||
} from '../../helpers/custom-validators/users' | |||
import { isVideoExist } from '../../helpers/custom-validators/videos' | |||
import { logger } from '../../helpers/logger' | |||
import { isSignupAllowed } from '../../helpers/utils' | |||
import { CONSTRAINTS_FIELDS } from '../../initializers' | |||
import { Redis } from '../../lib/redis' | |||
import { UserModel } from '../../models/account/user' | |||
import { areValidationErrors } from './utils' | |||
import { omit } from 'lodash' | |||
const usersAddValidator = [ | |||
body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'), | |||
@@ -167,6 +174,49 @@ const ensureUserRegistrationAllowed = [ | |||
} | |||
] | |||
const usersAskResetPasswordValidator = [ | |||
body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'), | |||
async (req: express.Request, res: express.Response, next: express.NextFunction) => { | |||
logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body }) | |||
if (areValidationErrors(req, res)) return | |||
const exists = await checkUserEmailExist(req.body.email, res, false) | |||
if (!exists) { | |||
logger.debug('User with email %s does not exist (asking reset password).', req.body.email) | |||
// Do not leak our emails | |||
return res.status(204).end() | |||
} | |||
return next() | |||
} | |||
] | |||
const usersResetPasswordValidator = [ | |||
param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), | |||
body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'), | |||
body('password').custom(isUserPasswordValid).withMessage('Should have a valid password'), | |||
async (req: express.Request, res: express.Response, next: express.NextFunction) => { | |||
logger.debug('Checking usersResetPassword parameters', { parameters: req.params }) | |||
if (areValidationErrors(req, res)) return | |||
if (!await checkUserIdExist(req.params.id, res)) return | |||
const user = res.locals.user as UserModel | |||
const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id) | |||
if (redisVerificationString !== req.body.verificationString) { | |||
return res | |||
.status(403) | |||
.send({ error: 'Invalid verification string.' }) | |||
.end | |||
} | |||
return next() | |||
} | |||
] | |||
// --------------------------------------------------------------------------- | |||
export { | |||
@@ -178,24 +228,19 @@ export { | |||
usersVideoRatingValidator, | |||
ensureUserRegistrationAllowed, | |||
usersGetValidator, | |||
usersUpdateMyAvatarValidator | |||
usersUpdateMyAvatarValidator, | |||
usersAskResetPasswordValidator, | |||
usersResetPasswordValidator | |||
} | |||
// --------------------------------------------------------------------------- | |||
async function checkUserIdExist (id: number, res: express.Response) { | |||
const user = await UserModel.loadById(id) | |||
if (!user) { | |||
res.status(404) | |||
.send({ error: 'User not found' }) | |||
.end() | |||
return false | |||
} | |||
function checkUserIdExist (id: number, res: express.Response) { | |||
return checkUserExist(() => UserModel.loadById(id), res) | |||
} | |||
res.locals.user = user | |||
return true | |||
function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { | |||
return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) | |||
} | |||
async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { | |||
@@ -210,3 +255,21 @@ async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: | |||
return true | |||
} | |||
async function checkUserExist (finder: () => Bluebird<UserModel>, res: express.Response, abortResponse = true) { | |||
const user = await finder() | |||
if (!user) { | |||
if (abortResponse === true) { | |||
res.status(404) | |||
.send({ error: 'User not found' }) | |||
.end() | |||
} | |||
return false | |||
} | |||
res.locals.user = user | |||
return true | |||
} |
@@ -161,6 +161,16 @@ export class UserModel extends Model<UserModel> { | |||
return UserModel.scope('withVideoChannel').findOne(query) | |||
} | |||
static loadByEmail (email: string) { | |||
const query = { | |||
where: { | |||
} | |||
} | |||
return UserModel.findOne(query) | |||
} | |||
static loadByUsernameOrEmail (username: string, email?: string) { | |||
if (!email) email = username | |||
@@ -3,7 +3,8 @@ export type JobState = 'active' | 'complete' | 'failed' | 'inactive' | 'delayed' | |||
export type JobType = 'activitypub-http-unicast' | | |||
'activitypub-http-broadcast' | | |||
'activitypub-http-fetcher' | | |||
'video-file' | |||
'video-file' | | |||
'email' | |||
export interface Job { | |||
id: number | |||
@@ -19,6 +19,8 @@ | |||
}, | |||
"exclude": [ | |||
"node_modules", | |||
"dist", | |||
"storage", | |||
"client", | |||
"test1", | |||
"test2", | |||
@@ -134,6 +134,12 @@ | |||
version "6.0.41" | |||
resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.41.tgz#578cf53aaec65887bcaf16792f8722932e8ff8ea" | |||
"@types/nodemailer@^4.3.1": | |||
version "4.3.1" | |||
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-4.3.1.tgz#e3985c1b7c7bbbb2a886108b89f1c7ce9a690654" | |||
dependencies: | |||
"@types/node" "*" | |||
"@types/parse-torrent-file@*": | |||
version "4.0.1" | |||
resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.1.tgz#056a6c18f3fac0cd7c6c74540f00496a3225976b" | |||
@@ -152,7 +158,7 @@ | |||
version "1.9.3" | |||
resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.3.tgz#0c864c8b79e43fef6367db895f60fd1edd10e86c" | |||
"@types/redis@*": | |||
"@types/redis@*", "@types/redis@^2.8.5": | |||
version "2.8.5" | |||
resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.5.tgz#c4a31a63e95434202eb84908290528ad8510b149" | |||
dependencies: | |||
@@ -4274,6 +4280,10 @@ node-sass@^4.0.0: | |||
stdout-stream "^1.4.0" | |||
"true-case-path" "^1.0.2" | |||
nodemailer@^4.4.2: | |||
version "4.4.2" | |||
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.4.2.tgz#f215fb88e8a1052f9f93083909e116d2b79fc8de" | |||
nodemon@^1.11.0: | |||
version "1.14.11" | |||
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.14.11.tgz#cc0009dd8d82f126f3aba50ace7e753827a8cebc" | |||
@@ -5149,7 +5159,7 @@ redis-commands@^1.2.0: | |||
version "1.3.1" | |||
resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.3.1.tgz#81d826f45fa9c8b2011f4cd7a0fe597d241d442b" | |||
redis-parser@^2.0.0: | |||
redis-parser@^2.0.0, redis-parser@^2.6.0: | |||
version "2.6.0" | |||
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b" | |||
@@ -5157,6 +5167,14 @@ redis@^0.12.1: | |||
version "0.12.1" | |||
resolved "https://registry.yarnpkg.com/redis/-/redis-0.12.1.tgz#64df76ad0fc8acebaebd2a0645e8a48fac49185e" | |||
redis@^2.8.0: | |||
version "2.8.0" | |||
resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02" | |||
dependencies: | |||
double-ended-queue "^2.1.0-0" | |||
redis-commands "^1.2.0" | |||
redis-parser "^2.6.0" | |||
redis@~2.6.0-2: | |||
version "2.6.5" | |||
resolved "https://registry.yarnpkg.com/redis/-/redis-2.6.5.tgz#87c1eff4a489f94b70871f3d08b6988f23a95687" | |||