Implement contact form on server side

pull/1551/head
Chocobozzz 2019-01-09 15:14:29 +01:00
parent 8d00889b60
commit a4101923e6
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
32 changed files with 541 additions and 49 deletions

View File

@ -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

View File

@ -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

View File

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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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,

View File

@ -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
}

View File

@ -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[] = []

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
// ---------------------------------------------------------------------------

View File

@ -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 *************/

View File

@ -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'),

View File

@ -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
}

View File

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

View File

@ -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()
}
})
})

View File

@ -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'

View File

@ -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

View File

@ -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 ])
})
})

View File

@ -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'

View File

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

View File

@ -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
})
})

View File

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

View File

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

View File

@ -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'

View File

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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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"