Updated Dependencies. Add axios override to patch CVE-2024-39338. Implemented email functionality. Updated README.md

This commit is contained in:
Glenn de Haan
2024-08-17 13:42:12 +02:00
parent 93e9037c6b
commit 46dd565379
9 changed files with 421 additions and 14 deletions

View File

@@ -65,6 +65,18 @@ services:
SERVICE_WEB: 'true'
# Enable/disable the API
SERVICE_API: 'false'
# SMTP Mail from email address (optional)
SMTP_FROM: ''
# SMTP Mail server hostname/ip (optional)
SMTP_HOST: ''
# SMTP Mail server port (optional)
SMTP_PORT: ''
# SMTP Mail use TLS? (optional)
SMTP_SECURE: 'false'
# SMTP Mail username (optional)
SMTP_USERNAME: ''
# SMTP Mail password (optional)
SMTP_PASSWORD: ''
# Sets the application Log Level (Valid Options: error|warn|info|debug|trace)
LOG_LEVEL: 'info'
```
@@ -230,6 +242,42 @@ The application will automatically format the voucher for 80mm paper width, ensu
![Example Print PDF](https://github.com/glenndehaan/unifi-voucher-site/assets/7496187/e86d0789-47d2-4630-a7fe-291a4fa9502f)
## Email Functionality
The UniFi Voucher Site includes a convenient email feature that allows you to send vouchers directly to users from the web interface. By configuring the SMTP settings, you can enable email sending and make it easy to distribute vouchers digitally.
### Configuration
To enable the email feature, you need to set the following environment variables with your SMTP server details:
```env
SMTP_FROM: ''
SMTP_HOST: ''
SMTP_PORT: ''
SMTP_SECURE: ''
SMTP_USERNAME: ''
SMTP_PASSWORD: ''
```
Heres what each variable represents:
- **`SMTP_FROM`**: The sender email address that will appear in the "From" field when users receive the voucher.
- **`SMTP_HOST`**: The hostname or IP address of your SMTP server (e.g., `smtp.example.com`).
- **`SMTP_PORT`**: The port used by your SMTP server (e.g., `587` for TLS or `465` for SSL).
- **`SMTP_SECURE`**: Set to `true` if your SMTP server requires a secure connection (SSL/TLS), or `false` if it does not.
- **`SMTP_USERNAME`**: The username for authenticating with your SMTP server.
- **`SMTP_PASSWORD`**: The password for authenticating with your SMTP server.
These settings allow the application to connect to your SMTP server and send emails on your behalf.
### Usage
Once the SMTP environment variables are configured, the email feature will be available within the UniFi Voucher Site interface. After generating a voucher, you will see an option to send the voucher via email. Enter the recipient's email address, and the application will send the voucher details directly to their inbox.
### Example Email
![Example Email](https://github.com/user-attachments/assets/45615db3-df76-48b0-ad30-05236e3754c1)
## Screenshots
### Login (Desktop)

View File

@@ -16,4 +16,10 @@ services:
VOUCHER_CUSTOM: 'true'
SERVICE_WEB: 'true'
SERVICE_API: 'false'
SMTP_FROM: ''
SMTP_HOST: ''
SMTP_PORT: ''
SMTP_SECURE: ''
SMTP_USERNAME: ''
SMTP_PASSWORD: ''
LOG_LEVEL: 'info'

65
modules/mail.js Normal file
View File

@@ -0,0 +1,65 @@
/**
* Import vendor modules
*/
const fs = require('fs');
const ejs = require('ejs');
const nodemailer = require('nodemailer');
/**
* Import own modules
*/
const log = require('./log');
/**
* Define global variables
*/
const smtpFrom = process.env.SMTP_FROM || '';
const smtpHost = process.env.SMTP_HOST || '';
const smtpPort = process.env.SMTP_PORT || 25;
const smtpSecure = process.env.SMTP_SECURE || false;
const smtpUsername = process.env.SMTP_USERNAME || '';
const smtpPassword = process.env.SMTP_PASSWORD || '';
/**
* Create nodemailer transport
*/
const transport = nodemailer.createTransport({
host: smtpHost,
port: parseInt(smtpPort),
secure: (smtpSecure === 'true'),
auth: {
user: smtpUsername,
pass: smtpPassword
}
});
/**
* Mail module functions
*/
module.exports = {
/**
* Sends an email via the nodemailer transport
*
* @param to
* @param voucher
* @return {Promise<unknown>}
*/
send: (to, voucher) => {
return new Promise(async (resolve) => {
await transport.sendMail({
from: smtpFrom,
to: to,
subject: 'Your WiFi Voucher',
text: `Hi there,\n\nSomeone generated a WiFi Voucher, please use this code when connecting:\n\n${voucher.code.slice(0, 5)}-${voucher.code.slice(5)}`,
html: ejs.render(fs.readFileSync(`${__dirname}/../template/email/voucher.ejs`, 'utf-8'), {
voucher
})
}).catch((e) => {
log.error(`[Mail] Error when sending mail`);
log.error(e);
});
resolve();
});
}
};

35
package-lock.json generated
View File

@@ -16,12 +16,13 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-unifi": "^2.5.1",
"nodemailer": "^6.9.14",
"pdfkit": "^0.15.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"nodemon": "^3.1.3",
"tailwindcss": "^3.4.4"
"nodemon": "^3.1.4",
"tailwindcss": "^3.4.10"
},
"engines": {
"node": ">=20.0.0"
@@ -401,11 +402,12 @@
}
},
"node_modules/axios": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
"integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
"integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.0",
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@@ -2103,10 +2105,19 @@
"node": ">=14.18.0 <15.0.0 || >=16.0.0"
}
},
"node_modules/nodemailer": {
"version": "6.9.14",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz",
"integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.3.tgz",
"integrity": "sha512-m4Vqs+APdKzDFpuaL9F9EVOF85+h070FnkHVEoU4+rmT6Vw0bmNl7s61VEkY/cJkL7RCv1p4urnUDUMrS5rk2w==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz",
"integrity": "sha512-wjPBbFhtpJwmIeY2yP7QF+UKzPfltVGtfce1g/bB15/8vCGZj8uxD62b/b9M9/WVgme0NZudpownKN+c0plXlQ==",
"dev": true,
"dependencies": {
"chokidar": "^3.5.2",
@@ -3044,9 +3055,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",
"integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==",
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
"integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
"dev": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -14,6 +14,11 @@
},
"author": "Glenn de Haan",
"license": "MIT",
"overrides": {
"node-unifi@^2.5.1": {
"axios": "1.7.4"
}
},
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.10",
@@ -22,11 +27,12 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-unifi": "^2.5.1",
"nodemailer": "^6.9.14",
"pdfkit": "^0.15.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"nodemon": "^3.1.3",
"tailwindcss": "^3.4.4"
"nodemon": "^3.1.4",
"tailwindcss": "^3.4.10"
}
}

View File

@@ -20,6 +20,7 @@ const types = require('./utils/types');
const time = require('./utils/time');
const bytes = require('./utils/bytes');
const unifi = require('./modules/unifi');
const mail = require('./modules/mail');
/**
* Import own middlewares
@@ -45,6 +46,9 @@ const voucherCustom = config('voucher_custom') !== null ? config('voucher_custom
const webService = process.env.SERVICE_WEB ? process.env.SERVICE_WEB !== 'false' : true;
const apiService = config('service_api') || (process.env.SERVICE_API === 'true') || false;
const authDisabled = (process.env.DISABLE_AUTH === 'true') || false;
const smtpFrom = process.env.SMTP_FROM || '';
const smtpHost = process.env.SMTP_HOST || '';
const smtpPort = process.env.SMTP_PORT || 25;
/**
* Output logo
@@ -86,6 +90,15 @@ voucherTypes.forEach((type, key) => {
*/
log.info(`[Auth] ${authDisabled ? 'Disabled!' : 'Enabled!'}`);
/**
* Log email status
*/
if(smtpFrom !== '' && smtpHost !== '' && smtpPort !== '') {
log.info(`[Email] Enabled! SMTP Server: ${smtpHost}:${smtpPort}`);
} else {
log.info(`[Email] Disabled!`);
}
/**
* Initialize JWT
*/
@@ -367,6 +380,46 @@ if(webService) {
});
}
});
app.get('/voucher/:id/email', [authorization.web], async (req, res) => {
const voucher = cache.vouchers.find((e) => {
return e._id === req.params.id;
});
if(voucher) {
res.render('components/email', {
baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '',
timeConvert: time,
bytesConvert: bytes,
voucher,
updated: cache.updated
});
} else {
res.status(404);
res.render('404', {
baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''
});
}
});
app.post('/voucher/:id/email', [authorization.web], async (req, res) => {
if (typeof req.body === "undefined") {
res.status(400).send();
return;
}
const voucher = cache.vouchers.find((e) => {
return e._id === req.params.id;
});
if(voucher) {
await mail.send(req.body.email, voucher);
res.cookie('flashMessage', JSON.stringify({type: 'info', message: 'Email has been send!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`);
} else {
res.status(404);
res.render('404', {
baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''
});
}
});
app.get('/vouchers', [authorization.web], async (req, res) => {
if(req.query.refresh) {
log.info('[Cache] Requesting UniFi Vouchers...');

View File

@@ -0,0 +1,40 @@
<div class="relative z-40" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16">
<div class="pointer-events-auto w-screen max-w-md">
<form id="voucher-forum" class="flex h-full flex-col divide-y divide-black/5 dark:divide-white/25 bg-white dark:bg-gray-900 shadow-xl" action="<%= baseUrl %>/voucher/<%= voucher._id %>/email" method="post" enctype="multipart/form-data">
<div class="h-0 flex-1 overflow-y-auto">
<div class="bg-sky-700 px-4 py-6 sm:px-6">
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold leading-6 text-white" id="slide-over-title">Email Voucher</h2>
</div>
<div class="mt-1">
<p class="text-sm text-sky-100">Get started by filling in the information below to email the selected voucher.</p>
</div>
</div>
<div class="flex flex-1 flex-col justify-between">
<div class="divide-y divide-black/5 dark:divide-white/25 px-4 sm:px-6">
<div class="space-y-6 pb-5 pt-6">
<div>
<label for="email" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Email</label>
<div class="mt-2">
<input type="email" id="email" name="email" required class="mt-2 block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Send</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>

150
template/email/voucher.ejs Normal file
View File

@@ -0,0 +1,150 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Your WiFi Voucher</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">Your WiFi Voucher</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f6f6f6; width: 100%;" width="100%" bgcolor="#f6f6f6">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; max-width: 580px; padding: 10px; width: 580px; margin: 0 auto;" width="580" valign="top">
<div class="content" style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px;">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;" width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;" valign="top">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" width="100%">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">
<p>
<center>
<img src="https://github.com/glenndehaan/unifi-voucher-site/blob/master/public/images/icon/logo_192x192.png?raw=true" height="75px"/>
<br/>
<h1 style="font-weight: 400; font-size: 1.75rem; line-height: 1.2;">WiFi Voucher</h1>
</center>
</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">Hi there,</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">Someone generated a WiFi Voucher, please use this code when connecting:</p>
<p style="font-family: sans-serif; font-size: 24px; font-weight: bold; margin: 0; margin-bottom: 15px;"><%= voucher.code.slice(0, 5) %>-<%= voucher.code.slice(5) %></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" width="100%">
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;" valign="top" align="center">
Powered by <a href="https://github.com/glenndehaan/unifi-voucher-site" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">UniFi Voucher Site</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@@ -187,6 +187,14 @@
<path d="M10.5 10.5a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963 5.23 5.23 0 0 0-3.434-1.279h-1.875a.375.375 0 0 1-.375-.375V10.5Z" />
</svg>
</button>
<button type="button" data-id="<%= voucher._id %>" class="voucher-email relative rounded-full p-1 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white">
<span class="absolute -inset-1.5"></span>
<span class="sr-only">Email Voucher Code</span>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z" />
<path d="M22.5 6.908V6.75a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3v.158l9.714 5.978a1.5 1.5 0 0 0 1.572 0L22.5 6.908Z" />
</svg>
</button>
<a href="<%= baseUrl %>/voucher/<%= voucher._id %>/print" type="button" class="relative rounded-full p-1 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white">
<span class="absolute -inset-1.5"></span>
<span class="sr-only">Print Voucher Code</span>
@@ -208,6 +216,7 @@
</div>
<div id="detail-dialog"></div>
<div id="email-dialog"></div>
<div id="create-dialog" class="hidden relative z-40" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
@@ -367,6 +376,7 @@
<script type="application/javascript">
const createDialog = document.querySelector('#create-dialog');
const detailDialog = document.querySelector('#detail-dialog');
const emailDialog = document.querySelector('#email-dialog');
const createForum = document.querySelector("#voucher-forum");
const voucherTypeField = document.querySelector('#voucher-type');
const customVoucherFields = document.querySelectorAll('.custom-voucher-field');
@@ -381,12 +391,18 @@
const spinnerList = document.querySelector("#spinner-list");
const copyNotification = document.querySelector("#copy-notification");
const vouchers = document.querySelectorAll('.voucher');
const vouchersEmail = document.querySelectorAll('.voucher-email');
const clearDetailDialog = () => {
document.querySelector('#close').removeEventListener('click', clearDetailDialog);
detailDialog.innerHTML = '';
};
const clearEmailDialog = () => {
document.querySelector('#close').removeEventListener('click', clearEmailDialog);
emailDialog.innerHTML = '';
};
createButtonHeader.addEventListener('click', () => {
createDialog.classList.remove('hidden');
});
@@ -446,6 +462,18 @@
}
});
});
vouchersEmail.forEach((el) => {
el.addEventListener('click', async () => {
const htmlRes = await fetch(`<%= baseUrl %>/voucher/${el.dataset.id}/email`);
if(htmlRes.status === 200 && !htmlRes.redirected) {
emailDialog.innerHTML = await htmlRes.text();
document.querySelector('#close').addEventListener('click', clearEmailDialog);
} else {
window.location.reload();
}
});
});
</script>
</body>
</html>