From a4101923e699e49ceb9ff36e971c75417fafc9f0 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 9 Jan 2019 15:14:29 +0100 Subject: [PATCH] Implement contact form on server side --- config/default.yaml | 7 +- config/production.yaml.example | 5 + config/test.yaml | 3 + server/controllers/api/config.ts | 55 +++++------ server/controllers/api/server/contact.ts | 28 ++++++ server/controllers/api/server/index.ts | 2 + server/helpers/core-utils.ts | 20 ++++ server/helpers/custom-validators/servers.ts | 11 +++ server/helpers/utils.ts | 6 +- server/initializers/constants.ts | 11 +++ server/lib/emailer.ts | 23 ++++- server/lib/job-queue/handlers/email.ts | 3 +- server/lib/redis.ts | 24 ++++- server/middlewares/validators/config.ts | 19 +++- server/middlewares/validators/server.ts | 49 +++++++++- server/tests/api/check-params/config.ts | 3 + server/tests/api/check-params/contact-form.ts | 92 +++++++++++++++++++ server/tests/api/check-params/index.ts | 2 +- server/tests/api/server/config.ts | 19 ++++ server/tests/api/server/contact-form.ts | 84 +++++++++++++++++ server/tests/api/server/handle-down.ts | 11 +-- server/tests/api/server/index.ts | 1 + server/tests/helpers/core-utils.ts | 52 ++++++++++- shared/models/server/contact-form.model.ts | 5 + shared/models/server/custom-config.model.ts | 4 + shared/models/server/index.ts | 6 ++ shared/models/server/server-config.model.ts | 4 + shared/utils/miscs/email.ts | 4 + shared/utils/server/config.ts | 3 + shared/utils/server/contact-form.ts | 28 ++++++ support/docker/production/.env | 1 + .../config/custom-environment-variables.yaml | 5 + 32 files changed, 541 insertions(+), 49 deletions(-) create mode 100644 server/controllers/api/server/contact.ts create mode 100644 server/tests/api/check-params/contact-form.ts create mode 100644 server/tests/api/server/contact-form.ts create mode 100644 shared/models/server/contact-form.model.ts create mode 100644 shared/models/server/index.ts create mode 100644 shared/utils/server/contact-form.ts diff --git a/config/default.yaml b/config/default.yaml index 5fdb41250..e16b8c352 100644 --- a/config/default.yaml +++ b/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 diff --git a/config/production.yaml.example b/config/production.yaml.example index c0dbf64b6..661eac0d5 100644 --- a/config/production.yaml.example +++ b/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 diff --git a/config/test.yaml b/config/test.yaml index 6e9c56e0a..aba5dd73c 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -21,6 +21,9 @@ smtp: log: level: 'debug' +contact_form: + enabled: true + redundancy: videos: check_interval: '10 minutes' diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index c75002aaf..43b20e078 100644 --- a/server/controllers/api/config.ts +++ b/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) +} diff --git a/server/controllers/api/server/contact.ts b/server/controllers/api/server/contact.ts new file mode 100644 index 000000000..b1144c94e --- /dev/null +++ b/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 +} diff --git a/server/controllers/api/server/index.ts b/server/controllers/api/server/index.ts index c08192a8c..814248e5f 100644 --- a/server/controllers/api/server/index.ts +++ b/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) // --------------------------------------------------------------------------- diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts index 84e33c0e9..3fb824e36 100644 --- a/server/helpers/core-utils.ts +++ b/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, diff --git a/server/helpers/custom-validators/servers.ts b/server/helpers/custom-validators/servers.ts index d5021bf38..18c80ec8f 100644 --- a/server/helpers/custom-validators/servers.ts +++ b/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 } diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts index 9b89e3e61..3c3406e38 100644 --- a/server/helpers/utils.ts +++ b/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 (objects: T[], objectsTotal: number, formattedArg?: any) { const formattedObjects: U[] = [] diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 91e74f6c7..4a88aef87 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -231,6 +231,9 @@ const CONFIG = { ADMIN: { get EMAIL () { return config.get('admin.email') } }, + CONTACT_FORM: { + get ENABLED () { return config.get('contact_form.enabled') } + }, SIGNUP: { get ENABLED () { return config.get('signup.enabled') }, get LIMIT () { return config.get('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 } diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts index 3429498e7..9b1c5122f 100644 --- a/server/lib/emailer.ts +++ b/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 diff --git a/server/lib/job-queue/handlers/email.ts b/server/lib/job-queue/handlers/email.ts index 73d98ae54..220d0af32 100644 --- a/server/lib/job-queue/handlers/email.ts +++ b/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) } // --------------------------------------------------------------------------- diff --git a/server/lib/redis.ts b/server/lib/redis.ts index 3e25e6a2c..3628c0583 100644 --- a/server/lib/redis.ts +++ b/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 *************/ diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts index f3f257d57..90108fa82 100644 --- a/server/middlewares/validators/config.ts +++ b/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'), diff --git a/server/middlewares/validators/server.ts b/server/middlewares/validators/server.ts index a491dfeb3..d82e19230 100644 --- a/server/middlewares/validators/server.ts +++ b/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 } diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index b7bf41b58..4038ecbf0 100644 --- a/server/tests/api/check-params/config.ts +++ b/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 diff --git a/server/tests/api/check-params/contact-form.ts b/server/tests/api/check-params/contact-form.ts new file mode 100644 index 000000000..2407ac0b5 --- /dev/null +++ b/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() + } + }) +}) diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 7a181d1d6..77c17036a 100644 --- a/server/tests/api/check-params/index.ts +++ b/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' diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index 4c163d47d..bebfc7398 100644 --- a/server/tests/api/server/config.ts +++ b/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 diff --git a/server/tests/api/server/contact-form.ts b/server/tests/api/server/contact-form.ts new file mode 100644 index 000000000..1a165331b --- /dev/null +++ b/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 ]) + }) +}) diff --git a/server/tests/api/server/handle-down.ts b/server/tests/api/server/handle-down.ts index 8e162b69e..cd7baadad 100644 --- a/server/tests/api/server/handle-down.ts +++ b/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' diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts index 6afcab1f9..1f80cc6cf 100644 --- a/server/tests/api/server/index.ts +++ b/server/tests/api/server/index.ts @@ -1,4 +1,5 @@ import './config' +import './contact-form' import './email' import './follow-constraints' import './follows' diff --git a/server/tests/helpers/core-utils.ts b/server/tests/helpers/core-utils.ts index a6d829a9f..e604cf7e3 100644 --- a/server/tests/helpers/core-utils.ts +++ b/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 + }) }) diff --git a/shared/models/server/contact-form.model.ts b/shared/models/server/contact-form.model.ts new file mode 100644 index 000000000..0696be8b4 --- /dev/null +++ b/shared/models/server/contact-form.model.ts @@ -0,0 +1,5 @@ +export interface ContactForm { + fromEmail: string + fromName: string + body: string +} diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index 028aafa1a..7a3eaa33f 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -41,6 +41,10 @@ export interface CustomConfig { email: string } + contactForm: { + enabled: boolean + } + user: { videoQuota: number videoQuotaDaily: number diff --git a/shared/models/server/index.ts b/shared/models/server/index.ts new file mode 100644 index 000000000..c42f6f67f --- /dev/null +++ b/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' diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index a6d28e05e..7031009d9 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -19,6 +19,10 @@ export interface ServerConfig { enabled: boolean } + contactForm: { + enabled: boolean + } + signup: { allowed: boolean, allowedForCurrentIP: boolean, diff --git a/shared/utils/miscs/email.ts b/shared/utils/miscs/email.ts index 6fac7621f..f9f1bd95b 100644 --- a/shared/utils/miscs/email.ts +++ b/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 diff --git a/shared/utils/server/config.ts b/shared/utils/server/config.ts index ff5288c82..0c5512bab 100644 --- a/shared/utils/server/config.ts +++ b/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 diff --git a/shared/utils/server/contact-form.ts b/shared/utils/server/contact-form.ts new file mode 100644 index 000000000..80394cf99 --- /dev/null +++ b/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 +} diff --git a/support/docker/production/.env b/support/docker/production/.env index f27def3b4..802d6b2ca 100644 --- a/support/docker/production/.env +++ b/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 diff --git a/support/docker/production/config/custom-environment-variables.yaml b/support/docker/production/config/custom-environment-variables.yaml index 550f1ad80..8604939aa 100644 --- a/support/docker/production/config/custom-environment-variables.yaml +++ b/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" -- 2.41.0