diff options
author | Chocobozzz <me@florianbigard.com> | 2019-01-09 15:14:29 +0100 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2019-01-10 11:32:37 +0100 |
commit | a4101923e699e49ceb9ff36e971c75417fafc9f0 (patch) | |
tree | c098a87ac5a85e1bc7454facbb59ecbd6c7dac82 | |
parent | 8d00889b6038c38d9c86cbeca88a9f3c23962c48 (diff) | |
download | PeerTube-a4101923e699e49ceb9ff36e971c75417fafc9f0.tar.gz PeerTube-a4101923e699e49ceb9ff36e971c75417fafc9f0.tar.zst PeerTube-a4101923e699e49ceb9ff36e971c75417fafc9f0.zip |
Implement contact form on server side
32 files changed, 541 insertions, 49 deletions
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: | |||
102 | size: 500 # Max number of video captions/subtitles you want to cache | 102 | size: 500 # Max number of video captions/subtitles you want to cache |
103 | 103 | ||
104 | admin: | 104 | admin: |
105 | email: 'admin@example.com' # Your personal email as administrator | 105 | # Used to generate the root user at first startup |
106 | # And to receive emails from the contact form | ||
107 | email: 'admin@example.com' | ||
108 | |||
109 | contact_form: | ||
110 | enabled: true | ||
106 | 111 | ||
107 | signup: | 112 | signup: |
108 | enabled: false | 113 | 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: | |||
115 | size: 500 # Max number of video captions/subtitles you want to cache | 115 | size: 500 # Max number of video captions/subtitles you want to cache |
116 | 116 | ||
117 | admin: | 117 | admin: |
118 | # Used to generate the root user at first startup | ||
119 | # And to receive emails from the contact form | ||
118 | email: 'admin@example.com' | 120 | email: 'admin@example.com' |
119 | 121 | ||
122 | contact_form: | ||
123 | enabled: true | ||
124 | |||
120 | signup: | 125 | signup: |
121 | enabled: false | 126 | enabled: false |
122 | limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited | 127 | 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: | |||
21 | log: | 21 | log: |
22 | level: 'debug' | 22 | level: 'debug' |
23 | 23 | ||
24 | contact_form: | ||
25 | enabled: true | ||
26 | |||
24 | redundancy: | 27 | redundancy: |
25 | videos: | 28 | videos: |
26 | check_interval: '10 minutes' | 29 | 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { omit } from 'lodash' | 2 | import { omit, snakeCase } from 'lodash' |
3 | import { ServerConfig, UserRight } from '../../../shared' | 3 | import { ServerConfig, UserRight } from '../../../shared' |
4 | import { About } from '../../../shared/models/server/about.model' | 4 | import { About } from '../../../shared/models/server/about.model' |
5 | import { CustomConfig } from '../../../shared/models/server/custom-config.model' | 5 | import { CustomConfig } from '../../../shared/models/server/custom-config.model' |
@@ -12,6 +12,8 @@ import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '.. | |||
12 | import { remove, writeJSON } from 'fs-extra' | 12 | import { remove, writeJSON } from 'fs-extra' |
13 | import { getServerCommit } from '../../helpers/utils' | 13 | import { getServerCommit } from '../../helpers/utils' |
14 | import { Emailer } from '../../lib/emailer' | 14 | import { Emailer } from '../../lib/emailer' |
15 | import { isNumeric } from 'validator' | ||
16 | import { objectConverter } from '../../helpers/core-utils' | ||
15 | 17 | ||
16 | const packageJSON = require('../../../../package.json') | 18 | const packageJSON = require('../../../../package.json') |
17 | const configRouter = express.Router() | 19 | const configRouter = express.Router() |
@@ -65,6 +67,9 @@ async function getConfig (req: express.Request, res: express.Response) { | |||
65 | email: { | 67 | email: { |
66 | enabled: Emailer.Instance.isEnabled() | 68 | enabled: Emailer.Instance.isEnabled() |
67 | }, | 69 | }, |
70 | contactForm: { | ||
71 | enabled: CONFIG.CONTACT_FORM.ENABLED | ||
72 | }, | ||
68 | serverVersion: packageJSON.version, | 73 | serverVersion: packageJSON.version, |
69 | serverCommit, | 74 | serverCommit, |
70 | signup: { | 75 | signup: { |
@@ -154,34 +159,10 @@ async function deleteCustomConfig (req: express.Request, res: express.Response, | |||
154 | } | 159 | } |
155 | 160 | ||
156 | async function updateCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) { | 161 | async function updateCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) { |
157 | const toUpdate: CustomConfig = req.body | ||
158 | const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig()) | 162 | const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig()) |
159 | 163 | ||
160 | // Force number conversion | 164 | // camelCase to snake_case key + Force number conversion |
161 | toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10) | 165 | const toUpdateJSON = convertCustomConfigBody(req.body) |
162 | toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10) | ||
163 | toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10) | ||
164 | toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10) | ||
165 | toUpdate.user.videoQuotaDaily = parseInt('' + toUpdate.user.videoQuotaDaily, 10) | ||
166 | toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10) | ||
167 | |||
168 | // camelCase to snake_case key | ||
169 | const toUpdateJSON = omit( | ||
170 | toUpdate, | ||
171 | 'user.videoQuota', | ||
172 | 'instance.defaultClientRoute', | ||
173 | 'instance.shortDescription', | ||
174 | 'cache.videoCaptions', | ||
175 | 'signup.requiresEmailVerification', | ||
176 | 'transcoding.allowAdditionalExtensions' | ||
177 | ) | ||
178 | toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota | ||
179 | toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily | ||
180 | toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute | ||
181 | toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription | ||
182 | toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy | ||
183 | toUpdateJSON.signup['requires_email_verification'] = toUpdate.signup.requiresEmailVerification | ||
184 | toUpdateJSON.transcoding['allow_additional_extensions'] = toUpdate.transcoding.allowAdditionalExtensions | ||
185 | 166 | ||
186 | await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 }) | 167 | await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 }) |
187 | 168 | ||
@@ -243,6 +224,9 @@ function customConfig (): CustomConfig { | |||
243 | admin: { | 224 | admin: { |
244 | email: CONFIG.ADMIN.EMAIL | 225 | email: CONFIG.ADMIN.EMAIL |
245 | }, | 226 | }, |
227 | contactForm: { | ||
228 | enabled: CONFIG.CONTACT_FORM.ENABLED | ||
229 | }, | ||
246 | user: { | 230 | user: { |
247 | videoQuota: CONFIG.USER.VIDEO_QUOTA, | 231 | videoQuota: CONFIG.USER.VIDEO_QUOTA, |
248 | videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY | 232 | videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY |
@@ -271,3 +255,20 @@ function customConfig (): CustomConfig { | |||
271 | } | 255 | } |
272 | } | 256 | } |
273 | } | 257 | } |
258 | |||
259 | function convertCustomConfigBody (body: CustomConfig) { | ||
260 | function keyConverter (k: string) { | ||
261 | // Transcoding resolutions exception | ||
262 | if (/^\d{3,4}p$/.exec(k)) return k | ||
263 | |||
264 | return snakeCase(k) | ||
265 | } | ||
266 | |||
267 | function valueConverter (v: any) { | ||
268 | if (isNumeric(v + '')) return parseInt('' + v, 10) | ||
269 | |||
270 | return v | ||
271 | } | ||
272 | |||
273 | return objectConverter(body, keyConverter, valueConverter) | ||
274 | } | ||
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { asyncMiddleware, contactAdministratorValidator } from '../../../middlewares' | ||
3 | import { Redis } from '../../../lib/redis' | ||
4 | import { Emailer } from '../../../lib/emailer' | ||
5 | import { ContactForm } from '../../../../shared/models/server' | ||
6 | |||
7 | const contactRouter = express.Router() | ||
8 | |||
9 | contactRouter.post('/contact', | ||
10 | asyncMiddleware(contactAdministratorValidator), | ||
11 | asyncMiddleware(contactAdministrator) | ||
12 | ) | ||
13 | |||
14 | async function contactAdministrator (req: express.Request, res: express.Response) { | ||
15 | const data = req.body as ContactForm | ||
16 | |||
17 | await Emailer.Instance.addContactFormJob(data.fromEmail, data.fromName, data.body) | ||
18 | |||
19 | await Redis.Instance.setContactFormIp(req.ip) | ||
20 | |||
21 | return res.status(204).end() | ||
22 | } | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | export { | ||
27 | contactRouter | ||
28 | } | ||
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' | |||
3 | import { statsRouter } from './stats' | 3 | import { statsRouter } from './stats' |
4 | import { serverRedundancyRouter } from './redundancy' | 4 | import { serverRedundancyRouter } from './redundancy' |
5 | import { serverBlocklistRouter } from './server-blocklist' | 5 | import { serverBlocklistRouter } from './server-blocklist' |
6 | import { contactRouter } from './contact' | ||
6 | 7 | ||
7 | const serverRouter = express.Router() | 8 | const serverRouter = express.Router() |
8 | 9 | ||
@@ -10,6 +11,7 @@ serverRouter.use('/', serverFollowsRouter) | |||
10 | serverRouter.use('/', serverRedundancyRouter) | 11 | serverRouter.use('/', serverRedundancyRouter) |
11 | serverRouter.use('/', statsRouter) | 12 | serverRouter.use('/', statsRouter) |
12 | serverRouter.use('/', serverBlocklistRouter) | 13 | serverRouter.use('/', serverBlocklistRouter) |
14 | serverRouter.use('/', contactRouter) | ||
13 | 15 | ||
14 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
15 | 17 | ||
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' | |||
11 | import { URL } from 'url' | 11 | import { URL } from 'url' |
12 | import { truncate } from 'lodash' | 12 | import { truncate } from 'lodash' |
13 | import { exec } from 'child_process' | 13 | import { exec } from 'child_process' |
14 | import { isArray } from './custom-validators/misc' | ||
15 | |||
16 | const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => { | ||
17 | if (!oldObject || typeof oldObject !== 'object') { | ||
18 | return valueConverter(oldObject) | ||
19 | } | ||
20 | |||
21 | if (isArray(oldObject)) { | ||
22 | return oldObject.map(e => objectConverter(e, keyConverter, valueConverter)) | ||
23 | } | ||
24 | |||
25 | const newObject = {} | ||
26 | Object.keys(oldObject).forEach(oldKey => { | ||
27 | const newKey = keyConverter(oldKey) | ||
28 | newObject[ newKey ] = objectConverter(oldObject[ oldKey ], keyConverter, valueConverter) | ||
29 | }) | ||
30 | |||
31 | return newObject | ||
32 | } | ||
14 | 33 | ||
15 | const timeTable = { | 34 | const timeTable = { |
16 | ms: 1, | 35 | ms: 1, |
@@ -235,6 +254,7 @@ export { | |||
235 | isTestInstance, | 254 | isTestInstance, |
236 | isProdInstance, | 255 | isProdInstance, |
237 | 256 | ||
257 | objectConverter, | ||
238 | root, | 258 | root, |
239 | escapeHTML, | 259 | escapeHTML, |
240 | pageToStartAndCount, | 260 | 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' | |||
3 | 3 | ||
4 | import { isArray, exists } from './misc' | 4 | import { isArray, exists } from './misc' |
5 | import { isTestInstance } from '../core-utils' | 5 | import { isTestInstance } from '../core-utils' |
6 | import { CONSTRAINTS_FIELDS } from '../../initializers' | ||
6 | 7 | ||
7 | function isHostValid (host: string) { | 8 | function isHostValid (host: string) { |
8 | const isURLOptions = { | 9 | const isURLOptions = { |
@@ -26,9 +27,19 @@ function isEachUniqueHostValid (hosts: string[]) { | |||
26 | }) | 27 | }) |
27 | } | 28 | } |
28 | 29 | ||
30 | function isValidContactBody (value: any) { | ||
31 | return exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.CONTACT_FORM.BODY) | ||
32 | } | ||
33 | |||
34 | function isValidContactFromName (value: any) { | ||
35 | return exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.CONTACT_FORM.FROM_NAME) | ||
36 | } | ||
37 | |||
29 | // --------------------------------------------------------------------------- | 38 | // --------------------------------------------------------------------------- |
30 | 39 | ||
31 | export { | 40 | export { |
41 | isValidContactBody, | ||
42 | isValidContactFromName, | ||
32 | isEachUniqueHostValid, | 43 | isEachUniqueHostValid, |
33 | isHostValid | 44 | isHostValid |
34 | } | 45 | } |
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' | |||
7 | import { Instance as ParseTorrent } from 'parse-torrent' | 7 | import { Instance as ParseTorrent } from 'parse-torrent' |
8 | import { remove } from 'fs-extra' | 8 | import { remove } from 'fs-extra' |
9 | import * as memoizee from 'memoizee' | 9 | import * as memoizee from 'memoizee' |
10 | import { isArray } from './custom-validators/misc' | ||
10 | 11 | ||
11 | function deleteFileAsync (path: string) { | 12 | function deleteFileAsync (path: string) { |
12 | remove(path) | 13 | remove(path) |
@@ -19,10 +20,7 @@ async function generateRandomString (size: number) { | |||
19 | return raw.toString('hex') | 20 | return raw.toString('hex') |
20 | } | 21 | } |
21 | 22 | ||
22 | interface FormattableToJSON { | 23 | interface FormattableToJSON { toFormattedJSON (args?: any) } |
23 | toFormattedJSON (args?: any) | ||
24 | } | ||
25 | |||
26 | function getFormattedObjects<U, T extends FormattableToJSON> (objects: T[], objectsTotal: number, formattedArg?: any) { | 24 | function getFormattedObjects<U, T extends FormattableToJSON> (objects: T[], objectsTotal: number, formattedArg?: any) { |
27 | const formattedObjects: U[] = [] | 25 | const formattedObjects: U[] = [] |
28 | 26 | ||
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 = { | |||
231 | ADMIN: { | 231 | ADMIN: { |
232 | get EMAIL () { return config.get<string>('admin.email') } | 232 | get EMAIL () { return config.get<string>('admin.email') } |
233 | }, | 233 | }, |
234 | CONTACT_FORM: { | ||
235 | get ENABLED () { return config.get<boolean>('contact_form.enabled') } | ||
236 | }, | ||
234 | SIGNUP: { | 237 | SIGNUP: { |
235 | get ENABLED () { return config.get<boolean>('signup.enabled') }, | 238 | get ENABLED () { return config.get<boolean>('signup.enabled') }, |
236 | get LIMIT () { return config.get<number>('signup.limit') }, | 239 | get LIMIT () { return config.get<number>('signup.limit') }, |
@@ -394,6 +397,10 @@ let CONSTRAINTS_FIELDS = { | |||
394 | }, | 397 | }, |
395 | VIDEO_SHARE: { | 398 | VIDEO_SHARE: { |
396 | URL: { min: 3, max: 2000 } // Length | 399 | URL: { min: 3, max: 2000 } // Length |
400 | }, | ||
401 | CONTACT_FORM: { | ||
402 | FROM_NAME: { min: 1, max: 120 }, // Length | ||
403 | BODY: { min: 3, max: 5000 } // Length | ||
397 | } | 404 | } |
398 | } | 405 | } |
399 | 406 | ||
@@ -409,6 +416,8 @@ const RATES_LIMIT = { | |||
409 | } | 416 | } |
410 | 417 | ||
411 | let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour | 418 | let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour |
419 | let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour | ||
420 | |||
412 | const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { | 421 | const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { |
413 | MIN: 10, | 422 | MIN: 10, |
414 | AVERAGE: 30, | 423 | AVERAGE: 30, |
@@ -685,6 +694,7 @@ if (isTestInstance() === true) { | |||
685 | REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1 | 694 | REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1 |
686 | 695 | ||
687 | VIDEO_VIEW_LIFETIME = 1000 // 1 second | 696 | VIDEO_VIEW_LIFETIME = 1000 // 1 second |
697 | CONTACT_FORM_LIFETIME = 1000 // 1 second | ||
688 | 698 | ||
689 | JOB_ATTEMPTS['email'] = 1 | 699 | JOB_ATTEMPTS['email'] = 1 |
690 | 700 | ||
@@ -756,6 +766,7 @@ export { | |||
756 | HTTP_SIGNATURE, | 766 | HTTP_SIGNATURE, |
757 | VIDEO_IMPORT_STATES, | 767 | VIDEO_IMPORT_STATES, |
758 | VIDEO_VIEW_LIFETIME, | 768 | VIDEO_VIEW_LIFETIME, |
769 | CONTACT_FORM_LIFETIME, | ||
759 | buildLanguages | 770 | buildLanguages |
760 | } | 771 | } |
761 | 772 | ||
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 { | |||
354 | return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) | 354 | return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) |
355 | } | 355 | } |
356 | 356 | ||
357 | sendMail (to: string[], subject: string, text: string) { | 357 | addContactFormJob (fromEmail: string, fromName: string, body: string) { |
358 | const text = 'Hello dear admin,\n\n' + | ||
359 | fromName + ' sent you a message' + | ||
360 | '\n\n---------------------------------------\n\n' + | ||
361 | body + | ||
362 | '\n\n---------------------------------------\n\n' + | ||
363 | 'Cheers,\n' + | ||
364 | 'PeerTube.' | ||
365 | |||
366 | const emailPayload: EmailPayload = { | ||
367 | from: fromEmail, | ||
368 | to: [ CONFIG.ADMIN.EMAIL ], | ||
369 | subject: '[PeerTube] Contact form submitted', | ||
370 | text | ||
371 | } | ||
372 | |||
373 | return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) | ||
374 | } | ||
375 | |||
376 | sendMail (to: string[], subject: string, text: string, from?: string) { | ||
358 | if (!this.enabled) { | 377 | if (!this.enabled) { |
359 | throw new Error('Cannot send mail because SMTP is not configured.') | 378 | throw new Error('Cannot send mail because SMTP is not configured.') |
360 | } | 379 | } |
361 | 380 | ||
362 | return this.transporter.sendMail({ | 381 | return this.transporter.sendMail({ |
363 | from: CONFIG.SMTP.FROM_ADDRESS, | 382 | from: from || CONFIG.SMTP.FROM_ADDRESS, |
364 | to: to.join(','), | 383 | to: to.join(','), |
365 | subject, | 384 | subject, |
366 | text | 385 | 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 = { | |||
6 | to: string[] | 6 | to: string[] |
7 | subject: string | 7 | subject: string |
8 | text: string | 8 | text: string |
9 | from?: string | ||
9 | } | 10 | } |
10 | 11 | ||
11 | async function processEmail (job: Bull.Job) { | 12 | async function processEmail (job: Bull.Job) { |
12 | const payload = job.data as EmailPayload | 13 | const payload = job.data as EmailPayload |
13 | logger.info('Processing email in job %d.', job.id) | 14 | logger.info('Processing email in job %d.', job.id) |
14 | 15 | ||
15 | return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text) | 16 | return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text, payload.from) |
16 | } | 17 | } |
17 | 18 | ||
18 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
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' | |||
2 | import { createClient, RedisClient } from 'redis' | 2 | import { createClient, RedisClient } from 'redis' |
3 | import { logger } from '../helpers/logger' | 3 | import { logger } from '../helpers/logger' |
4 | import { generateRandomString } from '../helpers/utils' | 4 | import { generateRandomString } from '../helpers/utils' |
5 | import { CONFIG, USER_PASSWORD_RESET_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers' | 5 | import { |
6 | CONFIG, | ||
7 | CONTACT_FORM_LIFETIME, | ||
8 | USER_EMAIL_VERIFY_LIFETIME, | ||
9 | USER_PASSWORD_RESET_LIFETIME, | ||
10 | VIDEO_VIEW_LIFETIME | ||
11 | } from '../initializers' | ||
6 | 12 | ||
7 | type CachedRoute = { | 13 | type CachedRoute = { |
8 | body: string, | 14 | body: string, |
@@ -76,6 +82,16 @@ class Redis { | |||
76 | return this.getValue(this.generateVerifyEmailKey(userId)) | 82 | return this.getValue(this.generateVerifyEmailKey(userId)) |
77 | } | 83 | } |
78 | 84 | ||
85 | /************* Contact form per IP *************/ | ||
86 | |||
87 | async setContactFormIp (ip: string) { | ||
88 | return this.setValue(this.generateContactFormKey(ip), '1', CONTACT_FORM_LIFETIME) | ||
89 | } | ||
90 | |||
91 | async isContactFormIpExists (ip: string) { | ||
92 | return this.exists(this.generateContactFormKey(ip)) | ||
93 | } | ||
94 | |||
79 | /************* Views per IP *************/ | 95 | /************* Views per IP *************/ |
80 | 96 | ||
81 | setIPVideoView (ip: string, videoUUID: string) { | 97 | setIPVideoView (ip: string, videoUUID: string) { |
@@ -175,7 +191,11 @@ class Redis { | |||
175 | } | 191 | } |
176 | 192 | ||
177 | private generateViewKey (ip: string, videoUUID: string) { | 193 | private generateViewKey (ip: string, videoUUID: string) { |
178 | return videoUUID + '-' + ip | 194 | return `views-${videoUUID}-${ip}` |
195 | } | ||
196 | |||
197 | private generateContactFormKey (ip: string) { | ||
198 | return 'contact-form-' + ip | ||
179 | } | 199 | } |
180 | 200 | ||
181 | /************* Redis helpers *************/ | 201 | /************* 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body } from 'express-validator/check' | 2 | import { body } from 'express-validator/check' |
3 | import { isUserNSFWPolicyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users' | 3 | import { isUserNSFWPolicyValid, isUserVideoQuotaValid, isUserVideoQuotaDailyValid } from '../../helpers/custom-validators/users' |
4 | import { logger } from '../../helpers/logger' | 4 | import { logger } from '../../helpers/logger' |
5 | import { areValidationErrors } from './utils' | 5 | import { areValidationErrors } from './utils' |
6 | 6 | ||
7 | const customConfigUpdateValidator = [ | 7 | const customConfigUpdateValidator = [ |
8 | body('instance.name').exists().withMessage('Should have a valid instance name'), | 8 | body('instance.name').exists().withMessage('Should have a valid instance name'), |
9 | body('instance.shortDescription').exists().withMessage('Should have a valid instance short description'), | ||
9 | body('instance.description').exists().withMessage('Should have a valid instance description'), | 10 | body('instance.description').exists().withMessage('Should have a valid instance description'), |
10 | body('instance.terms').exists().withMessage('Should have a valid instance terms'), | 11 | body('instance.terms').exists().withMessage('Should have a valid instance terms'), |
11 | body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'), | 12 | body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'), |
12 | body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid).withMessage('Should have a valid NSFW policy'), | 13 | body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid).withMessage('Should have a valid NSFW policy'), |
13 | body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'), | 14 | body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'), |
14 | body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'), | 15 | body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'), |
15 | body('cache.previews.size').isInt().withMessage('Should have a valid previews size'), | 16 | |
17 | body('services.twitter.username').exists().withMessage('Should have a valid twitter username'), | ||
18 | body('services.twitter.whitelisted').isBoolean().withMessage('Should have a valid twitter whitelisted boolean'), | ||
19 | |||
20 | body('cache.previews.size').isInt().withMessage('Should have a valid previews cache size'), | ||
21 | body('cache.captions.size').isInt().withMessage('Should have a valid captions cache size'), | ||
22 | |||
16 | body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'), | 23 | body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'), |
17 | body('signup.limit').isInt().withMessage('Should have a valid signup limit'), | 24 | body('signup.limit').isInt().withMessage('Should have a valid signup limit'), |
25 | body('signup.requiresEmailVerification').isBoolean().withMessage('Should have a valid requiresEmailVerification boolean'), | ||
26 | |||
18 | body('admin.email').isEmail().withMessage('Should have a valid administrator email'), | 27 | body('admin.email').isEmail().withMessage('Should have a valid administrator email'), |
28 | body('contactForm.enabled').isBoolean().withMessage('Should have a valid contact form enabled boolean'), | ||
29 | |||
19 | body('user.videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid video quota'), | 30 | body('user.videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid video quota'), |
31 | body('user.videoQuotaDaily').custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily video quota'), | ||
32 | |||
20 | body('transcoding.enabled').isBoolean().withMessage('Should have a valid transcoding enabled boolean'), | 33 | body('transcoding.enabled').isBoolean().withMessage('Should have a valid transcoding enabled boolean'), |
34 | body('transcoding.allowAdditionalExtensions').isBoolean().withMessage('Should have a valid additional extensions boolean'), | ||
21 | body('transcoding.threads').isInt().withMessage('Should have a valid transcoding threads number'), | 35 | body('transcoding.threads').isInt().withMessage('Should have a valid transcoding threads number'), |
22 | body('transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'), | 36 | body('transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'), |
23 | body('transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'), | 37 | body('transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'), |
24 | body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'), | 38 | body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'), |
25 | body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'), | 39 | body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'), |
26 | body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'), | 40 | body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'), |
41 | |||
27 | body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'), | 42 | body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'), |
28 | body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'), | 43 | body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'), |
29 | 44 | ||
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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { logger } from '../../helpers/logger' | 2 | import { logger } from '../../helpers/logger' |
3 | import { areValidationErrors } from './utils' | 3 | import { areValidationErrors } from './utils' |
4 | import { isHostValid } from '../../helpers/custom-validators/servers' | 4 | import { isHostValid, isValidContactBody } from '../../helpers/custom-validators/servers' |
5 | import { ServerModel } from '../../models/server/server' | 5 | import { ServerModel } from '../../models/server/server' |
6 | import { body } from 'express-validator/check' | 6 | import { body } from 'express-validator/check' |
7 | import { isUserDisplayNameValid } from '../../helpers/custom-validators/users' | ||
8 | import { Emailer } from '../../lib/emailer' | ||
9 | import { Redis } from '../../lib/redis' | ||
10 | import { CONFIG } from '../../initializers/constants' | ||
7 | 11 | ||
8 | const serverGetValidator = [ | 12 | const serverGetValidator = [ |
9 | body('host').custom(isHostValid).withMessage('Should have a valid host'), | 13 | body('host').custom(isHostValid).withMessage('Should have a valid host'), |
@@ -26,8 +30,49 @@ const serverGetValidator = [ | |||
26 | } | 30 | } |
27 | ] | 31 | ] |
28 | 32 | ||
33 | const contactAdministratorValidator = [ | ||
34 | body('fromName') | ||
35 | .custom(isUserDisplayNameValid).withMessage('Should have a valid name'), | ||
36 | body('fromEmail') | ||
37 | .isEmail().withMessage('Should have a valid email'), | ||
38 | body('body') | ||
39 | .custom(isValidContactBody).withMessage('Should have a valid body'), | ||
40 | |||
41 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
42 | logger.debug('Checking contactAdministratorValidator parameters', { parameters: req.body }) | ||
43 | |||
44 | if (areValidationErrors(req, res)) return | ||
45 | |||
46 | if (CONFIG.CONTACT_FORM.ENABLED === false) { | ||
47 | return res | ||
48 | .status(409) | ||
49 | .send({ error: 'Contact form is not enabled on this instance.' }) | ||
50 | .end() | ||
51 | } | ||
52 | |||
53 | if (Emailer.Instance.isEnabled() === false) { | ||
54 | return res | ||
55 | .status(409) | ||
56 | .send({ error: 'Emailer is not enabled on this instance.' }) | ||
57 | .end() | ||
58 | } | ||
59 | |||
60 | if (await Redis.Instance.isContactFormIpExists(req.ip)) { | ||
61 | logger.info('Refusing a contact form by %s: already sent one recently.', req.ip) | ||
62 | |||
63 | return res | ||
64 | .status(403) | ||
65 | .send({ error: 'You already sent a contact form recently.' }) | ||
66 | .end() | ||
67 | } | ||
68 | |||
69 | return next() | ||
70 | } | ||
71 | ] | ||
72 | |||
29 | // --------------------------------------------------------------------------- | 73 | // --------------------------------------------------------------------------- |
30 | 74 | ||
31 | export { | 75 | export { |
32 | serverGetValidator | 76 | serverGetValidator, |
77 | contactAdministratorValidator | ||
33 | } | 78 | } |
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 () { | |||
48 | admin: { | 48 | admin: { |
49 | email: 'superadmin1@example.com' | 49 | email: 'superadmin1@example.com' |
50 | }, | 50 | }, |
51 | contactForm: { | ||
52 | enabled: false | ||
53 | }, | ||
51 | user: { | 54 | user: { |
52 | videoQuota: 5242881, | 55 | videoQuota: 5242881, |
53 | videoQuotaDaily: 318742 | 56 | 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 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import 'mocha' | ||
4 | |||
5 | import { | ||
6 | flushTests, | ||
7 | immutableAssign, | ||
8 | killallServers, | ||
9 | reRunServer, | ||
10 | runServer, | ||
11 | ServerInfo, | ||
12 | setAccessTokensToServers | ||
13 | } from '../../../../shared/utils' | ||
14 | import { | ||
15 | checkBadCountPagination, | ||
16 | checkBadSortPagination, | ||
17 | checkBadStartPagination | ||
18 | } from '../../../../shared/utils/requests/check-api-params' | ||
19 | import { getAccount } from '../../../../shared/utils/users/accounts' | ||
20 | import { sendContactForm } from '../../../../shared/utils/server/contact-form' | ||
21 | import { MockSmtpServer } from '../../../../shared/utils/miscs/email' | ||
22 | |||
23 | describe('Test contact form API validators', function () { | ||
24 | let server: ServerInfo | ||
25 | const emails: object[] = [] | ||
26 | const defaultBody = { | ||
27 | fromName: 'super name', | ||
28 | fromEmail: 'toto@example.com', | ||
29 | body: 'Hello, how are you?' | ||
30 | } | ||
31 | |||
32 | // --------------------------------------------------------------- | ||
33 | |||
34 | before(async function () { | ||
35 | this.timeout(60000) | ||
36 | |||
37 | await flushTests() | ||
38 | await MockSmtpServer.Instance.collectEmails(emails) | ||
39 | |||
40 | // Email is disabled | ||
41 | server = await runServer(1) | ||
42 | }) | ||
43 | |||
44 | it('Should not accept a contact form if emails are disabled', async function () { | ||
45 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 409 })) | ||
46 | }) | ||
47 | |||
48 | it('Should not accept a contact form if it is disabled in the configuration', async function () { | ||
49 | killallServers([ server ]) | ||
50 | |||
51 | // Contact form is disabled | ||
52 | await reRunServer(server, { smtp: { hostname: 'localhost' }, contact_form: { enabled: false } }) | ||
53 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 409 })) | ||
54 | }) | ||
55 | |||
56 | it('Should not accept a contact form if from email is invalid', async function () { | ||
57 | killallServers([ server ]) | ||
58 | |||
59 | // Email & contact form enabled | ||
60 | await reRunServer(server, { smtp: { hostname: 'localhost' } }) | ||
61 | |||
62 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: 'badEmail' })) | ||
63 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: 'badEmail@' })) | ||
64 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: undefined })) | ||
65 | }) | ||
66 | |||
67 | it('Should not accept a contact form if from name is invalid', async function () { | ||
68 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: 'name'.repeat(100) })) | ||
69 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: '' })) | ||
70 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: undefined })) | ||
71 | }) | ||
72 | |||
73 | it('Should not accept a contact form if body is invalid', async function () { | ||
74 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: 'body'.repeat(5000) })) | ||
75 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: 'a' })) | ||
76 | await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: undefined })) | ||
77 | }) | ||
78 | |||
79 | it('Should accept a contact form with the correct parameters', async function () { | ||
80 | await sendContactForm(immutableAssign(defaultBody, { url: server.url })) | ||
81 | }) | ||
82 | |||
83 | after(async function () { | ||
84 | MockSmtpServer.Instance.kill() | ||
85 | killallServers([ server ]) | ||
86 | |||
87 | // Keep the logs if the test failed | ||
88 | if (this['ok']) { | ||
89 | await flushTests() | ||
90 | } | ||
91 | }) | ||
92 | }) | ||
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 @@ | |||
1 | // Order of the tests we want to execute | ||
2 | import './accounts' | 1 | import './accounts' |
3 | import './blocklist' | 2 | import './blocklist' |
4 | import './config' | 3 | import './config' |
4 | import './contact-form' | ||
5 | import './follows' | 5 | import './follows' |
6 | import './jobs' | 6 | import './jobs' |
7 | import './redundancy' | 7 | 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) { | |||
33 | expect(data.instance.defaultNSFWPolicy).to.equal('display') | 33 | expect(data.instance.defaultNSFWPolicy).to.equal('display') |
34 | expect(data.instance.customizations.css).to.be.empty | 34 | expect(data.instance.customizations.css).to.be.empty |
35 | expect(data.instance.customizations.javascript).to.be.empty | 35 | expect(data.instance.customizations.javascript).to.be.empty |
36 | |||
36 | expect(data.services.twitter.username).to.equal('@Chocobozzz') | 37 | expect(data.services.twitter.username).to.equal('@Chocobozzz') |
37 | expect(data.services.twitter.whitelisted).to.be.false | 38 | expect(data.services.twitter.whitelisted).to.be.false |
39 | |||
38 | expect(data.cache.previews.size).to.equal(1) | 40 | expect(data.cache.previews.size).to.equal(1) |
39 | expect(data.cache.captions.size).to.equal(1) | 41 | expect(data.cache.captions.size).to.equal(1) |
42 | |||
40 | expect(data.signup.enabled).to.be.true | 43 | expect(data.signup.enabled).to.be.true |
41 | expect(data.signup.limit).to.equal(4) | 44 | expect(data.signup.limit).to.equal(4) |
42 | expect(data.signup.requiresEmailVerification).to.be.false | 45 | expect(data.signup.requiresEmailVerification).to.be.false |
46 | |||
43 | expect(data.admin.email).to.equal('admin1@example.com') | 47 | expect(data.admin.email).to.equal('admin1@example.com') |
48 | expect(data.contactForm.enabled).to.be.true | ||
49 | |||
44 | expect(data.user.videoQuota).to.equal(5242880) | 50 | expect(data.user.videoQuota).to.equal(5242880) |
45 | expect(data.user.videoQuotaDaily).to.equal(-1) | 51 | expect(data.user.videoQuotaDaily).to.equal(-1) |
46 | expect(data.transcoding.enabled).to.be.false | 52 | expect(data.transcoding.enabled).to.be.false |
@@ -64,16 +70,23 @@ function checkUpdatedConfig (data: CustomConfig) { | |||
64 | expect(data.instance.defaultNSFWPolicy).to.equal('blur') | 70 | expect(data.instance.defaultNSFWPolicy).to.equal('blur') |
65 | expect(data.instance.customizations.javascript).to.equal('alert("coucou")') | 71 | expect(data.instance.customizations.javascript).to.equal('alert("coucou")') |
66 | expect(data.instance.customizations.css).to.equal('body { background-color: red; }') | 72 | expect(data.instance.customizations.css).to.equal('body { background-color: red; }') |
73 | |||
67 | expect(data.services.twitter.username).to.equal('@Kuja') | 74 | expect(data.services.twitter.username).to.equal('@Kuja') |
68 | expect(data.services.twitter.whitelisted).to.be.true | 75 | expect(data.services.twitter.whitelisted).to.be.true |
76 | |||
69 | expect(data.cache.previews.size).to.equal(2) | 77 | expect(data.cache.previews.size).to.equal(2) |
70 | expect(data.cache.captions.size).to.equal(3) | 78 | expect(data.cache.captions.size).to.equal(3) |
79 | |||
71 | expect(data.signup.enabled).to.be.false | 80 | expect(data.signup.enabled).to.be.false |
72 | expect(data.signup.limit).to.equal(5) | 81 | expect(data.signup.limit).to.equal(5) |
73 | expect(data.signup.requiresEmailVerification).to.be.true | 82 | expect(data.signup.requiresEmailVerification).to.be.true |
83 | |||
74 | expect(data.admin.email).to.equal('superadmin1@example.com') | 84 | expect(data.admin.email).to.equal('superadmin1@example.com') |
85 | expect(data.contactForm.enabled).to.be.false | ||
86 | |||
75 | expect(data.user.videoQuota).to.equal(5242881) | 87 | expect(data.user.videoQuota).to.equal(5242881) |
76 | expect(data.user.videoQuotaDaily).to.equal(318742) | 88 | expect(data.user.videoQuotaDaily).to.equal(318742) |
89 | |||
77 | expect(data.transcoding.enabled).to.be.true | 90 | expect(data.transcoding.enabled).to.be.true |
78 | expect(data.transcoding.threads).to.equal(1) | 91 | expect(data.transcoding.threads).to.equal(1) |
79 | expect(data.transcoding.allowAdditionalExtensions).to.be.true | 92 | expect(data.transcoding.allowAdditionalExtensions).to.be.true |
@@ -82,6 +95,7 @@ function checkUpdatedConfig (data: CustomConfig) { | |||
82 | expect(data.transcoding.resolutions['480p']).to.be.true | 95 | expect(data.transcoding.resolutions['480p']).to.be.true |
83 | expect(data.transcoding.resolutions['720p']).to.be.false | 96 | expect(data.transcoding.resolutions['720p']).to.be.false |
84 | expect(data.transcoding.resolutions['1080p']).to.be.false | 97 | expect(data.transcoding.resolutions['1080p']).to.be.false |
98 | |||
85 | expect(data.import.videos.http.enabled).to.be.false | 99 | expect(data.import.videos.http.enabled).to.be.false |
86 | expect(data.import.videos.torrent.enabled).to.be.false | 100 | expect(data.import.videos.torrent.enabled).to.be.false |
87 | } | 101 | } |
@@ -127,6 +141,8 @@ describe('Test config', function () { | |||
127 | expect(data.video.file.extensions).to.contain('.mp4') | 141 | expect(data.video.file.extensions).to.contain('.mp4') |
128 | expect(data.video.file.extensions).to.contain('.webm') | 142 | expect(data.video.file.extensions).to.contain('.webm') |
129 | expect(data.video.file.extensions).to.contain('.ogv') | 143 | expect(data.video.file.extensions).to.contain('.ogv') |
144 | |||
145 | expect(data.contactForm.enabled).to.be.true | ||
130 | }) | 146 | }) |
131 | 147 | ||
132 | it('Should get the customized configuration', async function () { | 148 | it('Should get the customized configuration', async function () { |
@@ -172,6 +188,9 @@ describe('Test config', function () { | |||
172 | admin: { | 188 | admin: { |
173 | email: 'superadmin1@example.com' | 189 | email: 'superadmin1@example.com' |
174 | }, | 190 | }, |
191 | contactForm: { | ||
192 | enabled: false | ||
193 | }, | ||
175 | user: { | 194 | user: { |
176 | videoQuota: 5242881, | 195 | videoQuota: 5242881, |
177 | videoQuotaDaily: 318742 | 196 | 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 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, wait } from '../../../../shared/utils' | ||
6 | import { MockSmtpServer } from '../../../../shared/utils/miscs/email' | ||
7 | import { waitJobs } from '../../../../shared/utils/server/jobs' | ||
8 | import { sendContactForm } from '../../../../shared/utils/server/contact-form' | ||
9 | |||
10 | const expect = chai.expect | ||
11 | |||
12 | describe('Test contact form', function () { | ||
13 | let server: ServerInfo | ||
14 | const emails: object[] = [] | ||
15 | |||
16 | before(async function () { | ||
17 | this.timeout(30000) | ||
18 | |||
19 | await MockSmtpServer.Instance.collectEmails(emails) | ||
20 | |||
21 | await flushTests() | ||
22 | |||
23 | const overrideConfig = { | ||
24 | smtp: { | ||
25 | hostname: 'localhost' | ||
26 | } | ||
27 | } | ||
28 | server = await runServer(1, overrideConfig) | ||
29 | await setAccessTokensToServers([ server ]) | ||
30 | }) | ||
31 | |||
32 | it('Should send a contact form', async function () { | ||
33 | await sendContactForm({ | ||
34 | url: server.url, | ||
35 | fromEmail: 'toto@example.com', | ||
36 | body: 'my super message', | ||
37 | fromName: 'Super toto' | ||
38 | }) | ||
39 | |||
40 | await waitJobs(server) | ||
41 | |||
42 | expect(emails).to.have.lengthOf(1) | ||
43 | |||
44 | const email = emails[0] | ||
45 | |||
46 | expect(email['from'][0]['address']).equal('toto@example.com') | ||
47 | expect(email['to'][0]['address']).equal('admin1@example.com') | ||
48 | expect(email['subject']).contains('Contact form') | ||
49 | expect(email['text']).contains('my super message') | ||
50 | }) | ||
51 | |||
52 | it('Should not be able to send another contact form because of the anti spam checker', async function () { | ||
53 | await sendContactForm({ | ||
54 | url: server.url, | ||
55 | fromEmail: 'toto@example.com', | ||
56 | body: 'my super message', | ||
57 | fromName: 'Super toto' | ||
58 | }) | ||
59 | |||
60 | await sendContactForm({ | ||
61 | url: server.url, | ||
62 | fromEmail: 'toto@example.com', | ||
63 | body: 'my super message', | ||
64 | fromName: 'Super toto', | ||
65 | expectedStatus: 403 | ||
66 | }) | ||
67 | }) | ||
68 | |||
69 | it('Should be able to send another contact form after a while', async function () { | ||
70 | await wait(1000) | ||
71 | |||
72 | await sendContactForm({ | ||
73 | url: server.url, | ||
74 | fromEmail: 'toto@example.com', | ||
75 | body: 'my super message', | ||
76 | fromName: 'Super toto' | ||
77 | }) | ||
78 | }) | ||
79 | |||
80 | after(async function () { | ||
81 | MockSmtpServer.Instance.kill() | ||
82 | killallServers([ server ]) | ||
83 | }) | ||
84 | }) | ||
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 | |||
8 | 8 | ||
9 | import { | 9 | import { |
10 | completeVideoCheck, | 10 | completeVideoCheck, |
11 | getVideo, | ||
12 | immutableAssign, | ||
13 | reRunServer, | ||
14 | unfollow, | ||
15 | viewVideo, | ||
16 | flushAndRunMultipleServers, | 11 | flushAndRunMultipleServers, |
12 | getVideo, | ||
17 | getVideosList, | 13 | getVideosList, |
14 | immutableAssign, | ||
18 | killallServers, | 15 | killallServers, |
16 | reRunServer, | ||
19 | ServerInfo, | 17 | ServerInfo, |
20 | setAccessTokensToServers, | 18 | setAccessTokensToServers, |
21 | uploadVideo, | 19 | unfollow, |
22 | updateVideo, | 20 | updateVideo, |
21 | uploadVideo, | ||
23 | wait | 22 | wait |
24 | } from '../../../../shared/utils' | 23 | } from '../../../../shared/utils' |
25 | import { follow, getFollowersListPaginationAndSort } from '../../../../shared/utils/server/follows' | 24 | 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 @@ | |||
1 | import './config' | 1 | import './config' |
2 | import './contact-form' | ||
2 | import './email' | 3 | import './email' |
3 | import './follow-constraints' | 4 | import './follow-constraints' |
4 | import './follows' | 5 | 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 @@ | |||
2 | 2 | ||
3 | import * as chai from 'chai' | 3 | import * as chai from 'chai' |
4 | import 'mocha' | 4 | import 'mocha' |
5 | import { snakeCase, isNumber } from 'lodash' | ||
5 | import { | 6 | import { |
6 | parseBytes | 7 | parseBytes, objectConverter |
7 | } from '../../helpers/core-utils' | 8 | } from '../../helpers/core-utils' |
9 | import { isNumeric } from 'validator' | ||
8 | 10 | ||
9 | const expect = chai.expect | 11 | const expect = chai.expect |
10 | 12 | ||
11 | describe('Parse Bytes', function () { | 13 | describe('Parse Bytes', function () { |
14 | |||
12 | it('Should pass when given valid value', async function () { | 15 | it('Should pass when given valid value', async function () { |
13 | // just return it | 16 | // just return it |
14 | expect(parseBytes(1024)).to.be.eq(1024) | 17 | expect(parseBytes(1024)).to.be.eq(1024) |
@@ -45,4 +48,51 @@ describe('Parse Bytes', function () { | |||
45 | it('Should be invalid when given invalid value', async function () { | 48 | it('Should be invalid when given invalid value', async function () { |
46 | expect(parseBytes('6GB 1GB')).to.be.eq(6) | 49 | expect(parseBytes('6GB 1GB')).to.be.eq(6) |
47 | }) | 50 | }) |
51 | |||
52 | it('Should convert an object', async function () { | ||
53 | function keyConverter (k: string) { | ||
54 | return snakeCase(k) | ||
55 | } | ||
56 | |||
57 | function valueConverter (v: any) { | ||
58 | if (isNumeric(v + '')) return parseInt('' + v, 10) | ||
59 | |||
60 | return v | ||
61 | } | ||
62 | |||
63 | const obj = { | ||
64 | mySuperKey: 'hello', | ||
65 | mySuper2Key: '45', | ||
66 | mySuper3Key: { | ||
67 | mySuperSubKey: '15', | ||
68 | mySuperSub2Key: 'hello', | ||
69 | mySuperSub3Key: [ '1', 'hello', 2 ], | ||
70 | mySuperSub4Key: 4 | ||
71 | }, | ||
72 | mySuper4Key: 45, | ||
73 | toto: { | ||
74 | super_key: '15', | ||
75 | superKey2: 'hello' | ||
76 | }, | ||
77 | super_key: { | ||
78 | superKey4: 15 | ||
79 | } | ||
80 | } | ||
81 | |||
82 | const res = objectConverter(obj, keyConverter, valueConverter) | ||
83 | |||
84 | expect(res.my_super_key).to.equal('hello') | ||
85 | expect(res.my_super_2_key).to.equal(45) | ||
86 | expect(res.my_super_3_key.my_super_sub_key).to.equal(15) | ||
87 | expect(res.my_super_3_key.my_super_sub_2_key).to.equal('hello') | ||
88 | expect(res.my_super_3_key.my_super_sub_3_key).to.deep.equal([ 1, 'hello', 2 ]) | ||
89 | expect(res.my_super_3_key.my_super_sub_4_key).to.equal(4) | ||
90 | expect(res.toto.super_key).to.equal(15) | ||
91 | expect(res.toto.super_key_2).to.equal('hello') | ||
92 | expect(res.super_key.super_key_4).to.equal(15) | ||
93 | |||
94 | // Immutable | ||
95 | expect(res.mySuperKey).to.be.undefined | ||
96 | expect(obj['my_super_key']).to.be.undefined | ||
97 | }) | ||
48 | }) | 98 | }) |
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 @@ | |||
1 | export interface ContactForm { | ||
2 | fromEmail: string | ||
3 | fromName: string | ||
4 | body: string | ||
5 | } | ||
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 { | |||
41 | email: string | 41 | email: string |
42 | } | 42 | } |
43 | 43 | ||
44 | contactForm: { | ||
45 | enabled: boolean | ||
46 | } | ||
47 | |||
44 | user: { | 48 | user: { |
45 | videoQuota: number | 49 | videoQuota: number |
46 | videoQuotaDaily: number | 50 | 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 @@ | |||
1 | export * from './about.model' | ||
2 | export * from './contact-form.model' | ||
3 | export * from './custom-config.model' | ||
4 | export * from './job.model' | ||
5 | export * from './server-config.model' | ||
6 | 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 { | |||
19 | enabled: boolean | 19 | enabled: boolean |
20 | } | 20 | } |
21 | 21 | ||
22 | contactForm: { | ||
23 | enabled: boolean | ||
24 | } | ||
25 | |||
22 | signup: { | 26 | signup: { |
23 | allowed: boolean, | 27 | allowed: boolean, |
24 | allowedForCurrentIP: boolean, | 28 | 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 { | |||
15 | return this.emails.push(msg.email) | 15 | return this.emails.push(msg.email) |
16 | } | 16 | } |
17 | }) | 17 | }) |
18 | |||
19 | process.on('exit', () => this.kill()) | ||
18 | } | 20 | } |
19 | 21 | ||
20 | collectEmails (emailsCollection: object[]) { | 22 | collectEmails (emailsCollection: object[]) { |
@@ -42,6 +44,8 @@ class MockSmtpServer { | |||
42 | } | 44 | } |
43 | 45 | ||
44 | kill () { | 46 | kill () { |
47 | if (!this.emailChildProcess) return | ||
48 | |||
45 | process.kill(this.emailChildProcess.pid) | 49 | process.kill(this.emailChildProcess.pid) |
46 | 50 | ||
47 | this.emailChildProcess = null | 51 | 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) { | |||
80 | admin: { | 80 | admin: { |
81 | email: 'superadmin1@example.com' | 81 | email: 'superadmin1@example.com' |
82 | }, | 82 | }, |
83 | contactForm: { | ||
84 | enabled: true | ||
85 | }, | ||
83 | user: { | 86 | user: { |
84 | videoQuota: 5242881, | 87 | videoQuota: 5242881, |
85 | videoQuotaDaily: 318742 | 88 | 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 @@ | |||
1 | import * as request from 'supertest' | ||
2 | import { ContactForm } from '../../models/server' | ||
3 | |||
4 | function sendContactForm (options: { | ||
5 | url: string, | ||
6 | fromEmail: string, | ||
7 | fromName: string, | ||
8 | body: string, | ||
9 | expectedStatus?: number | ||
10 | }) { | ||
11 | const path = '/api/v1/server/contact' | ||
12 | |||
13 | const body: ContactForm = { | ||
14 | fromEmail: options.fromEmail, | ||
15 | fromName: options.fromName, | ||
16 | body: options.body | ||
17 | } | ||
18 | return request(options.url) | ||
19 | .post(path) | ||
20 | .send(body) | ||
21 | .expect(options.expectedStatus || 204) | ||
22 | } | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | export { | ||
27 | sendContactForm | ||
28 | } | ||
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 | |||
18 | # /!\ Prefer to use the PeerTube admin interface to set the following configurations /!\ | 18 | # /!\ Prefer to use the PeerTube admin interface to set the following configurations /!\ |
19 | #PEERTUBE_SIGNUP_ENABLED=true | 19 | #PEERTUBE_SIGNUP_ENABLED=true |
20 | #PEERTUBE_TRANSCODING_ENABLED=true | 20 | #PEERTUBE_TRANSCODING_ENABLED=true |
21 | #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: | |||
50 | admin: | 50 | admin: |
51 | email: "PEERTUBE_ADMIN_EMAIL" | 51 | email: "PEERTUBE_ADMIN_EMAIL" |
52 | 52 | ||
53 | contact_form: | ||
54 | enabled: | ||
55 | __name: "PEERTUBE_CONTACT_FORM_ENABLED" | ||
56 | __format: "json" | ||
57 | |||
53 | signup: | 58 | signup: |
54 | enabled: | 59 | enabled: |
55 | __name: "PEERTUBE_SIGNUP_ENABLED" | 60 | __name: "PEERTUBE_SIGNUP_ENABLED" |