diff --git a/.gitignore b/.gitignore index 96e888fd4..9dc03a6a1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ /test6/ /storage/ /config/production.yaml -/config/local*.json +/config/local* /ffmpeg/ /*.sublime-project /*.sublime-workspace diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index e69edbc4b..ddcaf3f48 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -1,19 +1,20 @@ 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 { LoginModule } from './login' -import { SignupModule } from './signup' -import { SharedModule } from './shared' -import { VideosModule } from './videos' -import { MenuComponent } from './menu' 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 { return new MetaStaticLoader({ @@ -46,6 +47,7 @@ export function metaFactory (): MetaLoader { AccountModule, CoreModule, LoginModule, + ResetPasswordModule, SignupModule, SharedModule, VideosModule, diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html index b61b66ec7..660a08280 100644 --- a/client/src/app/login/login.component.html +++ b/client/src/app/login/login.component.html @@ -19,10 +19,13 @@
- +
+ +
I forgot my password
+
{{ formErrors.password }}
@@ -31,3 +34,36 @@
+ + diff --git a/client/src/app/login/login.component.scss b/client/src/app/login/login.component.scss index efec6b706..2cf6991ce 100644 --- a/client/src/app/login/login.component.scss +++ b/client/src/app/login/login.component.scss @@ -10,3 +10,13 @@ input[type=submit] { @include peertube-button; @include orange-button; } + +input[type=password] { + display: inline-block; + margin-right: 5px; +} + +.forgot-password-button { + display: inline-block; + cursor: pointer; +} diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts index e7c9c7226..22e8c77dd 100644 --- a/client/src/app/login/login.component.ts +++ b/client/src/app/login/login.component.ts @@ -1,7 +1,9 @@ -import { Component, OnInit } from '@angular/core' +import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' import { FormBuilder, FormGroup, Validators } from '@angular/forms' import { Router } from '@angular/router' - +import { UserService } from '@app/shared' +import { NotificationsService } from 'angular2-notifications' +import { ModalDirective } from 'ngx-bootstrap/modal' import { AuthService } from '../core' import { FormReactive } from '../shared' @@ -12,6 +14,9 @@ import { FormReactive } from '../shared' }) export class LoginComponent extends FormReactive implements OnInit { + @ViewChild('forgotPasswordModal') forgotPasswordModal: ModalDirective + @ViewChild('forgotPasswordEmailInput') forgotPasswordEmailInput: ElementRef + error: string = null form: FormGroup @@ -27,9 +32,12 @@ export class LoginComponent extends FormReactive implements OnInit { 'required': 'Password is required.' } } + forgotPasswordEmail = '' constructor ( private authService: AuthService, + private userService: UserService, + private notificationsService: NotificationsService, private formBuilder: FormBuilder, private router: Router ) { @@ -60,4 +68,29 @@ export class LoginComponent extends FormReactive implements OnInit { err => this.error = err.message ) } + + askResetPassword () { + this.userService.askResetPassword(this.forgotPasswordEmail) + .subscribe( + res => { + const message = `An email with the reset password instructions will be sent to ${this.forgotPasswordEmail}.` + this.notificationsService.success('Success', message) + this.hideForgotPasswordModal() + }, + + err => this.notificationsService.error('Error', err.message) + ) + } + + onForgotPasswordModalShown () { + this.forgotPasswordEmailInput.nativeElement.focus() + } + + openForgotPasswordModal () { + this.forgotPasswordModal.show() + } + + hideForgotPasswordModal () { + this.forgotPasswordModal.hide() + } } diff --git a/client/src/app/reset-password/index.ts b/client/src/app/reset-password/index.ts new file mode 100644 index 000000000..438dc576a --- /dev/null +++ b/client/src/app/reset-password/index.ts @@ -0,0 +1,3 @@ +export * from './reset-password-routing.module' +export * from './reset-password.component' +export * from './reset-password.module' diff --git a/client/src/app/reset-password/reset-password-routing.module.ts b/client/src/app/reset-password/reset-password-routing.module.ts new file mode 100644 index 000000000..b41069568 --- /dev/null +++ b/client/src/app/reset-password/reset-password-routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' + +import { MetaGuard } from '@ngx-meta/core' + +import { ResetPasswordComponent } from './reset-password.component' + +const resetPasswordRoutes: Routes = [ + { + path: 'reset-password', + component: ResetPasswordComponent, + canActivate: [ MetaGuard ], + data: { + meta: { + title: 'Reset password' + } + } + } +] + +@NgModule({ + imports: [ RouterModule.forChild(resetPasswordRoutes) ], + exports: [ RouterModule ] +}) +export class ResetPasswordRoutingModule {} diff --git a/client/src/app/reset-password/reset-password.component.html b/client/src/app/reset-password/reset-password.component.html new file mode 100644 index 000000000..d142c523f --- /dev/null +++ b/client/src/app/reset-password/reset-password.component.html @@ -0,0 +1,33 @@ +
+
+ Reset my password +
+ +
{{ error }}
+ +
+
+ + +
+ {{ formErrors.password }} +
+
+ +
+ + +
+ {{ formErrors['password-confirm'] }} +
+
+ + +
+
diff --git a/client/src/app/reset-password/reset-password.component.scss b/client/src/app/reset-password/reset-password.component.scss new file mode 100644 index 000000000..efec6b706 --- /dev/null +++ b/client/src/app/reset-password/reset-password.component.scss @@ -0,0 +1,12 @@ +@import '_variables'; +@import '_mixins'; + +input:not([type=submit]) { + @include peertube-input-text(340px); + display: block; +} + +input[type=submit] { + @include peertube-button; + @include orange-button; +} diff --git a/client/src/app/reset-password/reset-password.component.ts b/client/src/app/reset-password/reset-password.component.ts new file mode 100644 index 000000000..408374779 --- /dev/null +++ b/client/src/app/reset-password/reset-password.component.ts @@ -0,0 +1,79 @@ +import { Component, OnInit } from '@angular/core' +import { FormBuilder, FormGroup, Validators } from '@angular/forms' +import { ActivatedRoute, Router } from '@angular/router' +import { USER_PASSWORD, UserService } from '@app/shared' +import { NotificationsService } from 'angular2-notifications' +import { AuthService } from '../core' +import { FormReactive } from '../shared' + +@Component({ + selector: 'my-login', + templateUrl: './reset-password.component.html', + styleUrls: [ './reset-password.component.scss' ] +}) + +export class ResetPasswordComponent extends FormReactive implements OnInit { + form: FormGroup + formErrors = { + 'password': '', + 'password-confirm': '' + } + validationMessages = { + 'password': USER_PASSWORD.MESSAGES, + 'password-confirm': { + 'required': 'Confirmation of the password is required.' + } + } + + private userId: number + private verificationString: string + + constructor ( + private authService: AuthService, + private userService: UserService, + private notificationsService: NotificationsService, + private formBuilder: FormBuilder, + private router: Router, + private route: ActivatedRoute + ) { + super() + } + + buildForm () { + this.form = this.formBuilder.group({ + password: [ '', USER_PASSWORD.VALIDATORS ], + 'password-confirm': [ '', Validators.required ] + }) + + this.form.valueChanges.subscribe(data => this.onValueChanged(data)) + } + + ngOnInit () { + this.buildForm() + + this.userId = this.route.snapshot.queryParams['userId'] + this.verificationString = this.route.snapshot.queryParams['verificationString'] + + if (!this.userId || !this.verificationString) { + this.notificationsService.error('Error', 'Unable to find user id or verification string.') + this.router.navigate([ '/' ]) + } + } + + resetPassword () { + this.userService.resetPassword(this.userId, this.verificationString, this.form.value.password) + .subscribe( + () => { + this.notificationsService.success('Success', 'Your password has been successfully reset!') + this.router.navigate([ '/login' ]) + }, + + err => this.notificationsService.error('Error', err.message) + ) + } + + isConfirmedPasswordValid () { + const values = this.form.value + return values.password === values['password-confirm'] + } +} diff --git a/client/src/app/reset-password/reset-password.module.ts b/client/src/app/reset-password/reset-password.module.ts new file mode 100644 index 000000000..c2711981a --- /dev/null +++ b/client/src/app/reset-password/reset-password.module.ts @@ -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 { } diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index 742fb0728..da7b583f4 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts @@ -5,7 +5,6 @@ import 'rxjs/add/operator/map' 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)) + } } diff --git a/client/src/polyfills.ts b/client/src/polyfills.ts index c2d7f1d6e..fbe104aa0 100644 --- a/client/src/polyfills.ts +++ b/client/src/polyfills.ts @@ -19,26 +19,30 @@ */ /** 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. **/ diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index 253bb1b3c..33d7ce0a5 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss @@ -19,7 +19,7 @@ $FontPathSourceSansPro: '../../node_modules/npm-font-source-sans-pro/fonts'; } body { - font-family: 'Source Sans Pro'; + font-family: 'Source Sans Pro', sans-serif; font-weight: $font-regular; color: #000; } diff --git a/config/default.yaml b/config/default.yaml index fd04b5ce6..691c9e00b 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -19,6 +19,15 @@ redis: 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 diff --git a/config/production.yaml.example b/config/production.yaml.example index a2b332983..04354b75d 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -20,6 +20,15 @@ redis: 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/' diff --git a/package.json b/package.json index 9a455d212..1b06bcba1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/help.sh b/scripts/help.sh index 51f55547e..a822d5d2e 100755 --- a/scripts/help.sh +++ b/scripts/help.sh @@ -3,12 +3,11 @@ 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" diff --git a/server.ts b/server.ts index d0b351c62..44e93d1a6 100644 --- a/server.ts +++ b/server.ts @@ -53,9 +53,11 @@ migrate() // ----------- PeerTube modules ----------- import { installApplication } from './server/initializers' +import { Emailer } from './server/lib/emailer' import { JobQueue } from './server/lib/job-queue' import { VideosPreviewCache } from './server/lib/cache' import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers' +import { Redis } from './server/lib/redis' import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler' import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler' @@ -169,10 +171,20 @@ function onDatabaseInitDone () { .then(() => { // ----------- Make the server listening ----------- server.listen(port, () => { + // Emailer initialization and then job queue initialization + Emailer.Instance.init() + Emailer.Instance.checkConnectionOrDie() + .then(() => JobQueue.Instance.init()) + + // Caches initializations VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE) + + // Enable Schedulers BadActorFollowScheduler.Instance.enable() RemoveOldJobsScheduler.Instance.enable() - JobQueue.Instance.init() + + // Redis initialization + Redis.Instance.init() logger.info('Server listening on port %d', port) logger.info('Web server: %s', CONFIG.WEBSERVER.URL) diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts index 79bb2665d..05639fbec 100644 --- a/server/controllers/api/users.ts +++ b/server/controllers/api/users.ts @@ -6,17 +6,23 @@ import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRat 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() } diff --git a/server/helpers/logger.ts b/server/helpers/logger.ts index 10e8cabc8..c353f55da 100644 --- a/server/helpers/logger.ts +++ b/server/helpers/logger.ts @@ -26,6 +26,7 @@ const loggerFormat = winston.format.printf((info) => { 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}` }) diff --git a/server/initializers/checker.ts b/server/initializers/checker.ts index 35fab244c..d550fd23f 100644 --- a/server/initializers/checker.ts +++ b/server/initializers/checker.ts @@ -22,7 +22,8 @@ function checkMissedConfig () { '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[] = [] diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 03828f54f..e7b1656e2 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -65,13 +65,15 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = { '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('redis.hostname'), - PORT: config.get('redis.port'), + PORT: config.get('redis.port'), AUTH: config.get('redis.auth') }, + SMTP: { + HOSTNAME: config.get('smtp.hostname'), + PORT: config.get('smtp.port'), + USERNAME: config.get('smtp.username'), + PASSWORD: config.get('smtp.password'), + TLS: config.get('smtp.tls'), + CA_FILE: config.get('smtp.ca_file'), + FROM_ADDRESS: config.get('smtp.from_address') + }, STORAGE: { AVATARS_DIR: buildPath(config.get('storage.avatars')), LOG_DIR: buildPath(config.get('storage.logs')), @@ -311,6 +322,8 @@ const PRIVATE_RSA_KEY_SIZE = 2048 // Password encryption const BCRYPT_SALT_SIZE = 10 +const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes + // --------------------------------------------------------------------------- // Express static paths (router) @@ -408,6 +421,7 @@ export { VIDEO_LICENCES, VIDEO_RATE_TYPES, VIDEO_MIMETYPE_EXT, + USER_PASSWORD_RESET_LIFETIME, AVATAR_MIMETYPE_EXT, SCHEDULER_INTERVAL, JOB_COMPLETED_LIFETIME diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts new file mode 100644 index 000000000..f5b68640e --- /dev/null +++ b/server/lib/emailer.ts @@ -0,0 +1,106 @@ +import { createTransport, Transporter } from 'nodemailer' +import { isTestInstance } from '../helpers/core-utils' +import { logger } from '../helpers/logger' +import { CONFIG } from '../initializers' +import { JobQueue } from './job-queue' +import { EmailPayload } from './job-queue/handlers/email' +import { readFileSync } from 'fs' + +class Emailer { + + private static instance: Emailer + private initialized = false + private transporter: Transporter + + private constructor () {} + + init () { + // Already initialized + if (this.initialized === true) return + this.initialized = true + + if (CONFIG.SMTP.HOSTNAME && CONFIG.SMTP.PORT) { + logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT) + + let tls + if (CONFIG.SMTP.CA_FILE) { + tls = { + ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ] + } + } + + this.transporter = createTransport({ + host: CONFIG.SMTP.HOSTNAME, + port: CONFIG.SMTP.PORT, + secure: CONFIG.SMTP.TLS, + tls, + auth: { + user: CONFIG.SMTP.USERNAME, + pass: CONFIG.SMTP.PASSWORD + } + }) + } else { + if (!isTestInstance()) { + logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!') + } + } + } + + async checkConnectionOrDie () { + if (!this.transporter) return + + try { + const success = await this.transporter.verify() + if (success !== true) this.dieOnConnectionFailure() + + logger.info('Successfully connected to SMTP server.') + } catch (err) { + this.dieOnConnectionFailure(err) + } + } + + addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { + const text = `Hi dear user,\n\n` + + `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + + `Please follow this link to reset it: ${resetPasswordUrl}.\n\n` + + `If you are not the person who initiated this request, please ignore this email.\n\n` + + `Cheers,\n` + + `PeerTube.` + + const emailPayload: EmailPayload = { + to: [ to ], + subject: 'Reset your PeerTube password', + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + sendMail (to: string[], subject: string, text: string) { + if (!this.transporter) { + throw new Error('Cannot send mail because SMTP is not configured.') + } + + return this.transporter.sendMail({ + from: CONFIG.SMTP.FROM_ADDRESS, + to: to.join(','), + subject, + text + }) + } + + private dieOnConnectionFailure (err?: Error) { + logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, err) + process.exit(-1) + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + Emailer +} diff --git a/server/lib/job-queue/handlers/email.ts b/server/lib/job-queue/handlers/email.ts new file mode 100644 index 000000000..9d7686116 --- /dev/null +++ b/server/lib/job-queue/handlers/email.ts @@ -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 +} diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 7a2b6c78d..3f176f896 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -5,19 +5,22 @@ import { CONFIG, JOB_ATTEMPTS, JOB_COMPLETED_LIFETIME, JOB_CONCURRENCY } from '. 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} = { 'activitypub-http-broadcast': processActivityPubHttpBroadcast, 'activitypub-http-unicast': processActivityPubHttpUnicast, 'activitypub-http-fetcher': processActivityPubHttpFetcher, - 'video-file': processVideoFile + 'video-file': processVideoFile, + 'email': processEmail } class JobQueue { @@ -43,6 +46,8 @@ class JobQueue { } }) + this.jobQueue.setMaxListeners(15) + this.jobQueue.on('error', err => { logger.error('Error in job queue.', err) process.exit(-1) diff --git a/server/lib/redis.ts b/server/lib/redis.ts new file mode 100644 index 000000000..4240cc162 --- /dev/null +++ b/server/lib/redis.ts @@ -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((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((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 +} diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index b6591c9e1..5f44c3b99 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -1,18 +1,25 @@ +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) +function checkUserIdExist (id: number, res: express.Response) { + return checkUserExist(() => UserModel.loadById(id), res) +} - if (!user) { - res.status(404) - .send({ error: 'User not found' }) - .end() - - return false - } - - 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, 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 +} diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 809e821bd..026a8c9a0 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -161,6 +161,16 @@ export class UserModel extends Model { return UserModel.scope('withVideoChannel').findOne(query) } + static loadByEmail (email: string) { + const query = { + where: { + email + } + } + + return UserModel.findOne(query) + } + static loadByUsernameOrEmail (username: string, email?: string) { if (!email) email = username diff --git a/shared/models/job.model.ts b/shared/models/job.model.ts index 1a25600f3..5ebb75a5c 100644 --- a/shared/models/job.model.ts +++ b/shared/models/job.model.ts @@ -3,7 +3,8 @@ export type JobState = 'active' | 'complete' | 'failed' | 'inactive' | 'delayed' export type JobType = 'activitypub-http-unicast' | 'activitypub-http-broadcast' | 'activitypub-http-fetcher' | - 'video-file' + 'video-file' | + 'email' export interface Job { id: number diff --git a/tsconfig.json b/tsconfig.json index 1c1472aae..70d2c51c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,8 @@ }, "exclude": [ "node_modules", + "dist", + "storage", "client", "test1", "test2", diff --git a/yarn.lock b/yarn.lock index a3f6fce8a..b69b33ed7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"