/test6/
/storage/
/config/production.yaml
-/config/local*.json
+/config/local*
/ffmpeg/
/*.sublime-project
/*.sublime-workspace
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({
AccountModule,
CoreModule,
LoginModule,
+ ResetPasswordModule,
SignupModule,
SharedModule,
VideosModule,
<div class="form-group">
<label for="password">Password</label>
- <input
- type="password" name="password" id="password" placeholder="Password" required
- formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
- >
+ <div>
+ <input
+ type="password" name="password" id="password" placeholder="Password" required
+ formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
+ >
+ <div class="forgot-password-button" (click)="openForgotPasswordModal()">I forgot my password</div>
+ </div>
<div *ngIf="formErrors.password" class="form-error">
{{ formErrors.password }}
</div>
<input type="submit" value="Login" [disabled]="!form.valid">
</form>
</div>
+
+<div bsModal #forgotPasswordModal="bs-modal" (onShown)="onForgotPasswordModalShown()" class="modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+
+ <div class="modal-header">
+ <span class="close" aria-hidden="true" (click)="hideForgotPasswordModal()"></span>
+ <h4 class="modal-title">Forgot your password</h4>
+ </div>
+
+ <div class="modal-body">
+ <div class="form-group">
+ <label for="forgot-password-email">Email</label>
+ <input
+ type="email" id="forgot-password-email" placeholder="Email address" required
+ [(ngModel)]="forgotPasswordEmail" #forgotPasswordEmailInput
+ >
+ </div>
+
+ <div class="form-group inputs">
+ <span class="action-button action-button-cancel" (click)="hideForgotPasswordModal()">
+ Cancel
+ </span>
+
+ <input
+ type="submit" value="Send me an email to reset my password" class="action-button-submit"
+ (click)="askResetPassword()" [disabled]="!forgotPasswordEmailInput.validity.valid"
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
@include peertube-button;
@include orange-button;
}
+
+input[type=password] {
+ display: inline-block;
+ margin-right: 5px;
+}
+
+.forgot-password-button {
+ display: inline-block;
+ cursor: pointer;
+}
-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'
})
export class LoginComponent extends FormReactive implements OnInit {
+ @ViewChild('forgotPasswordModal') forgotPasswordModal: ModalDirective
+ @ViewChild('forgotPasswordEmailInput') forgotPasswordEmailInput: ElementRef
+
error: string = null
form: FormGroup
'required': 'Password is required.'
}
}
+ forgotPasswordEmail = ''
constructor (
private authService: AuthService,
+ private userService: UserService,
+ private notificationsService: NotificationsService,
private formBuilder: FormBuilder,
private router: Router
) {
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()
+ }
}
--- /dev/null
+export * from './reset-password-routing.module'
+export * from './reset-password.component'
+export * from './reset-password.module'
--- /dev/null
+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 {}
--- /dev/null
+<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>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+input:not([type=submit]) {
+ @include peertube-input-text(340px);
+ display: block;
+}
+
+input[type=submit] {
+ @include peertube-button;
+ @include orange-button;
+}
--- /dev/null
+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']
+ }
+}
--- /dev/null
+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 { }
import { UserCreate, UserUpdateMe } from '../../../../../shared'
import { environment } from '../../../environments/environment'
import { RestExtractor } from '../rest'
-import { User } from './user.model'
@Injectable()
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))
+ }
}
*/
/** 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. **/
}
body {
- font-family: 'Source Sans Pro';
+ font-family: 'Source Sans Pro', sans-serif;
font-weight: $font-regular;
color: #000;
}
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/'
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
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/'
"mkdirp": "^0.5.1",
"morgan": "^1.5.3",
"multer": "^1.1.0",
+ "nodemailer": "^4.4.2",
"parse-torrent": "^5.8.0",
"password-generator": "^2.0.2",
"pem": "^1.12.3",
"pg": "^6.4.2",
"pg-hstore": "^2.3.2",
+ "redis": "^2.8.0",
"reflect-metadata": "^0.1.10",
"request": "^2.81.0",
"rimraf": "^2.5.4",
"@types/morgan": "^1.7.32",
"@types/multer": "^1.3.3",
"@types/node": "^9.3.0",
+ "@types/nodemailer": "^4.3.1",
"@types/pem": "^1.9.3",
+ "@types/redis": "^2.8.5",
"@types/request": "^2.0.3",
"@types/sequelize": "^4.0.55",
"@types/sharp": "^0.17.6",
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"
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"
// ----------- 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'
.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)
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'
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
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()
}
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}`
})
'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[] = []
'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
},
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')),
// Password encryption
const BCRYPT_SALT_SIZE = 10
+const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes
+
// ---------------------------------------------------------------------------
// Express static paths (router)
VIDEO_LICENCES,
VIDEO_RATE_TYPES,
VIDEO_MIMETYPE_EXT,
+ USER_PASSWORD_RESET_LIFETIME,
AVATAR_MIMETYPE_EXT,
SCHEDULER_INTERVAL,
JOB_COMPLETED_LIFETIME
--- /dev/null
+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
+}
--- /dev/null
+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
+}
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 {
}
})
+ this.jobQueue.setMaxListeners(15)
+
this.jobQueue.on('error', err => {
logger.error('Error in job queue.', err)
process.exit(-1)
--- /dev/null
+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
+}
+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)'),
}
]
+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 {
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) {
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
+}
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
export type JobType = 'activitypub-http-unicast' |
'activitypub-http-broadcast' |
'activitypub-http-fetcher' |
- 'video-file'
+ 'video-file' |
+ 'email'
export interface Job {
id: number
},
"exclude": [
"node_modules",
+ "dist",
+ "storage",
"client",
"test1",
"test2",
version "6.0.41"
resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.41.tgz#578cf53aaec65887bcaf16792f8722932e8ff8ea"
+"@types/nodemailer@^4.3.1":
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-4.3.1.tgz#e3985c1b7c7bbbb2a886108b89f1c7ce9a690654"
+ dependencies:
+ "@types/node" "*"
+
"@types/parse-torrent-file@*":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.1.tgz#056a6c18f3fac0cd7c6c74540f00496a3225976b"
version "1.9.3"
resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.3.tgz#0c864c8b79e43fef6367db895f60fd1edd10e86c"
-"@types/redis@*":
+"@types/redis@*", "@types/redis@^2.8.5":
version "2.8.5"
resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.5.tgz#c4a31a63e95434202eb84908290528ad8510b149"
dependencies:
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"
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"
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"