diff --git a/README.md b/README.md index 22b545a..7912c70 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Webistor API - Version 0.6.1 Beta +# Webistor API - Version 0.7.0 Beta ## Installing (Linux Debian) diff --git a/bin/import b/bin/import index dc6b2fa..853e752 100755 --- a/bin/import +++ b/bin/import @@ -285,7 +285,7 @@ migrate = (database) -> ## # Connect to the mongoose database. -mongoose.connect config.database +mongoose.connect "mongodb://#{config.database.host}/#{config.database.name}" # Go! program.parse process.argv diff --git a/bin/invite b/bin/invite index 1459373..8553b5d 100755 --- a/bin/invite +++ b/bin/invite @@ -158,7 +158,7 @@ invite = (options = {}) -> ## # Connect to the database. -mongoose.connect config.database +mongoose.connect "mongodb://#{config.database.host}/#{config.database.name}" # Go! program.parse process.argv diff --git a/bin/shutdown b/bin/shutdown index d570aaa..c378621 100755 --- a/bin/shutdown +++ b/bin/shutdown @@ -6,7 +6,7 @@ log = require 'node-logging' # Request options. options = host: "localhost" - port: config.daemon.adminPort + port: config.proxy.adminPort path: "/shutdown" # Create request. diff --git a/package.json b/package.json index 981df77..a423bce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webistor-api", - "version": "0.6.1-beta", + "version": "0.7.0-beta", "description": "Webistor server API", "main": "lib/index.js", "scripts": { diff --git a/src/classes/auth.coffee b/src/classes/auth.coffee index 4b6cf85..cf5c027 100644 --- a/src/classes/auth.coffee +++ b/src/classes/auth.coffee @@ -112,6 +112,7 @@ module.exports = class Auth return Promise.reject new AuthError AuthError.EXPIRED, "Auth instance expired." if @isExpired() return Promise.reject new AuthError AuthError.LOCKED, "Auth instance locked." if @isLocked() @token = randtoken.generate 32 + Promise.resolve @token ###* * Determine if this authentication session is locked for any reason. diff --git a/src/client.coffee b/src/client.coffee new file mode 100644 index 0000000..cadae94 --- /dev/null +++ b/src/client.coffee @@ -0,0 +1,69 @@ +express = require 'express' +serveStatic = require 'serve-static' +staticFavicon = require 'static-favicon' + +###* + * Creates a new client, static file host. + * @param {object} config Dependency injection of the configuration values. + * See `/config.coffee`. + * @param {object} opts Holds the options for this client. + * - html: the location of local files to serve. + * - port: (optional) the port to listen on directly. + * @return {Express} The created client express instance. +### +module.exports = (config, opts) -> + + # Favicon middleware. + favicon = staticFavicon "#{opts.html}/icons/favicon.ico" + + # Instantiate client application-server. + client = express() + + # Content Security Policy. + client.use (req, res, next) -> + + # Arrays of whitelisted domains for styles and fonts. + styleDomains = ['fonts.googleapis.com', 'netdna.bootstrapcdn.com'] + fontDomains = ['themes.googleusercontent.com', 'netdna.bootstrapcdn.com', 'fonts.gstatic.com'] + + # Chrome implemented CSP properly. + if /Chrome/.test req.headers['user-agent'] + styles = styleDomains.join(' ') + fonts = fontDomains.join(' ') + + # Others didn't. + else + styles = + styleDomains.map((domain) -> "http://#{domain}").join(' ') + ' ' + + styleDomains.map((domain) -> "https://#{domain}").join(' ') + fonts = + fontDomains.map((domain) -> "http://#{domain}").join(' ') + ' ' + + fontDomains.map((domain) -> "https://#{domain}").join(' ') + + # Send the CSP header. + res.header 'Content-Security-Policy', [ + "default-src 'none'" + "style-src 'self' 'unsafe-inline' " + styles + "font-src 'self' " + fonts + "script-src 'self' 'unsafe-eval'" + "img-src 'self'" + "connect-src api.#{config.domainName}" + ( + if config.debug then " ws://localhost:9485/ localhost:#{config.serverPort}" else '' + ) + ].join(';\n') + + # Next middleware. + next() + + # Set up shared middleware. + client.use favicon + + # Set up routing to serve up static files from the /public folder, or index.html. + client.use serveStatic opts.html + client.get '*', (req, res) -> res.sendFile "#{opts.html}/index.html" + + # Start listening on the client port, if any. + client.listen opts.port if opts.port + + # Return the express instance. + return client diff --git a/src/config.coffee b/src/config.coffee index 2ffe1ca..8d3588a 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -1,12 +1,17 @@ module.exports = + debug: false + logLevel: ['debug', 'info', 'error'][1] + domainName: 'webistor.net' + timezone: 'Europe/Amsterdam' clientPort: null serverPort: null - debug: false - logLevel: ['debug', 'info', 'error'][0] - timezone: 'Europe/Amsterdam' - publicHtml: '/absolute/path/to/public' - whitelist: ['localhost', 'webistor.net', 'www.webistor.net'] + + stableHtml: '/home/node/webistor/app-stable/public/' + newHtml: '/home/node/webistor/app-new/public/' + + # For Content Security Policy + whitelist: ['localhost', 'webistor.net', 'www.webistor.net', 'new.webistor.net'] # Database settings. database: @@ -34,8 +39,9 @@ module.exports = # An array of usernames which users are not allowed to take. reservedUserNames: ['me'] - # Daemon settings. - daemon: + # Proxy settings. + proxy: + redirectToHttps: true enabled: true httpPort: 80 adminPort: 3002 diff --git a/src/controllers/feedback-controller.coffee b/src/controllers/feedback-controller.coffee new file mode 100644 index 0000000..d1cd2b0 --- /dev/null +++ b/src/controllers/feedback-controller.coffee @@ -0,0 +1,29 @@ +Controller = require './base/controller' +Promise = require 'bluebird' +ServerError = require './base/server-error' +config = require '../config' +log = require 'node-logging' +Mail = require '../classes/mail' + +module.exports = class FeedbackController extends Controller + + ###* + * Send user feedback to hello@webistor.net. + * + * @param {http.IncomingMessage} req The Express request object. Required fields: + * `req.body.subject`: The feedback subject line. + * + * @return {Promise} A Promise which resolves once the response is generated. + ### + contribution: (req, res) -> + + # Ensure a subject was given. + throw new ServerError 400, "No subject given." unless req.body.subject + + # Send an email to hello@webistor.net. + return new Mail() + .from req.body.email + .to "team@tuxion.nl" + .subject "Webistor Feedback - #{req.body.subject}" + .template "feedback/contribution", {req} + .send() diff --git a/src/controllers/session-controller.coffee b/src/controllers/session-controller.coffee index 216c479..aeb6a52 100644 --- a/src/controllers/session-controller.coffee +++ b/src/controllers/session-controller.coffee @@ -384,15 +384,15 @@ module.exports = class SessionController extends Controller auth = @passwordTokenAuth[user.id] or= @authFactory.create user, => delete @passwordTokenAuth[user.id] # Generate a token with which they can reset their password. - token = auth.generateToken() - - # Send them the token. - (new Mail) - .to user - .from "Webistor Team " - .subject "Your password reset ticket" - .template "account/password-token", {user, token} - .send() + auth.generateToken().then (token) -> + + # Send them the token. + (new Mail) + .to user + .from "Webistor Team " + .subject "Your password reset ticket" + .template "account/password-token", {user, token} + .send() # Done. .return "Mail sent." diff --git a/src/index.coffee b/src/index.coffee index e8b52e5..92aa2cc 100644 --- a/src/index.coffee +++ b/src/index.coffee @@ -1,236 +1,64 @@ -http = require 'http' -express = require 'express' -Promise = require 'bluebird' -config = require './config' log = require 'node-logging' -AuthFactory = require './classes/auth-factory' -SessionController = require './controllers/session-controller' -EntryController = require './controllers/entry-controller' -TagController = require './controllers/tag-controller' -InvitationController = require './controllers/invitation-controller' -favicon = require 'static-favicon' -{json} = require 'body-parser' -session = require 'express-session' -cookie = require 'cookie-parser' -MongoStore = require 'express-session-mongo' -serveStatic = require 'serve-static' -countTagsTimesUsed = require './tasks/count-tags-times-used' +Promise = require 'bluebird' +countTagsTimesUsed = require './tasks/count-tags-times-used' +config = require './config' +client = require './client' +server = require './server' +proxy = require './proxy' ## ## SHARED ## -# Create shared middleware. -favicon = favicon "#{config.publicHtml}/icons/favicon.ico" - # Set up logging. # Promise.onPossiblyUnhandledRejection -> log.dbg 'Supressing PossiblyUnhandledRejection.' Promise.longStackTraces() if config.logLevel is 'debug' log.setLevel config.logLevel - ## -## CLIENT +## CLIENTS ## -# Instantiate client application-server. -client = express() - -# Content Security Policy. -client.use (req, res, next) -> - - # Arrays of whitelisted domains for styles and fonts. - styleDomains = ['fonts.googleapis.com', 'netdna.bootstrapcdn.com'] - fontDomains = ['themes.googleusercontent.com', 'netdna.bootstrapcdn.com', 'fonts.gstatic.com'] - - # Chrome implemented CSP properly. - if /Chrome/.test req.headers['user-agent'] - styles = styleDomains.join(' ') - fonts = fontDomains.join(' ') - - # Others didn't. - else - styles = - styleDomains.map((domain) -> "http://#{domain}").join(' ') + ' ' + - styleDomains.map((domain) -> "https://#{domain}").join(' ') - fonts = - fontDomains.map((domain) -> "http://#{domain}").join(' ') + ' ' + - fontDomains.map((domain) -> "https://#{domain}").join(' ') - - # Send the CSP header. - res.header 'Content-Security-Policy', [ - "default-src 'none'" - "style-src 'self' 'unsafe-inline' " + styles - "font-src 'self' " + fonts - "script-src 'self' 'unsafe-eval'" - "img-src 'self'" - "connect-src api.#{config.domainName}:#{config.daemon.httpPort}" + ( - if config.debug then " ws://localhost:9485/ localhost:#{config.serverPort}" else '' - ) - ].join(';\n') - - # Next middleware. - next() - -# Set up shared middleware. -client.use favicon - -# Set up routing to serve up static files from the /public folder, or index.html. -client.use serveStatic config.publicHtml -client.get '*', (req, res) -> res.sendFile "#{config.publicHtml}/index.html" - -# Start listening on the client port. -client.listen config.clientPort if config.clientPort +# Instantiate stable client application-server. +stableClient = client config, + html: config.stableHtml + port: config.clientPort +# Instantiate new client application-server, when in production mode. +unless config.debug + newClient = client config, + html: config.newHtml + port: false ## ## SERVER ## # Instantiate API server. -server = express() - -# Access control. -server.use (req, res, next) -> - return next() unless req.headers.origin? - originDomain = req.headers.origin.match(/^\w+:\/\/(.*?)([:\/].*?)?$/)[1] - if originDomain in config.whitelist - res.header 'Access-Control-Allow-Origin', req.headers.origin - res.header 'Access-Control-Allow-Credentials', 'true' - if req.method is 'OPTIONS' - res.header 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH' - res.header 'Access-Control-Allow-Headers', req.headers['access-control-request-headers'] - return res.end() - next() - -# Parse request body as JSON. -server.use json strict:true - -# Import database schemas and connect to the MongoDB. -server.db = require './schemas' -server.db.mongoose.connect "mongodb://#{config.database.host}/#{config.database.name}" - -# Set up cookie support. -server.use cookie() - -# Set up session support. -server.use session - name: 'session' - secret: config.authentication.secret - resave: true - saveUninitialized: false - store: new MongoStore - host: config.database.host - db: config.database.name - collection: 'sessions' - -# Instantiate controllers. -server.sessionController = new SessionController new AuthFactory -server.entryController = new EntryController -server.tagController = new TagController -server.invitationController = new InvitationController - -# Route: Set up user system routes. -server.get '/users/me', server.sessionController.getMiddleware 'getUser' -server.post '/users/me', server.sessionController.getMiddleware 'login' -server.delete '/users/me', server.sessionController.getMiddleware 'logout' -server.get '/session/loginCheck', server.sessionController.getMiddleware 'isLoggedIn' -server.post '/session/nameCheck', server.sessionController.getMiddleware 'usernameExists' -server.post '/password-reset', server.sessionController.getMiddleware 'sendPasswordToken' - -# TODO: Protect this request with anti-botting measures. -server.post '/users', server.sessionController.getMiddleware 'register' -server.post '/users/:id/password-reset', server.sessionController.getMiddleware 'resetPassword' - -# Shared middleware. -ensureLogin = server.sessionController.getMiddleware 'ensureLogin' -ensureOwnership = server.sessionController.getMiddleware 'ensureOwnership' - -# Route: Set up entry REST routes. -server.get '/entries', ensureLogin -server.get '/entries', server.entryController.getMiddleware 'search' -server.db.Entry.methods ['get', 'post', 'put', 'delete'] -server.db.Entry.before 'get', ensureOwnership -server.db.Entry.before 'post', ensureOwnership -server.db.Entry.before 'post', server.entryController.getMiddleware 'ensureUniqueURI' -server.db.Entry.before 'post', server.entryController.getMiddleware 'detectDirtyTags' -server.db.Entry.after 'post', server.entryController.getMiddleware 'cacheDirtyTags' -server.db.Entry.before 'put', ensureOwnership -server.db.Entry.before 'put', server.entryController.getMiddleware 'ensureUniqueURI' -server.db.Entry.before 'put', server.entryController.getMiddleware 'detectDirtyTags' -server.db.Entry.after 'put', server.entryController.getMiddleware 'cacheDirtyTags' -server.db.Entry.before 'delete', ensureOwnership -server.db.Entry.before 'delete', server.entryController.getMiddleware 'detectDirtyTags' -server.db.Entry.after 'delete', server.entryController.getMiddleware 'cacheDirtyTags' -server.db.Entry.register server, '/entries' - -# Route: Set up tag REST routes. -server.db.Tag.methods ['get', 'post', 'put', 'delete'] -server.db.Tag.before 'get', ensureOwnership -server.db.Tag.before 'get', server.entryController.getMiddleware 'updateDirtyTags' -server.db.Tag.before 'post', ensureOwnership -server.db.Tag.before 'put', ensureOwnership -server.db.Tag.before 'delete', ensureOwnership -server.db.Tag.register server, '/tags' -server.patch '/tags', ensureLogin -server.patch '/tags', server.tagController.getMiddleware 'patch' - -# Route: Set up invitation related routes. -server.get '/invitations/:token', server.invitationController.getMiddleware 'findByToken' -server.post '/invitations/request', server.invitationController.getMiddleware 'request' -server.post '/invitations', ensureLogin -server.post '/invitations', server.invitationController.getMiddleware 'invite' - -# Start listening on the server port. -server.listen config.serverPort if config.serverPort - +apiServer = server config, {} ## -## DAEMON +## Proxy ## -# Only perform daemon related setup if enabled. -if config.daemon?.enabled +# Only perform proxy related setup if enabled. +if config.proxy?.enabled # Better not have debug mode enabled past this point. log.err "WARNING: Ensure debug mode is disabled in a production environment." if config.debug - - # Create a simple "proxy" server which will forward requests made to the daemon port + + # Create a simple "proxy" server which will forward requests made to the proxy port # to the right express server. - proxy = http.createServer (req, res) -> - root = config.domainName - host = req.headers.host.split(':')[0] - switch host - when root - res.writeHead 301, Location: "http://www.#{root}#{req.url}" - res.end() - when "www.#{root}" then client arguments... - when "api.#{root}" then server arguments... - else - body = "Host #{host} not recognized. This might be due to bad server configuration." - res.writeHead 400, "Invalid host.", { - 'Content-Length': body.length - 'Content-Type': 'text/plain' - } - res.end body - - # Listen on the set http port. Downgrade process permissions once set up. - proxy.listen config.daemon.httpPort, -> - process.setgid config.daemon.gid - process.setuid config.daemon.uid - - # Create an admin server. - admin = express() - - # Bring the application to an idle state. - admin.get '/shutdown', (req, res) -> - server.db.disconnect client.close server.close proxy.close -> res.status(200).end() - - # Listen on admin port. - admin.listen config.daemon.adminPort - - + + opts = + stableClient: stableClient + apiServer: apiServer + + opts.newClient = newClient unless config.debug + + {proxyServer, adminServer} = proxy config, opts + ## ## MAIN ## @@ -240,10 +68,9 @@ countTagsTimesUsed() .catch (err) -> log.err "Failed counting tags: #{err}" .done -> log.dbg "Finished counting tags." - ## ## EXPORTS ## # Export our servers when in debug mode. -module.exports = {client, server, proxy, admin} if config.debug +module.exports = {stableClient, apiServer, proxyServer, adminServer} if config.debug diff --git a/src/proxy.coffee b/src/proxy.coffee new file mode 100644 index 0000000..df8a1ec --- /dev/null +++ b/src/proxy.coffee @@ -0,0 +1,64 @@ +http = require 'http' +express = require 'express' + +###* + * Creates a new proxy server, internal management requests. + * @param {object} config Dependency injection of the configuration values. + * See `/config.coffee`. + * @param {object} opts Holds the options for this client. + * - stableClient: the express stable client. + * - apiServer: the express server instance. + * - newClient: (optional) the express new client. + * @return {Express} The created proxy server. +### +module.exports = (config, opts) -> + + proxyServer = http.createServer (req, res) -> + root = config.domainName + host = req.headers.host.split(':')[0] + schema = if config.redirectToHttps then 'https' else 'http' + switch host + when root + res.writeHead 301, Location: "#{schema}://www.#{root}#{req.url}" + res.end() + when "www.#{root}" then opts.stableClient arguments... + when "new.#{root}" then opts.newClient arguments... + when "api.#{root}" then opts.apiServer arguments... + else + body = "Host #{host} not recognized. This might be due to bad server configuration." + res.writeHead 400, "Invalid host.", { + 'Content-Length': body.length + 'Content-Type': 'text/plain' + } + res.end body + + # Listen on the set http port. Downgrade process permissions once set up. + proxyServer.listen config.proxy.httpPort, -> + process.setgid config.proxy.gid + process.setuid config.proxy.uid + + # Create an admin server. + adminServer = express() + + # Bring the application to an idle state. + adminServer.get '/shutdown', (req, res) -> + + closeOrder = [] + closeOrder.push opts.server.db.disconnect + closeOrder.push opts.server.close + closeOrder.push opts.stableclient.close + closeOrder.push opts.newClient.close if opts.newClient? + closeOrder.push proxyServer.close + + closeMethod = -> + if closeOrder.length > 0 + closer = closeOrder.shift() + closer(closeMethod) + else + res.status(200).end() + + # Listen on admin port. + adminServer.listen config.proxy.adminPort + + # Return both servers. + return {proxyServer, adminServer} diff --git a/src/server.coffee b/src/server.coffee new file mode 100644 index 0000000..9700bc3 --- /dev/null +++ b/src/server.coffee @@ -0,0 +1,125 @@ +express = require 'express' +{json} = require 'body-parser' +cookie = require 'cookie-parser' +session = require 'express-session' +MongoStore = require 'express-session-mongo' + +EntryController = require './controllers/entry-controller' +FeedbackController = require './controllers/feedback-controller' +InvitationController = require './controllers/invitation-controller' +SessionController = require './controllers/session-controller' +TagController = require './controllers/tag-controller' + +AuthFactory = require './classes/auth-factory' + +###* + * Creates a new server, the central API. + * @param {object} config Dependency injection of the configuration values. + * See `/config.coffee`. + * @param {object} opts Holds the options for this server. Currently none. + * @return {Express} The created server express instance. +### +module.exports = (config, opts) -> + + # Instantiate API server. + server = express() + + # Access control. + server.use (req, res, next) -> + return next() unless req.headers.origin? + originDomain = req.headers.origin.match(/^\w+:\/\/(.*?)([:\/].*?)?$/)[1] + if originDomain in config.whitelist + res.header 'Access-Control-Allow-Origin', req.headers.origin + res.header 'Access-Control-Allow-Credentials', 'true' + if req.method is 'OPTIONS' + res.header 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH' + res.header 'Access-Control-Allow-Headers', req.headers['access-control-request-headers'] + return res.end() + next() + + # Parse request body as JSON. + server.use json strict:true + + # Import database schemas and connect to the MongoDB. + server.db = require './schemas' + server.db.mongoose.connect "mongodb://#{config.database.host}/#{config.database.name}" + + # Set up cookie support. + server.use cookie() + + # Set up session support. + server.use session + name: 'session' + secret: config.authentication.secret + resave: true + saveUninitialized: false + store: new MongoStore + host: config.database.host + db: config.database.name + server.tagController = new TagController + collection: 'sessions' + + # Instantiate controllers. + server.sessionController = new SessionController new AuthFactory + server.entryController = new EntryController + server.feedbackController = new FeedbackController + server.invitationController = new InvitationController + + # Route: Set up user system routes. + server.get '/users/me', server.sessionController.getMiddleware 'getUser' + server.post '/users/me', server.sessionController.getMiddleware 'login' + server.delete '/users/me', server.sessionController.getMiddleware 'logout' + server.get '/session/loginCheck', server.sessionController.getMiddleware 'isLoggedIn' + server.post '/session/nameCheck', server.sessionController.getMiddleware 'usernameExists' + server.post '/password-reset', server.sessionController.getMiddleware 'sendPasswordToken' + + # TODO: Protect this request with anti-botting measures. + server.post '/users', server.sessionController.getMiddleware 'register' + server.post '/users/:id/password-reset', server.sessionController.getMiddleware 'resetPassword' + + # Shared middleware. + ensureLogin = server.sessionController.getMiddleware 'ensureLogin' + ensureOwnership = server.sessionController.getMiddleware 'ensureOwnership' + + # Route: Set up entry REST routes. + server.get '/entries', ensureLogin + server.get '/entries', server.entryController.getMiddleware 'search' + server.db.Entry.methods ['get', 'post', 'put', 'delete'] + server.db.Entry.before 'get', ensureOwnership + server.db.Entry.before 'post', ensureOwnership + server.db.Entry.before 'post', server.entryController.getMiddleware 'ensureUniqueURI' + server.db.Entry.before 'post', server.entryController.getMiddleware 'detectDirtyTags' + server.db.Entry.after 'post', server.entryController.getMiddleware 'cacheDirtyTags' + server.db.Entry.before 'put', ensureOwnership + server.db.Entry.before 'put', server.entryController.getMiddleware 'ensureUniqueURI' + server.db.Entry.before 'put', server.entryController.getMiddleware 'detectDirtyTags' + server.db.Entry.after 'put', server.entryController.getMiddleware 'cacheDirtyTags' + server.db.Entry.before 'delete', ensureOwnership + server.db.Entry.before 'delete', server.entryController.getMiddleware 'detectDirtyTags' + server.db.Entry.after 'delete', server.entryController.getMiddleware 'cacheDirtyTags' + server.db.Entry.register server, '/entries' + + # Route: Set up tag REST routes. + server.db.Tag.methods ['get', 'post', 'put', 'delete'] + server.db.Tag.before 'get', ensureOwnership + server.db.Tag.before 'get', server.entryController.getMiddleware 'updateDirtyTags' + server.db.Tag.before 'post', ensureOwnership + server.db.Tag.before 'put', ensureOwnership + server.db.Tag.before 'delete', ensureOwnership + server.db.Tag.register server, '/tags' + server.patch '/tags', ensureLogin + server.patch '/tags', server.tagController.getMiddleware 'patch' + + # Route: Set up invitation related routes. + server.get '/invitations/:token', server.invitationController.getMiddleware 'findByToken' + server.post '/invitations/request', server.invitationController.getMiddleware 'request' + server.post '/invitations', ensureLogin + server.post '/invitations', server.invitationController.getMiddleware 'invite' + + # Route: Set up feedback related routes. + server.post '/feedback/contribution', server.feedbackController.getMiddleware 'contribution' + + # Start listening on the server port. + server.listen config.serverPort if config.serverPort + + return server diff --git a/templates/mail/feedback/contribution/html.hbs b/templates/mail/feedback/contribution/html.hbs new file mode 100644 index 0000000..6763c31 --- /dev/null +++ b/templates/mail/feedback/contribution/html.hbs @@ -0,0 +1,8 @@ + + + + +{{req.body.message}} + + + diff --git a/templates/mail/feedback/contribution/text.hbs b/templates/mail/feedback/contribution/text.hbs new file mode 100644 index 0000000..690c4d2 --- /dev/null +++ b/templates/mail/feedback/contribution/text.hbs @@ -0,0 +1 @@ +{{req.body.message}}