Browse Source

Implement contact form on server side

Chocobozzz 5 years ago
parent
commit
a4101923e6

+ 6 - 1
config/default.yaml

@@ -102,7 +102,12 @@ cache:
     size: 500 # Max number of video captions/subtitles you want to cache
 
 admin:
-  email: 'admin@example.com' # Your personal email as administrator
+  # Used to generate the root user at first startup
+  # And to receive emails from the contact form
+  email: 'admin@example.com'
+
+contact_form:
+  enabled: true
 
 signup:
   enabled: false

+ 5 - 0
config/production.yaml.example

@@ -115,8 +115,13 @@ cache:
     size: 500 # Max number of video captions/subtitles you want to cache
 
 admin:
+  # Used to generate the root user at first startup
+  # And to receive emails from the contact form
   email: 'admin@example.com'
 
+contact_form:
+  enabled: true
+
 signup:
   enabled: false
   limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited

+ 3 - 0
config/test.yaml

@@ -21,6 +21,9 @@ smtp:
 log:
   level: 'debug'
 
+contact_form:
+  enabled: true
+
 redundancy:
   videos:
     check_interval: '10 minutes'

+ 28 - 27
server/controllers/api/config.ts

@@ -1,5 +1,5 @@
 import * as express from 'express'
-import { omit } from 'lodash'
+import { omit, snakeCase } from 'lodash'
 import { ServerConfig, UserRight } from '../../../shared'
 import { About } from '../../../shared/models/server/about.model'
 import { CustomConfig } from '../../../shared/models/server/custom-config.model'
@@ -12,6 +12,8 @@ import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '..
 import { remove, writeJSON } from 'fs-extra'
 import { getServerCommit } from '../../helpers/utils'
 import { Emailer } from '../../lib/emailer'
+import { isNumeric } from 'validator'
+import { objectConverter } from '../../helpers/core-utils'
 
 const packageJSON = require('../../../../package.json')
 const configRouter = express.Router()
@@ -65,6 +67,9 @@ async function getConfig (req: express.Request, res: express.Response) {
     email: {
       enabled: Emailer.Instance.isEnabled()
     },
+    contactForm: {
+      enabled: CONFIG.CONTACT_FORM.ENABLED
+    },
     serverVersion: packageJSON.version,
     serverCommit,
     signup: {
@@ -154,34 +159,10 @@ async function deleteCustomConfig (req: express.Request, res: express.Response,
 }
 
 async function updateCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) {
-  const toUpdate: CustomConfig = req.body
   const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig())
 
-  // Force number conversion
-  toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10)
-  toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10)
-  toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10)
-  toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10)
-  toUpdate.user.videoQuotaDaily = parseInt('' + toUpdate.user.videoQuotaDaily, 10)
-  toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
-
-  // camelCase to snake_case key
-  const toUpdateJSON = omit(
-    toUpdate,
-    'user.videoQuota',
-    'instance.defaultClientRoute',
-    'instance.shortDescription',
-    'cache.videoCaptions',
-    'signup.requiresEmailVerification',
-    'transcoding.allowAdditionalExtensions'
-  )
-  toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
-  toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily
-  toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
-  toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
-  toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy
-  toUpdateJSON.signup['requires_email_verification'] = toUpdate.signup.requiresEmailVerification
-  toUpdateJSON.transcoding['allow_additional_extensions'] = toUpdate.transcoding.allowAdditionalExtensions
+  // camelCase to snake_case key + Force number conversion
+  const toUpdateJSON = convertCustomConfigBody(req.body)
 
   await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
 
@@ -243,6 +224,9 @@ function customConfig (): CustomConfig {
     admin: {
       email: CONFIG.ADMIN.EMAIL
     },
+    contactForm: {
+      enabled: CONFIG.CONTACT_FORM.ENABLED
+    },
     user: {
       videoQuota: CONFIG.USER.VIDEO_QUOTA,
       videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
@@ -271,3 +255,20 @@ function customConfig (): CustomConfig {
     }
   }
 }
+
+function convertCustomConfigBody (body: CustomConfig) {
+  function keyConverter (k: string) {
+    // Transcoding resolutions exception
+    if (/^\d{3,4}p$/.exec(k)) return k
+
+    return snakeCase(k)
+  }
+
+  function valueConverter (v: any) {
+    if (isNumeric(v + '')) return parseInt('' + v, 10)
+
+    return v
+  }
+
+  return objectConverter(body, keyConverter, valueConverter)
+}

+ 28 - 0
server/controllers/api/server/contact.ts

@@ -0,0 +1,28 @@
+import * as express from 'express'
+import { asyncMiddleware, contactAdministratorValidator } from '../../../middlewares'
+import { Redis } from '../../../lib/redis'
+import { Emailer } from '../../../lib/emailer'
+import { ContactForm } from '../../../../shared/models/server'
+
+const contactRouter = express.Router()
+
+contactRouter.post('/contact',
+  asyncMiddleware(contactAdministratorValidator),
+  asyncMiddleware(contactAdministrator)
+)
+
+async function contactAdministrator (req: express.Request, res: express.Response) {
+  const data = req.body as ContactForm
+
+  await Emailer.Instance.addContactFormJob(data.fromEmail, data.fromName, data.body)
+
+  await Redis.Instance.setContactFormIp(req.ip)
+
+  return res.status(204).end()
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  contactRouter
+}

+ 2 - 0
server/controllers/api/server/index.ts

@@ -3,6 +3,7 @@ import { serverFollowsRouter } from './follows'
 import { statsRouter } from './stats'
 import { serverRedundancyRouter } from './redundancy'
 import { serverBlocklistRouter } from './server-blocklist'
+import { contactRouter } from './contact'
 
 const serverRouter = express.Router()
 
@@ -10,6 +11,7 @@ serverRouter.use('/', serverFollowsRouter)
 serverRouter.use('/', serverRedundancyRouter)
 serverRouter.use('/', statsRouter)
 serverRouter.use('/', serverBlocklistRouter)
+serverRouter.use('/', contactRouter)
 
 // ---------------------------------------------------------------------------
 

+ 20 - 0
server/helpers/core-utils.ts

@@ -11,6 +11,25 @@ import * as pem from 'pem'
 import { URL } from 'url'
 import { truncate } from 'lodash'
 import { exec } from 'child_process'
+import { isArray } from './custom-validators/misc'
+
+const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => {
+  if (!oldObject || typeof oldObject !== 'object') {
+    return valueConverter(oldObject)
+  }
+
+  if (isArray(oldObject)) {
+    return oldObject.map(e => objectConverter(e, keyConverter, valueConverter))
+  }
+
+  const newObject = {}
+  Object.keys(oldObject).forEach(oldKey => {
+    const newKey = keyConverter(oldKey)
+    newObject[ newKey ] = objectConverter(oldObject[ oldKey ], keyConverter, valueConverter)
+  })
+
+  return newObject
+}
 
 const timeTable = {
   ms:           1,
@@ -235,6 +254,7 @@ export {
   isTestInstance,
   isProdInstance,
 
+  objectConverter,
   root,
   escapeHTML,
   pageToStartAndCount,

+ 11 - 0
server/helpers/custom-validators/servers.ts

@@ -3,6 +3,7 @@ import 'express-validator'
 
 import { isArray, exists } from './misc'
 import { isTestInstance } from '../core-utils'
+import { CONSTRAINTS_FIELDS } from '../../initializers'
 
 function isHostValid (host: string) {
   const isURLOptions = {
@@ -26,9 +27,19 @@ function isEachUniqueHostValid (hosts: string[]) {
     })
 }
 
+function isValidContactBody (value: any) {
+  return exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.CONTACT_FORM.BODY)
+}
+
+function isValidContactFromName (value: any) {
+  return exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.CONTACT_FORM.FROM_NAME)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
+  isValidContactBody,
+  isValidContactFromName,
   isEachUniqueHostValid,
   isHostValid
 }

+ 2 - 4
server/helpers/utils.ts

@@ -7,6 +7,7 @@ import { join } from 'path'
 import { Instance as ParseTorrent } from 'parse-torrent'
 import { remove } from 'fs-extra'
 import * as memoizee from 'memoizee'
+import { isArray } from './custom-validators/misc'
 
 function deleteFileAsync (path: string) {
   remove(path)
@@ -19,10 +20,7 @@ async function generateRandomString (size: number) {
   return raw.toString('hex')
 }
 
-interface FormattableToJSON {
-  toFormattedJSON (args?: any)
-}
-
+interface FormattableToJSON { toFormattedJSON (args?: any) }
 function getFormattedObjects<U, T extends FormattableToJSON> (objects: T[], objectsTotal: number, formattedArg?: any) {
   const formattedObjects: U[] = []
 

+ 11 - 0
server/initializers/constants.ts

@@ -231,6 +231,9 @@ const CONFIG = {
   ADMIN: {
     get EMAIL () { return config.get<string>('admin.email') }
   },
+  CONTACT_FORM: {
+    get ENABLED () { return config.get<boolean>('contact_form.enabled') }
+  },
   SIGNUP: {
     get ENABLED () { return config.get<boolean>('signup.enabled') },
     get LIMIT () { return config.get<number>('signup.limit') },
@@ -394,6 +397,10 @@ let CONSTRAINTS_FIELDS = {
   },
   VIDEO_SHARE: {
     URL: { min: 3, max: 2000 } // Length
+  },
+  CONTACT_FORM: {
+    FROM_NAME: { min: 1, max: 120 }, // Length
+    BODY: { min: 3, max: 5000 } // Length
   }
 }
 
@@ -409,6 +416,8 @@ const RATES_LIMIT = {
 }
 
 let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour
+let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
+
 const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
   MIN: 10,
   AVERAGE: 30,
@@ -685,6 +694,7 @@ if (isTestInstance() === true) {
   REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1
 
   VIDEO_VIEW_LIFETIME = 1000 // 1 second
+  CONTACT_FORM_LIFETIME = 1000 // 1 second
 
   JOB_ATTEMPTS['email'] = 1
 
@@ -756,6 +766,7 @@ export {
   HTTP_SIGNATURE,
   VIDEO_IMPORT_STATES,
   VIDEO_VIEW_LIFETIME,
+  CONTACT_FORM_LIFETIME,
   buildLanguages
 }
 

+ 21 - 2
server/lib/emailer.ts

@@ -354,13 +354,32 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  sendMail (to: string[], subject: string, text: string) {
+  addContactFormJob (fromEmail: string, fromName: string, body: string) {
+    const text = 'Hello dear admin,\n\n' +
+      fromName + ' sent you a message' +
+      '\n\n---------------------------------------\n\n' +
+      body +
+      '\n\n---------------------------------------\n\n' +
+      'Cheers,\n' +
+      'PeerTube.'
+
+    const emailPayload: EmailPayload = {
+      from: fromEmail,
+      to: [ CONFIG.ADMIN.EMAIL ],
+      subject: '[PeerTube] Contact form submitted',
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
+  sendMail (to: string[], subject: string, text: string, from?: string) {
     if (!this.enabled) {
       throw new Error('Cannot send mail because SMTP is not configured.')
     }
 
     return this.transporter.sendMail({
-      from: CONFIG.SMTP.FROM_ADDRESS,
+      from: from || CONFIG.SMTP.FROM_ADDRESS,
       to: to.join(','),
       subject,
       text

+ 2 - 1
server/lib/job-queue/handlers/email.ts

@@ -6,13 +6,14 @@ export type EmailPayload = {
   to: string[]
   subject: string
   text: string
+  from?: string
 }
 
 async function processEmail (job: Bull.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)
+  return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text, payload.from)
 }
 
 // ---------------------------------------------------------------------------

+ 22 - 2
server/lib/redis.ts

@@ -2,7 +2,13 @@ import * as express from 'express'
 import { createClient, RedisClient } from 'redis'
 import { logger } from '../helpers/logger'
 import { generateRandomString } from '../helpers/utils'
-import { CONFIG, USER_PASSWORD_RESET_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers'
+import {
+  CONFIG,
+  CONTACT_FORM_LIFETIME,
+  USER_EMAIL_VERIFY_LIFETIME,
+  USER_PASSWORD_RESET_LIFETIME,
+  VIDEO_VIEW_LIFETIME
+} from '../initializers'
 
 type CachedRoute = {
   body: string,
@@ -76,6 +82,16 @@ class Redis {
     return this.getValue(this.generateVerifyEmailKey(userId))
   }
 
+  /************* Contact form per IP *************/
+
+  async setContactFormIp (ip: string) {
+    return this.setValue(this.generateContactFormKey(ip), '1', CONTACT_FORM_LIFETIME)
+  }
+
+  async isContactFormIpExists (ip: string) {
+    return this.exists(this.generateContactFormKey(ip))
+  }
+
   /************* Views per IP *************/
 
   setIPVideoView (ip: string, videoUUID: string) {
@@ -175,7 +191,11 @@ class Redis {
   }
 
   private generateViewKey (ip: string, videoUUID: string) {
-    return videoUUID + '-' + ip
+    return `views-${videoUUID}-${ip}`
+  }
+
+  private generateContactFormKey (ip: string) {
+    return 'contact-form-' + ip
   }
 
   /************* Redis helpers *************/

+ 17 - 2
server/middlewares/validators/config.ts

@@ -1,29 +1,44 @@
 import * as express from 'express'
 import { body } from 'express-validator/check'
-import { isUserNSFWPolicyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
+import { isUserNSFWPolicyValid, isUserVideoQuotaValid, isUserVideoQuotaDailyValid } from '../../helpers/custom-validators/users'
 import { logger } from '../../helpers/logger'
 import { areValidationErrors } from './utils'
 
 const customConfigUpdateValidator = [
   body('instance.name').exists().withMessage('Should have a valid instance name'),
+  body('instance.shortDescription').exists().withMessage('Should have a valid instance short description'),
   body('instance.description').exists().withMessage('Should have a valid instance description'),
   body('instance.terms').exists().withMessage('Should have a valid instance terms'),
   body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'),
   body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid).withMessage('Should have a valid NSFW policy'),
   body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'),
   body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'),
-  body('cache.previews.size').isInt().withMessage('Should have a valid previews size'),
+
+  body('services.twitter.username').exists().withMessage('Should have a valid twitter username'),
+  body('services.twitter.whitelisted').isBoolean().withMessage('Should have a valid twitter whitelisted boolean'),
+
+  body('cache.previews.size').isInt().withMessage('Should have a valid previews cache size'),
+  body('cache.captions.size').isInt().withMessage('Should have a valid captions cache size'),
+
   body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'),
   body('signup.limit').isInt().withMessage('Should have a valid signup limit'),
+  body('signup.requiresEmailVerification').isBoolean().withMessage('Should have a valid requiresEmailVerification boolean'),
+
   body('admin.email').isEmail().withMessage('Should have a valid administrator email'),
+  body('contactForm.enabled').isBoolean().withMessage('Should have a valid contact form enabled boolean'),
+
   body('user.videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid video quota'),
+  body('user.videoQuotaDaily').custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily video quota'),
+
   body('transcoding.enabled').isBoolean().withMessage('Should have a valid transcoding enabled boolean'),
+  body('transcoding.allowAdditionalExtensions').isBoolean().withMessage('Should have a valid additional extensions boolean'),
   body('transcoding.threads').isInt().withMessage('Should have a valid transcoding threads number'),
   body('transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'),
   body('transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'),
   body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
   body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
   body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
+
   body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
   body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
 

+ 47 - 2
server/middlewares/validators/server.ts

@@ -1,9 +1,13 @@
 import * as express from 'express'
 import { logger } from '../../helpers/logger'
 import { areValidationErrors } from './utils'
-import { isHostValid } from '../../helpers/custom-validators/servers'
+import { isHostValid, isValidContactBody } from '../../helpers/custom-validators/servers'
 import { ServerModel } from '../../models/server/server'
 import { body } from 'express-validator/check'
+import { isUserDisplayNameValid } from '../../helpers/custom-validators/users'
+import { Emailer } from '../../lib/emailer'
+import { Redis } from '../../lib/redis'
+import { CONFIG } from '../../initializers/constants'
 
 const serverGetValidator = [
   body('host').custom(isHostValid).withMessage('Should have a valid host'),
@@ -26,8 +30,49 @@ const serverGetValidator = [
   }
 ]
 
+const contactAdministratorValidator = [
+  body('fromName')
+    .custom(isUserDisplayNameValid).withMessage('Should have a valid name'),
+  body('fromEmail')
+    .isEmail().withMessage('Should have a valid email'),
+  body('body')
+    .custom(isValidContactBody).withMessage('Should have a valid body'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking contactAdministratorValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    if (CONFIG.CONTACT_FORM.ENABLED === false) {
+      return res
+        .status(409)
+        .send({ error: 'Contact form is not enabled on this instance.' })
+        .end()
+    }
+
+    if (Emailer.Instance.isEnabled() === false) {
+      return res
+        .status(409)
+        .send({ error: 'Emailer is not enabled on this instance.' })
+        .end()
+    }
+
+    if (await Redis.Instance.isContactFormIpExists(req.ip)) {
+      logger.info('Refusing a contact form by %s: already sent one recently.', req.ip)
+
+      return res
+        .status(403)
+        .send({ error: 'You already sent a contact form recently.' })
+        .end()
+    }
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
-  serverGetValidator
+  serverGetValidator,
+  contactAdministratorValidator
 }

+ 3 - 0
server/tests/api/check-params/config.ts

@@ -48,6 +48,9 @@ describe('Test config API validators', function () {
     admin: {
       email: 'superadmin1@example.com'
     },
+    contactForm: {
+      enabled: false
+    },
     user: {
       videoQuota: 5242881,
       videoQuotaDaily: 318742

+ 92 - 0
server/tests/api/check-params/contact-form.ts

@@ -0,0 +1,92 @@
+/* tslint:disable:no-unused-expression */
+
+import 'mocha'
+
+import {
+  flushTests,
+  immutableAssign,
+  killallServers,
+  reRunServer,
+  runServer,
+  ServerInfo,
+  setAccessTokensToServers
+} from '../../../../shared/utils'
+import {
+  checkBadCountPagination,
+  checkBadSortPagination,
+  checkBadStartPagination
+} from '../../../../shared/utils/requests/check-api-params'
+import { getAccount } from '../../../../shared/utils/users/accounts'
+import { sendContactForm } from '../../../../shared/utils/server/contact-form'
+import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
+
+describe('Test contact form API validators', function () {
+  let server: ServerInfo
+  const emails: object[] = []
+  const defaultBody = {
+    fromName: 'super name',
+    fromEmail: 'toto@example.com',
+    body: 'Hello, how are you?'
+  }
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(60000)
+
+    await flushTests()
+    await MockSmtpServer.Instance.collectEmails(emails)
+
+    // Email is disabled
+    server = await runServer(1)
+  })
+
+  it('Should not accept a contact form if emails are disabled', async function () {
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 409 }))
+  })
+
+  it('Should not accept a contact form if it is disabled in the configuration', async function () {
+    killallServers([ server ])
+
+    // Contact form is disabled
+    await reRunServer(server, { smtp: { hostname: 'localhost' }, contact_form: { enabled: false } })
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 409 }))
+  })
+
+  it('Should not accept a contact form if from email is invalid', async function () {
+    killallServers([ server ])
+
+    // Email & contact form enabled
+    await reRunServer(server, { smtp: { hostname: 'localhost' } })
+
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: 'badEmail' }))
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: 'badEmail@' }))
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: undefined }))
+  })
+
+  it('Should not accept a contact form if from name is invalid', async function () {
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: 'name'.repeat(100) }))
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: '' }))
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: undefined }))
+  })
+
+  it('Should not accept a contact form if body is invalid', async function () {
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: 'body'.repeat(5000) }))
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: 'a' }))
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: undefined }))
+  })
+
+  it('Should accept a contact form with the correct parameters', async function () {
+    await sendContactForm(immutableAssign(defaultBody, { url: server.url }))
+  })
+
+  after(async function () {
+    MockSmtpServer.Instance.kill()
+    killallServers([ server ])
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})

+ 1 - 1
server/tests/api/check-params/index.ts

@@ -1,7 +1,7 @@
-// Order of the tests we want to execute
 import './accounts'
 import './blocklist'
 import './config'
+import './contact-form'
 import './follows'
 import './jobs'
 import './redundancy'

+ 19 - 0
server/tests/api/server/config.ts

@@ -33,14 +33,20 @@ function checkInitialConfig (data: CustomConfig) {
   expect(data.instance.defaultNSFWPolicy).to.equal('display')
   expect(data.instance.customizations.css).to.be.empty
   expect(data.instance.customizations.javascript).to.be.empty
+
   expect(data.services.twitter.username).to.equal('@Chocobozzz')
   expect(data.services.twitter.whitelisted).to.be.false
+
   expect(data.cache.previews.size).to.equal(1)
   expect(data.cache.captions.size).to.equal(1)
+
   expect(data.signup.enabled).to.be.true
   expect(data.signup.limit).to.equal(4)
   expect(data.signup.requiresEmailVerification).to.be.false
+
   expect(data.admin.email).to.equal('admin1@example.com')
+  expect(data.contactForm.enabled).to.be.true
+
   expect(data.user.videoQuota).to.equal(5242880)
   expect(data.user.videoQuotaDaily).to.equal(-1)
   expect(data.transcoding.enabled).to.be.false
@@ -64,16 +70,23 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.instance.defaultNSFWPolicy).to.equal('blur')
   expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
   expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
+
   expect(data.services.twitter.username).to.equal('@Kuja')
   expect(data.services.twitter.whitelisted).to.be.true
+
   expect(data.cache.previews.size).to.equal(2)
   expect(data.cache.captions.size).to.equal(3)
+
   expect(data.signup.enabled).to.be.false
   expect(data.signup.limit).to.equal(5)
   expect(data.signup.requiresEmailVerification).to.be.true
+
   expect(data.admin.email).to.equal('superadmin1@example.com')
+  expect(data.contactForm.enabled).to.be.false
+
   expect(data.user.videoQuota).to.equal(5242881)
   expect(data.user.videoQuotaDaily).to.equal(318742)
+
   expect(data.transcoding.enabled).to.be.true
   expect(data.transcoding.threads).to.equal(1)
   expect(data.transcoding.allowAdditionalExtensions).to.be.true
@@ -82,6 +95,7 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.transcoding.resolutions['480p']).to.be.true
   expect(data.transcoding.resolutions['720p']).to.be.false
   expect(data.transcoding.resolutions['1080p']).to.be.false
+
   expect(data.import.videos.http.enabled).to.be.false
   expect(data.import.videos.torrent.enabled).to.be.false
 }
@@ -127,6 +141,8 @@ describe('Test config', function () {
     expect(data.video.file.extensions).to.contain('.mp4')
     expect(data.video.file.extensions).to.contain('.webm')
     expect(data.video.file.extensions).to.contain('.ogv')
+
+    expect(data.contactForm.enabled).to.be.true
   })
 
   it('Should get the customized configuration', async function () {
@@ -172,6 +188,9 @@ describe('Test config', function () {
       admin: {
         email: 'superadmin1@example.com'
       },
+      contactForm: {
+        enabled: false
+      },
       user: {
         videoQuota: 5242881,
         videoQuotaDaily: 318742

+ 84 - 0
server/tests/api/server/contact-form.ts

@@ -0,0 +1,84 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, wait } from '../../../../shared/utils'
+import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
+import { waitJobs } from '../../../../shared/utils/server/jobs'
+import { sendContactForm } from '../../../../shared/utils/server/contact-form'
+
+const expect = chai.expect
+
+describe('Test contact form', function () {
+  let server: ServerInfo
+  const emails: object[] = []
+
+  before(async function () {
+    this.timeout(30000)
+
+    await MockSmtpServer.Instance.collectEmails(emails)
+
+    await flushTests()
+
+    const overrideConfig = {
+      smtp: {
+        hostname: 'localhost'
+      }
+    }
+    server = await runServer(1, overrideConfig)
+    await setAccessTokensToServers([ server ])
+  })
+
+  it('Should send a contact form', async function () {
+    await sendContactForm({
+      url: server.url,
+      fromEmail: 'toto@example.com',
+      body: 'my super message',
+      fromName: 'Super toto'
+    })
+
+    await waitJobs(server)
+
+    expect(emails).to.have.lengthOf(1)
+
+    const email = emails[0]
+
+    expect(email['from'][0]['address']).equal('toto@example.com')
+    expect(email['to'][0]['address']).equal('admin1@example.com')
+    expect(email['subject']).contains('Contact form')
+    expect(email['text']).contains('my super message')
+  })
+
+  it('Should not be able to send another contact form because of the anti spam checker', async function () {
+    await sendContactForm({
+      url: server.url,
+      fromEmail: 'toto@example.com',
+      body: 'my super message',
+      fromName: 'Super toto'
+    })
+
+    await sendContactForm({
+      url: server.url,
+      fromEmail: 'toto@example.com',
+      body: 'my super message',
+      fromName: 'Super toto',
+      expectedStatus: 403
+    })
+  })
+
+  it('Should be able to send another contact form after a while', async function () {
+    await wait(1000)
+
+    await sendContactForm({
+      url: server.url,
+      fromEmail: 'toto@example.com',
+      body: 'my super message',
+      fromName: 'Super toto'
+    })
+  })
+
+  after(async function () {
+    MockSmtpServer.Instance.kill()
+    killallServers([ server ])
+  })
+})

+ 5 - 6
server/tests/api/server/handle-down.ts

@@ -8,18 +8,17 @@ import { VideoCommentThreadTree } from '../../../../shared/models/videos/video-c
 
 import {
   completeVideoCheck,
-  getVideo,
-  immutableAssign,
-  reRunServer,
-  unfollow,
-  viewVideo,
   flushAndRunMultipleServers,
+  getVideo,
   getVideosList,
+  immutableAssign,
   killallServers,
+  reRunServer,
   ServerInfo,
   setAccessTokensToServers,
-  uploadVideo,
+  unfollow,
   updateVideo,
+  uploadVideo,
   wait
 } from '../../../../shared/utils'
 import { follow, getFollowersListPaginationAndSort } from '../../../../shared/utils/server/follows'

+ 1 - 0
server/tests/api/server/index.ts

@@ -1,4 +1,5 @@
 import './config'
+import './contact-form'
 import './email'
 import './follow-constraints'
 import './follows'

+ 51 - 1
server/tests/helpers/core-utils.ts

@@ -2,13 +2,16 @@
 
 import * as chai from 'chai'
 import 'mocha'
+import { snakeCase, isNumber } from 'lodash'
 import {
-  parseBytes
+  parseBytes, objectConverter
 } from '../../helpers/core-utils'
+import { isNumeric } from 'validator'
 
 const expect = chai.expect
 
 describe('Parse Bytes', function () {
+
   it('Should pass when given valid value', async function () {
     // just return it
     expect(parseBytes(1024)).to.be.eq(1024)
@@ -45,4 +48,51 @@ describe('Parse Bytes', function () {
   it('Should be invalid when given invalid value', async function () {
     expect(parseBytes('6GB 1GB')).to.be.eq(6)
   })
+
+  it('Should convert an object', async function () {
+    function keyConverter (k: string) {
+      return snakeCase(k)
+    }
+
+    function valueConverter (v: any) {
+      if (isNumeric(v + '')) return parseInt('' + v, 10)
+
+      return v
+    }
+
+    const obj = {
+      mySuperKey: 'hello',
+      mySuper2Key: '45',
+      mySuper3Key: {
+        mySuperSubKey: '15',
+        mySuperSub2Key: 'hello',
+        mySuperSub3Key: [ '1', 'hello', 2 ],
+        mySuperSub4Key: 4
+      },
+      mySuper4Key: 45,
+      toto: {
+        super_key: '15',
+        superKey2: 'hello'
+      },
+      super_key: {
+        superKey4: 15
+      }
+    }
+
+    const res = objectConverter(obj, keyConverter, valueConverter)
+
+    expect(res.my_super_key).to.equal('hello')
+    expect(res.my_super_2_key).to.equal(45)
+    expect(res.my_super_3_key.my_super_sub_key).to.equal(15)
+    expect(res.my_super_3_key.my_super_sub_2_key).to.equal('hello')
+    expect(res.my_super_3_key.my_super_sub_3_key).to.deep.equal([ 1, 'hello', 2 ])
+    expect(res.my_super_3_key.my_super_sub_4_key).to.equal(4)
+    expect(res.toto.super_key).to.equal(15)
+    expect(res.toto.super_key_2).to.equal('hello')
+    expect(res.super_key.super_key_4).to.equal(15)
+
+    // Immutable
+    expect(res.mySuperKey).to.be.undefined
+    expect(obj['my_super_key']).to.be.undefined
+  })
 })

+ 5 - 0
shared/models/server/contact-form.model.ts

@@ -0,0 +1,5 @@
+export interface ContactForm {
+  fromEmail: string
+  fromName: string
+  body: string
+}

+ 4 - 0
shared/models/server/custom-config.model.ts

@@ -41,6 +41,10 @@ export interface CustomConfig {
     email: string
   }
 
+  contactForm: {
+    enabled: boolean
+  }
+
   user: {
     videoQuota: number
     videoQuotaDaily: number

+ 6 - 0
shared/models/server/index.ts

@@ -0,0 +1,6 @@
+export * from './about.model'
+export * from './contact-form.model'
+export * from './custom-config.model'
+export * from './job.model'
+export * from './server-config.model'
+export * from './server-stats.model'

+ 4 - 0
shared/models/server/server-config.model.ts

@@ -19,6 +19,10 @@ export interface ServerConfig {
     enabled: boolean
   }
 
+  contactForm: {
+    enabled: boolean
+  }
+
   signup: {
     allowed: boolean,
     allowedForCurrentIP: boolean,

+ 4 - 0
shared/utils/miscs/email.ts

@@ -15,6 +15,8 @@ class MockSmtpServer {
         return this.emails.push(msg.email)
       }
     })
+
+    process.on('exit', () => this.kill())
   }
 
   collectEmails (emailsCollection: object[]) {
@@ -42,6 +44,8 @@ class MockSmtpServer {
   }
 
   kill () {
+    if (!this.emailChildProcess) return
+
     process.kill(this.emailChildProcess.pid)
 
     this.emailChildProcess = null

+ 3 - 0
shared/utils/server/config.ts

@@ -80,6 +80,9 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
     admin: {
       email: 'superadmin1@example.com'
     },
+    contactForm: {
+      enabled: true
+    },
     user: {
       videoQuota: 5242881,
       videoQuotaDaily: 318742

+ 28 - 0
shared/utils/server/contact-form.ts

@@ -0,0 +1,28 @@
+import * as request from 'supertest'
+import { ContactForm } from '../../models/server'
+
+function sendContactForm (options: {
+  url: string,
+  fromEmail: string,
+  fromName: string,
+  body: string,
+  expectedStatus?: number
+}) {
+  const path = '/api/v1/server/contact'
+
+  const body: ContactForm = {
+    fromEmail: options.fromEmail,
+    fromName: options.fromName,
+    body: options.body
+  }
+  return request(options.url)
+    .post(path)
+    .send(body)
+    .expect(options.expectedStatus || 204)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  sendContactForm
+}

+ 1 - 0
support/docker/production/.env

@@ -18,3 +18,4 @@ PEERTUBE_ADMIN_EMAIL=admin@domain.tld
 # /!\ Prefer to use the PeerTube admin interface to set the following configurations /!\
 #PEERTUBE_SIGNUP_ENABLED=true
 #PEERTUBE_TRANSCODING_ENABLED=true
+#PEERTUBE_CONTACT_FORM_ENABLED=true

+ 5 - 0
support/docker/production/config/custom-environment-variables.yaml

@@ -50,6 +50,11 @@ user:
 admin:
   email: "PEERTUBE_ADMIN_EMAIL"
 
+contact_form:
+  enabled:
+    __name: "PEERTUBE_CONTACT_FORM_ENABLED"
+    __format: "json"
+
 signup:
   enabled:
     __name: "PEERTUBE_SIGNUP_ENABLED"