Implemented API endpoints. Update README.md. Implemented health endpoint. Updated dependencies

This commit is contained in:
Glenn de Haan
2024-03-22 17:13:37 +01:00
parent 611e8cc45c
commit 95989c212c
6 changed files with 338 additions and 124 deletions

106
README.md
View File

@@ -7,34 +7,39 @@ A small UniFi Voucher Site for simple voucher creation
![Screenshot](https://github.com/glenndehaan/unifi-voucher-site/assets/7496187/64f199e6-5e50-4d91-8731-1f970c1f1210)
## Structure
- ES6 Javascript
- Javascript
- ExpressJS
- Node UniFi
- Tailwind
- TailwindCSS
## Development Usage
- Install NodeJS 18.0 or higher.
- Install NodeJS 20.0 or higher.
- Run `npm ci` in the root folder
- Run `npm start` & `npm run tailwind` in the root folder
Then open up your favorite browser and go to http://localhost:3000/
## Build Usage
- Install NodeJS 18.0 or higher.
- Install NodeJS 20.0 or higher.
- Run `npm ci` in the root folder
- Run `npm run build` in the root folder
## Docker
- Code from master is build by Docker Hub
- Builds can be pulled by using this command: `docker pull glenndehaan/unifi-voucher-site`
- An example docker compose file can be found below:
```yaml
version: '3'
services:
app:
image: glenndehaan/unifi-voucher-site
ports:
- "8081:3000"
- "3000:3000"
environment:
# The IP address to your UniFi OS Console
UNIFI_IP: '192.168.1.1'
@@ -46,16 +51,103 @@ services:
UNIFI_PASSWORD: 'password'
# The UniFi Site ID
UNIFI_SITE_ID: 'default'
# The 'password' used to log in to this voucher portal
# The 'password' used to log in to the voucher portal and used as Bearer token for the API
SECURITY_CODE: '0000'
# Disables the login/authentication for the portal
# Disables the login/authentication for the portal and API
DISABLE_AUTH: 'false'
# Voucher Types, format: expiration in minutes (required),single-use or multi-use vouchers value - '0' is for multi-use - '1' is for single-use (optional),upload speed limit in kbps (optional),download speed limit in kbps (optional),data transfer limit in MB (optional)
# To skip a parameter just but nothing in between the comma's
# After a voucher type add a semicolon, after the semicolon you can start a new voucher type
VOUCHER_TYPES: '480,0,,,;'
# Enable/disable the Web UI
SERVICE_WEB: 'true'
# Enable/disable the API
SERVICE_API: 'false'
```
## Services
The project consists of two main services: Web and API.
### Web Service
The Web service is a user interface accessible through a web browser. It provides functionality for generating, viewing,
and managing vouchers within the UniFi system.
### API Service
The API service allows programmatic access to voucher-related functionalities. It is designed for developers who wish to
integrate voucher management into their applications or automate voucher generation processes. Below are the details of
the different endpoints available in the API:
#### Endpoints
1. **`/api`**
- Method: GET
- Description: Retrieves information about the API and its endpoints.
- Access: Open
- Response Example:
```json
{
"error": null,
"data": {
"message": "OK",
"endpoints": [
"/api",
"/api/types",
"/api/voucher/:type"
]
}
}
```
2. **`/api/types`**
- Method: GET
- Description: Retrieves a list of available voucher types supported by the system.
- Response Format: JSON
- Access: Open
- Response Example:
```json
{
"error": null,
"data": {
"message": "OK",
"types": [
{
"expiration": "480",
"usage": "0",
"raw": "480,0,,,"
}
]
}
}
```
3. **`/api/voucher/:type`**
- Method: GET
- Description: Generates a voucher of the specified type.
- Parameters:
- `type` (string): The type of voucher to generate.
- Response Format: JSON
- Access: Protected by Bearer Token
- Response Example:
```json
{
"error": null,
"data": {
"message": "OK",
"voucher": "12345-67890"
}
}
```
> This endpoint is protected by a security mechanism. To access it, users need to include a bearer token in the
request authorization header. The token must match the value of the `SECURITY_CODE` environment variable. Without
this token, access to the endpoint will be denied.
## License
MIT

View File

@@ -3,7 +3,7 @@ services:
app:
build: .
ports:
- "8081:3000"
- "3000:3000"
environment:
UNIFI_IP: '192.168.1.1'
UNIFI_PORT: 443
@@ -13,3 +13,5 @@ services:
SECURITY_CODE: '0000'
DISABLE_AUTH: 'false'
VOUCHER_TYPES: '480,0,,,;'
SERVICE_WEB: 'true'
SERVICE_API: 'false'

View File

@@ -6,26 +6,62 @@ const authDisabled = (process.env.DISABLE_AUTH === 'true') || false;
/**
* Verifies if a user is signed in
*
* @param req
* @param res
* @param next
* @type {{web: ((function(*, *, *): Promise<void>)|*), api: ((function(*, *, *): Promise<void>)|*)}}
*/
module.exports = async (req, res, next) => {
// Check if authentication is enabled
if(!authDisabled) {
// Check if user has an existing authorization cookie
if (!req.cookies.authorization) {
res.redirect(302, '/login');
return;
module.exports = {
/**
* Handle web authentication
*
* @param req
* @param res
* @param next
* @return {Promise<void>}
*/
web: async (req, res, next) => {
// Check if authentication is enabled
if(!authDisabled) {
// Check if user has an existing authorization cookie
if (!req.cookies.authorization) {
res.redirect(302, '/login');
return;
}
// Check if password is correct
const passwordCheck = req.cookies.authorization === (process.env.SECURITY_CODE || "0000");
if (!passwordCheck) {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'Password Invalid!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/login');
return;
}
}
// Check if password is correct
const passwordCheck = req.cookies.authorization === (process.env.SECURITY_CODE || "0000");
if (!passwordCheck) {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'Password Invalid!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/login');
return;
next();
},
/**
* Handle api authentication
*
* @param req
* @param res
* @param next
* @return {Promise<void>}
*/
api: async (req, res, next) => {
// Check if authentication is enabled
if(!authDisabled) {
// Check if user has sent the authorization header
if (!req.headers.authorization) {
res.status(401).send();
return;
}
// Check if password is correct
const passwordCheck = req.headers.authorization === `Bearer ${(process.env.SECURITY_CODE || "0000")}`;
if (!passwordCheck) {
res.status(403).send();
return;
}
}
next();
}
next();
}

30
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.18.3",
"express": "^4.19.1",
"multer": "^1.4.5-lts.1",
"node-unifi": "^2.5.1",
"tailwindcss": "^3.4.1",
@@ -478,9 +478,9 @@
}
},
"node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"engines": {
"node": ">= 0.6"
}
@@ -638,16 +638,16 @@
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
},
"node_modules/express": {
"version": "4.18.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.3.tgz",
"integrity": "sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==",
"version": "4.19.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.19.1.tgz",
"integrity": "sha512-K4w1/Bp7y8iSiVObmCrtq8Cs79XjJc/RU2YYkZQ7wpUu5ZyZ7MtPHkqoMz4pf+mgXfNvo2qft8D9OnrH2ABk9w==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.2",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.5.0",
"cookie": "0.6.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
@@ -2904,9 +2904,9 @@
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="
},
"cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
},
"cookie-parser": {
"version": "1.4.6",
@@ -3026,16 +3026,16 @@
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
},
"express": {
"version": "4.18.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.3.tgz",
"integrity": "sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==",
"version": "4.19.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.19.1.tgz",
"integrity": "sha512-K4w1/Bp7y8iSiVObmCrtq8Cs79XjJc/RU2YYkZQ7wpUu5ZyZ7MtPHkqoMz4pf+mgXfNvo2qft8D9OnrH2ABk9w==",
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.2",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.5.0",
"cookie": "0.6.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",

View File

@@ -10,14 +10,14 @@
"build": "tailwindcss -i ./css/style.css -o ./public/dist/style.css --minify"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"author": "Glenn de Haan",
"license": "MIT",
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.18.3",
"express": "^4.19.1",
"multer": "^1.4.5-lts.1",
"node-unifi": "^2.5.1",
"tailwindcss": "^3.4.1",

248
server.js
View File

@@ -1,6 +1,7 @@
/**
* Import base packages
*/
const os = require('os');
const express = require('express');
const multer = require('multer');
const cookieParser = require('cookie-parser');
@@ -30,6 +31,8 @@ const app = express();
*/
const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
const voucherTypes = types(process.env.VOUCHER_TYPES || '480,0,,,;');
const webService = (process.env.SERVICE_WEB === 'true') || true;
const apiService = (process.env.SERVICE_API === 'true') || false;
const authDisabled = (process.env.DISABLE_AUTH === 'true') || false;
/**
@@ -37,6 +40,12 @@ const authDisabled = (process.env.DISABLE_AUTH === 'true') || false;
*/
logo();
/**
* Log service status
*/
console.log(`[Service][Web] ${webService ? 'Enabled!' : 'Disabled!'}`);
console.log(`[Service][Api] ${apiService ? 'Enabled!' : 'Disabled!'}`);
/**
* Log voucher types
*/
@@ -48,7 +57,7 @@ voucherTypes.forEach((type, key) => {
/**
* Log auth status
*/
console.log(`[AUTH] ${authDisabled ? 'Disabled!' : 'Enabled!'}`);
console.log(`[Auth] ${authDisabled ? 'Disabled!' : 'Enabled!'}`);
/**
* Log controller
@@ -66,6 +75,19 @@ app.enable('trust proxy');
app.set('view engine', 'ejs');
app.set('views', `${__dirname}/template`);
/**
* GET /_health - Health check page
*/
app.get('/_health', (req, res) => {
res.json({
status: 'UP',
host: os.hostname(),
load: process.cpuUsage(),
mem: process.memoryUsage(),
uptime: process.uptime()
});
});
/**
* Enable multer
*/
@@ -108,89 +130,52 @@ app.use(express.static(`${__dirname}/public`));
* Configure routers
*/
app.get('/', (req, res) => {
res.redirect(302, '/voucher');
});
app.get('/login', (req, res) => {
// Check if authentication is disabled
if(authDisabled) {
if(webService) {
res.redirect(302, '/voucher');
return;
}
const hour = new Date().getHours();
const timeHeader = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
res.render('login', {
error: req.flashMessage.type === 'error',
error_text: req.flashMessage.message || '',
banner_image: process.env.BANNER_IMAGE || `/images/bg-${random(1, 10)}.jpg`,
app_header: timeHeader,
sid: req.sid
});
});
app.post('/login', async (req, res) => {
if(typeof req.body === "undefined") {
res.status(400).send();
return;
}
const passwordCheck = req.body.password === (process.env.SECURITY_CODE || "0000");
if(!passwordCheck) {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'Password Invalid!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/login');
return;
}
res.cookie('authorization', req.body.password, {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
});
app.get('/voucher', [authorization], async (req, res) => {
const hour = new Date().getHours();
const timeHeader = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
res.render('voucher', {
info: req.flashMessage.type === 'info',
info_text: req.flashMessage.message || '',
error: req.flashMessage.type === 'error',
error_text: req.flashMessage.message || '',
banner_image: process.env.BANNER_IMAGE || `/images/bg-${random(1, 10)}.jpg`,
app_header: timeHeader,
sid: req.sid,
timeConvert: time,
voucher_types: voucherTypes,
vouchers_popup: false
});
});
app.post('/voucher', [authorization], async (req, res) => {
if(typeof req.body === "undefined") {
res.status(400).send();
return;
}
const typeCheck = (process.env.VOUCHER_TYPES || '480,0,,,;').split(';').includes(req.body['voucher-type']);
if(!typeCheck) {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'Unknown Type!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
return;
}
// Create voucher code
const voucherCode = await unifi(types(req.body['voucher-type'], true)).catch((e) => {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
});
if(voucherCode) {
res.cookie('flashMessage', JSON.stringify({type: 'info', message: `Voucher Created: ${voucherCode}`}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
} else {
res.status(501).send();
}
});
app.get('/vouchers', [authorization], async (req, res) => {
const hour = new Date().getHours();
const timeHeader = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
const vouchers = await unifi('', false).catch((e) => {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
// Check if web service is enabled
if(webService) {
app.get('/login', (req, res) => {
// Check if authentication is disabled
if (authDisabled) {
res.redirect(302, '/voucher');
return;
}
const hour = new Date().getHours();
const timeHeader = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
res.render('login', {
error: req.flashMessage.type === 'error',
error_text: req.flashMessage.message || '',
banner_image: process.env.BANNER_IMAGE || `/images/bg-${random(1, 10)}.jpg`,
app_header: timeHeader,
sid: req.sid
});
});
app.post('/login', async (req, res) => {
if (typeof req.body === "undefined") {
res.status(400).send();
return;
}
const passwordCheck = req.body.password === (process.env.SECURITY_CODE || "0000");
if(!passwordCheck) {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'Password Invalid!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/login');
return;
}
res.cookie('authorization', req.body.password, {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
});
app.get('/voucher', [authorization.web], async (req, res) => {
const hour = new Date().getHours();
const timeHeader = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
if(vouchers) {
res.render('voucher', {
info: req.flashMessage.type === 'info',
info_text: req.flashMessage.message || '',
@@ -201,11 +186,110 @@ app.get('/vouchers', [authorization], async (req, res) => {
sid: req.sid,
timeConvert: time,
voucher_types: voucherTypes,
vouchers_popup: true,
vouchers
vouchers_popup: false
});
}
});
});
app.post('/voucher', [authorization.web], async (req, res) => {
if (typeof req.body === "undefined") {
res.status(400).send();
return;
}
const typeCheck = (process.env.VOUCHER_TYPES || '480,0,,,;').split(';').includes(req.body['voucher-type']);
if(!typeCheck) {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'Unknown Type!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
return;
}
// Create voucher code
const voucherCode = await unifi(types(req.body['voucher-type'], true)).catch((e) => {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
});
if(voucherCode) {
res.cookie('flashMessage', JSON.stringify({type: 'info', message: `Voucher Created: ${voucherCode}`}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
}
});
app.get('/vouchers', [authorization.web], async (req, res) => {
const hour = new Date().getHours();
const timeHeader = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
const vouchers = await unifi('', false).catch((e) => {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, '/voucher');
});
if (vouchers) {
res.render('voucher', {
info: req.flashMessage.type === 'info',
info_text: req.flashMessage.message || '',
error: req.flashMessage.type === 'error',
error_text: req.flashMessage.message || '',
banner_image: process.env.BANNER_IMAGE || `/images/bg-${random(1, 10)}.jpg`,
app_header: timeHeader,
sid: req.sid,
timeConvert: time,
voucher_types: voucherTypes,
vouchers_popup: true,
vouchers
});
}
});
}
if(apiService) {
app.get('/api', (req, res) => {
res.json({
error: null,
data: {
message: 'OK',
endpoints: [
'/api',
'/api/types',
'/api/voucher/:type'
]
}
});
});
app.get('/api/types', (req, res) => {
res.json({
error: null,
data: {
message: 'OK',
types: voucherTypes
}
});
});
app.get('/api/voucher/:type', [authorization.api], async (req, res) => {
const typeCheck = (process.env.VOUCHER_TYPES || '480,0,,,;').split(';').includes(req.params.type);
if(!typeCheck) {
res.json({
error: 'Unknown Type!',
data: {}
});
return;
}
// Create voucher code
const voucherCode = await unifi(types(req.params.type, true)).catch((e) => {
res.json({
error: e,
data: {}
});
});
if(voucherCode) {
res.json({
error: null,
data: {
message: 'OK',
voucher: voucherCode
}
});
}
});
}
/**
* Setup default 404 message