Rebuild application for UniFi OS, Network v7

This commit is contained in:
Glenn de Haan
2022-10-06 17:33:58 +02:00
parent 22162db21a
commit ead4e927c1
55 changed files with 3256 additions and 9448 deletions

32
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
# When the workflow succeeds the package will be published to docker hub
name: Publish Docker Image
on:
push:
branches:
- 'master'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: auth/.
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x,linux/386,linux/arm/v7,linux/arm/v6
push: true
tags: glenndehaan/unifi-voucher-site:latest

View File

@@ -1,19 +1,40 @@
FROM alpine:3.13 #
# Define OS
#
FROM alpine:3.16
#
# Basic OS management
#
# Install packages # Install packages
RUN apk add --no-cache nginx nodejs npm RUN apk add --no-cache nodejs npm
#
# Require app
#
# Create app directory # Create app directory
WORKDIR /usr/src/app WORKDIR /app
# Bundle app source # Bundle app source
COPY . . COPY . .
# Install dependencies
RUN npm ci --only=production
# Create production build # Create production build
RUN npm ci --only=production && npm run build RUN npm run build
#
# Setup app
#
# Expose app # Expose app
EXPOSE 3001 EXPOSE 3000
# Set node env
ENV NODE_ENV=production
# Run app # Run app
CMD ["node", "/usr/src/app/app/server.js"] CMD ["node", "/app/server.js"]

View File

@@ -2,7 +2,7 @@
A small UniFi Voucher Site for simple voucher creation A small UniFi Voucher Site for simple voucher creation
[![Build Status](https://img.shields.io/docker/cloud/build/glenndehaan/unifi-voucher-site.svg)](https://hub.docker.com/r/glenndehaan/unifi-voucher-site) [![Build Status](https://img.shields.io/docker/cloud/automated/glenndehaan/unifi-voucher-site.svg)](https://hub.docker.com/r/glenndehaan/unifi-voucher-site) [![Image Size](https://img.shields.io/docker/image-size/glenndehaan/unifi-voucher-site)](https://hub.docker.com/r/glenndehaan/unifi-voucher-site)
## Structure ## Structure
- ES6 Javascript - ES6 Javascript
@@ -11,14 +11,14 @@ A small UniFi Voucher Site for simple voucher creation
- Webpack - Webpack
## Development Usage ## Development Usage
- Install NodeJS 8.0 or higher. - Install NodeJS 16.0 or higher.
- Run `npm ci` in the root folder - Run `npm ci` in the root folder
- Run `npm start` in the root folder - Run `npm start` in the root folder
Then open up your favorite browser and go to http://localhost:3001/ Then open up your favorite browser and go to http://localhost:3000/
## Build Usage ## Build Usage
- Install NodeJS 8.0 or higher. - Install NodeJS 16.0 or higher.
- Run `npm ci` in the root folder - Run `npm ci` in the root folder
- Run `npm run build` in the root folder - Run `npm run build` in the root folder

View File

@@ -1,66 +0,0 @@
const webpack = require('webpack');
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
//Get path so every environment works
const projectPath = path.resolve(__dirname);
//Environment check depending on call from
const env = process.env.NODE_ENV || 'development';
//Define all the global config for both production & dev
module.exports = {
entry: {
main: [
projectPath + '/../public/js/main.js',
projectPath + '/../public/scss/style.scss'
]
},
output: {
path: projectPath + '/../public/dist/',
filename: '[name].[fullhash:6].js',
publicPath: ''
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
require.resolve('@babel/preset-env')
]
}
}
},
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader?url=false',
'sass-loader'
]
}
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(env)
}
}),
new MiniCssExtractPlugin({
filename: '[name].[fullhash:6].css'
}),
new WebpackManifestPlugin({
fileName: 'rev-manifest.json'
})
],
resolve: {
extensions: ['.js'],
modules: [path.join(__dirname, '../node_modules')]
}
};

View File

@@ -1,22 +0,0 @@
/**
* General config
*/
module.exports = {
application: {
name: "UniFi Voucher",
env: " (local)",
basePath: "/",
port: 3001,
bind: "0.0.0.0"
},
security: {
code: process.env.SECURITY_CODE || "0000" // <- Only 4 digits
},
unifi: {
ip: process.env.UNIFI_IP || '192.168.1.1',
port: process.env.UNIFI_PORT || 8443,
username: process.env.UNIFI_USERNAME || 'admin',
password: process.env.UNIFI_PASSWORD || 'password',
siteID: process.env.UNIFI_SITE_ID || 'default'
}
};

View File

@@ -1,38 +0,0 @@
const config = require("../config/config");
const assets = require("../modules/Assets");
class BaseController {
constructor() {
this.baseConfig = {
config: config,
protocol: '',
hostname: '',
baseUrl: '',
assets: {
js: false,
css: false
}
}
}
/**
* Returns the complete config base + page specific
*
* @param request
* @param pageSpecificConfig
*/
mergePageConfig(request, pageSpecificConfig) {
const manifest = assets();
this.baseConfig.hostname = request.get('host');
this.baseConfig.protocol = request.protocol;
this.baseConfig.baseUrl = `${request.protocol}://${request.get('host')}${config.application.basePath}`;
this.baseConfig.assets.js = manifest["main.js"];
this.baseConfig.assets.css = manifest["main.css"];
return Object.assign(this.baseConfig, pageSpecificConfig);
}
}
module.exports = BaseController;

View File

@@ -1,74 +0,0 @@
const baseController = require('./BaseController');
class IndexController extends baseController {
/**
* Renders the Home page
*
* @param req
* @param res
*/
indexAction(req, res) {
res.render('index', this.mergePageConfig(req, {
template: 'index/index',
pageTitle: 'Home'
}));
}
/**
* Renders the 404 page
*
* @param req
* @param res
*/
notFoundAction(req, res) {
res.render('index', this.mergePageConfig(req, {
template: 'general/notfound',
pageTitle: 'Not Found'
}));
}
/**
* Renders the old browser page (Fallback page)
*
* @param req
* @param res
*/
oldBrowserAction(req, res) {
res.render('index', this.mergePageConfig(req, {
template: 'general/oldbrowser',
pageTitle: 'Old Browser'
}));
}
/**
* Renders the sitemap.xml
*
* @param req
* @param res
* @param routes
*/
siteMapAction(req, res, routes) {
res.type("application/xml");
res.render('general/sitemap', this.mergePageConfig(req, {
template: false,
pageTitle: false,
routes: routes
}));
}
/**
* Renders the robots.txt
*
* @param req
* @param res
*/
robotsAction(req, res) {
res.type("text/plain");
res.render('general/robots', this.mergePageConfig(req, {
template: false,
pageTitle: false
}));
}
}
module.exports = new IndexController();

View File

@@ -1,18 +0,0 @@
/**
* Import vendor modules
*/
const fs = require("fs");
/**
* Define public path
*/
const path = `${__dirname}/../../public/dist`;
/**
* Return the manifest
*
* @return {any}
*/
module.exports = () => {
return JSON.parse(fs.existsSync(path) ? fs.readFileSync(`${path}/rev-manifest.json`) : "{}");
};

View File

@@ -1,148 +0,0 @@
/**
* Import vendor packages
*/
const { v4: uuidv4 } = require('uuid');
/**
* Import own packages
*/
const config = require('../config/config');
const unifi = require('./UniFi');
/**
* Socket
*/
class Socket {
/**
* Constructor
*
* @param server
*/
constructor(server) {
this.io = require('socket.io')(server);
this.authenticatedUsers = [];
this.init();
// Cleanup after 30 minutes
setInterval(() => this.cleanup(), 30 * 60 * 1000);
}
/**
* Init the socket connection
*/
init() {
this.io.on('connection', (socket) => {
/**
* Triggered when a socket disconnects
*/
socket.on('disconnect', () => {
console.log(`[SOCKET] Client disconnected! ID: ${socket.id}`);
});
/**
* Client requests a uuid
*/
socket.on('uuid', () => {
const uuid = uuidv4();
this.authenticatedUsers[uuid] = false;
socket.emit('uuid', {
uuid: uuid
});
console.log(`[SOCKET][${socket.id}] Client requested a UUID! UUID: ${uuid}`);
});
/**
* Client auth check
*/
socket.on('auth', (data) => {
if(typeof this.authenticatedUsers[data.uuid] !== "undefined") {
if(config.security.code === data.code) {
this.authenticatedUsers[data.uuid] = true;
socket.emit('auth', {
success: true
});
console.log(`[SOCKET][${socket.id}] Client auth: OK`);
} else {
socket.emit('auth', {
success: false
});
console.log(`[SOCKET][${socket.id}] Client auth: FAILED. Invalid code!`);
}
} else {
socket.emit('auth', {
success: false
});
console.log(`[SOCKET][${socket.id}] Client auth: FAILED. Invalid UUID!`);
}
});
/**
* Create voucher method
*/
socket.on('voucher', (data) => {
if(typeof this.authenticatedUsers[data.uuid] !== "undefined") {
if(this.authenticatedUsers[data.uuid]) {
unifi((voucher) => {
if(voucher !== false) {
socket.emit('voucher', {
success: true,
voucher: voucher
});
console.log(`[SOCKET][${socket.id}] Client voucher: OK. Voucher: ${voucher}!`);
} else {
socket.emit('voucher', {
success: false,
voucher: ''
});
console.log(`[SOCKET][${socket.id}] Client voucher: FAILED. UniFi Error!`);
}
});
} else {
socket.emit('voucher', {
success: false,
voucher: ''
});
console.log(`[SOCKET][${socket.id}] Client voucher: FAILED. Not authenticated!`);
}
} else {
socket.emit('voucher', {
success: false,
voucher: ''
});
console.log(`[SOCKET][${socket.id}] Client voucher: FAILED. Invalid UUID!`);
}
});
console.log(`[SOCKET] New client connected! ID: ${socket.id}`);
});
/**
* Start listening on the right port/host for the Socket.IO server
*/
console.log('[SYSTEM] Socket.IO started !');
}
/**
* Clear the authenticated users after 30 minutes
*/
cleanup() {
this.authenticatedUsers = [];
console.log('[SYSTEM] Cleanup authenticatedUsers complete!');
}
}
/**
* Export the socket class
*
* @type {Socket}
*/
module.exports = Socket;

View File

@@ -1,54 +0,0 @@
/**
* Import vendor modules
*/
const unifi = require('node-unifi');
/**
* Import own modules
*/
const config = require('../config/config');
/**
* Create new UniFi controller object
*
* @type {Controller}
*/
const controller = new unifi.Controller(config.unifi.ip, config.unifi.port);
/**
* Exports the UniFi voucher function
*
* @param callback
*/
module.exports = (callback) => {
controller.login(config.unifi.username, config.unifi.password, (err) => {
if(err) {
console.log(`[UNIFI] Error: ${err}`);
callback(false);
return;
}
// CREATE VOUCHER
controller.createVouchers(config.unifi.siteID, 480, (err, voucher_data) => {
if(err) {
console.log(`[UNIFI] Error: ${err}`);
callback(false);
return;
}
// GET VOUCHER CODE
controller.getVouchers(config.unifi.siteID, (err, voucher_data_complete) => {
if(err) {
console.log(`[UNIFI] Error: ${err}`);
callback(false);
return;
}
const voucher = `${[voucher_data_complete[0][0].code.slice(0, 5), '-', voucher_data_complete[0][0].code.slice(5)].join('')}`;
callback(voucher);
controller.logout();
}, voucher_data[0][0].create_time);
}, 1, 1);
});
};

View File

@@ -1,62 +0,0 @@
/**
* Import base packages
*/
const express = require('express');
const app = express();
/**
* Import own packages
*/
const config = require('./config/config');
const socket = require('./modules/Socket');
const IndexController = require('./controllers/IndexController');
/**
* Set template engine
*/
app.set('view engine', 'ejs');
app.set('views', `${__dirname}/views`);
/**
* Trust proxy
*/
app.enable('trust proxy');
/**
* Serve static public dir
*/
app.use(express.static(`${__dirname}/../public`));
/**
* Configure routes
*/
app.get('/', (req, res) => {
IndexController.indexAction(req, res);
});
/**
* Disable powered by header for security reasons
*/
app.disable('x-powered-by');
/**
* Start listening on port
*/
const server = app.listen(config.application.port, config.application.bind, () => {
console.log(`[NODE] App is running on: ${config.application.bind}:${config.application.port}`);
});
/**
* Init socket connection
*/
global.socket = new socket(server);
/**
* Handle nodemon shutdown
*/
process.once('SIGUSR2', () => {
server.close(() => {
console.log(`[NODE] Express exited! Port ${config.application.port} is now free!`);
process.kill(process.pid, 'SIGUSR2');
});
});

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('partials/head') %>
</head>
<body>
<%- include('partials/preloader') %>
<main>
<%- include(template) %>
</main>
<%- include('partials/footer') %>
<script>
var expressConfig = {protocol: '<%= protocol %>', hostname: '<%= hostname %>', baseUrl: '<%= baseUrl %>'};
</script>
<script src="/dist/<%= assets.js %>" type="application/javascript"></script>
</body>
</html>

View File

@@ -1,33 +0,0 @@
<div id="particles"></div>
<div id="container">
<div id="center">
<img src="/images/unifi-icon.png" alt="UniFi Icon" width="100px" height="100px" /><br/>
<h2><%= config.application.name %></h2>
<div id="sign-in">
<h3>Access Code</h3>
<input type="number" class="access-fields" title="number-1" maxlength="1">
<input type="number" class="access-fields" title="number-2" maxlength="1">
<input type="number" class="access-fields" title="number-3" maxlength="1">
<input type="number" class="access-fields" title="number-4" maxlength="1">
</div>
<div id="voucher" class="hidden">
<h3>Voucher:</h3>
<h4>XXXXX-XXXXX</h4>
<p>
This voucher is valid for one day on one device.
</p>
<button>
Generate
</button>
</div>
<div id="error" class="hidden">
<h3>Woops</h3>
<p>
We are having troubles connecting to the server... please try again later.
</p>
</div>
</div>
</div>

View File

@@ -1,3 +0,0 @@
<footer>
<p>UniFi Voucher | <a href="https://github.com/glenndehaan/unifi-voucher-site" rel="noreferrer noopener">GitHub</a></p>
</footer>

View File

@@ -1,81 +0,0 @@
<title><%= pageTitle %> | <%= config.application.name %> <%= config.application.env %></title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="UniFi Voucher">
<meta name="author" content="Glenn de Haan">
<meta property="og:title" content="<%= config.application.name %>"/>
<meta property="og:type" content="website"/>
<meta property="og:url" content="<%= baseUrl %>"/>
<meta property="og:description" content="UniFi Voucher"/>
<link rel="alternate" href="<%= baseUrl %>" hreflang="en"/>
<link rel="canonical" href="<%= baseUrl %>"/>
<script type='application/ld+json'>
{
"@context": "http://schema.org",
"@type": "WebSite",
"name": "<%= config.application.name %>",
"url": "<%= baseUrl %>"
}
</script>
<link rel="manifest" href="/manifest.json">
<link rel="shortcut icon" href="/images/favicon.ico">
<link rel="apple-touch-icon" href="/images/icon/logo_256x256.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1875b6">
<style>
#preloader {
position: absolute;
width: 100%;
height: 100%;
background-color: #1875b6;
z-index: 100;
}
.loader {
position: absolute;
top: 50%;
left: 42%;
width: 75px;
height: 75px;
background-color: #fff;
border-radius: 100%;
-webkit-animation: scaleout 1.0s infinite ease-in-out;
animation: scaleout 1.0s infinite ease-in-out;
}
@-webkit-keyframes scaleout {
0% {
-webkit-transform: scale(0)
}
100% {
-webkit-transform: scale(1.0);
opacity: 0;
}
}
@keyframes scaleout {
0% {
-webkit-transform: scale(0);
transform: scale(0);
}
100% {
-webkit-transform: scale(1.0);
transform: scale(1.0);
opacity: 0;
}
}
</style>
<link href="/dist/<%= assets.css %>" rel="stylesheet" type="text/css"/>
<link rel="preload" href="/fonts/text-security-disc.woff" as="font">

View File

@@ -1,3 +0,0 @@
<div id="preloader">
<div class="loader"></div>
</div>

28
css/style.css Normal file
View File

@@ -0,0 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.divider {
display: flex;
align-items: center;
text-align: center;
@apply mt-6;
}
/* To show the lines on right
and left sides of the text */
.divider::after,
.divider::before {
content: "";
flex: 1;
@apply border;
}
/* Space on left and right sides of text */
.divider:not(:empty)::before {
margin-right: 1em;
}
.divider:not(:empty)::after {
margin-left: 1em;
}

View File

@@ -3,10 +3,10 @@ services:
app: app:
build: . build: .
ports: ports:
- "8081:3001" - "8081:3000"
environment: environment:
UNIFI_IP: '192.168.1.1' UNIFI_IP: '192.168.1.1'
UNIFI_PORT: 8443 UNIFI_PORT: 443
UNIFI_USERNAME: 'admin' UNIFI_USERNAME: 'admin'
UNIFI_PASSWORD: 'password' UNIFI_PASSWORD: 'password'
UNIFI_SITE_ID: 'default' UNIFI_SITE_ID: 'default'

56
modules/unifi.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* Import vendor modules
*/
const unifi = require('node-unifi');
/**
* Import own modules
*/
const config = {
unifi: {
ip: process.env.UNIFI_IP || '192.168.1.1',
port: process.env.UNIFI_PORT || 443,
username: process.env.UNIFI_USERNAME || 'admin',
password: process.env.UNIFI_PASSWORD || 'password',
siteID: process.env.UNIFI_SITE_ID || 'default'
}
};
/**
* Create new UniFi controller object
*
* @type {Controller}
*/
const controller = new unifi.Controller({host: config.unifi.ip, port: config.unifi.port, site: config.unifi.siteID, sslverify: false});
/**
* Exports the UniFi voucher function
*
* @returns {Promise<unknown>}
*/
module.exports = () => {
return new Promise((resolve) => {
controller.login(config.unifi.username, config.unifi.password).then(() => {
controller.getSitesStats().then(() => {
controller.createVouchers(480).then((voucher_data) => {
controller.getVouchers(voucher_data[0].create_time).then((voucher_data_complete) => {
const voucher = `${[voucher_data_complete[0].code.slice(0, 5), '-', voucher_data_complete[0].code.slice(5)].join('')}`;
resolve(voucher);
}).catch((e) => {
console.log(e);
process.exit(1);
});
}).catch((e) => {
console.log(e);
process.exit(1);
});
}).catch((e) => {
console.log(e);
process.exit(1);
});
}).catch((e) => {
console.log(e);
process.exit(1);
});
});
};

8442
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,76 +4,25 @@
"description": "NPM packages for unifi-voucher-site", "description": "NPM packages for unifi-voucher-site",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "concurrently --kill-others 'npm run frontend' 'npm run backend:dev'", "start": "node server.js",
"backend:start": "node ./app/server.js", "dev": "nodemon --watch . --ignore db.json --exec 'node server.js'",
"backend:dev": "nodemon -L --watch ./app ./app/server.js", "tailwind": "tailwindcss -i ./css/style.css -o ./public/dist/style.css --watch",
"prebuild": "rimraf ./public/dist", "build": "tailwindcss -i ./css/style.css -o ./public/dist/style.css --minify"
"build": "cross-env NODE_ENV=production webpack --mode production --config ./_scripts/webpack.config.js",
"prefrontend": "rimraf ./public/dist",
"frontend": "webpack --watch --mode development --config ./_scripts/webpack.config.js",
"lint": "eslint -c ./package.json ./app ./public/js"
}, },
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=16.0.0"
},
"eslintConfig": {
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
},
"env": {
"browser": true,
"node": true
},
"rules": {
"no-console": 0,
"comma-dangle": [
"error",
"never"
],
"indent": [
"error",
4
]
},
"globals": {
"site": false,
"expressConfig": false
},
"extends": [
"eslint:recommended"
]
}, },
"author": "Glenn de Haan", "author": "Glenn de Haan",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.12.10", "ejs": "^3.1.6",
"@babel/preset-env": "^7.12.11", "express": "^4.17.3",
"animejs": "^3.2.1", "multer": "^1.4.4",
"babel-core": "^6.26.3", "node-unifi": "^2.1.0",
"babel-loader": "^8.2.2", "tailwindcss": "^3.0.23",
"cross-env": "^7.0.3", "uuid": "^8.3.2"
"css-loader": "^5.0.1",
"ejs": "^3.1.5",
"express": "^4.17.1",
"gsap": "^3.6.0",
"mini-css-extract-plugin": "^1.3.4",
"mitt": "^2.1.0",
"node-sass": "^5.0.0",
"node-unifi": "^1.3.8",
"particles.js": "^2.0.0",
"rimraf": "^3.0.2",
"sass-loader": "^10.1.1",
"socket.io": "^3.1.0",
"socket.io-client": "^3.1.0",
"uuid": "^8.3.2",
"webpack": "^5.14.0",
"webpack-cli": "^4.3.1",
"webpack-manifest-plugin": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^5.3.0", "nodemon": "^2.0.15"
"eslint": "^7.17.0",
"nodemon": "^2.0.7"
} }
} }

Binary file not shown.

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Binary file not shown.

BIN
public/images/bg-1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

BIN
public/images/bg-2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

BIN
public/images/bg-3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

BIN
public/images/bg-4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

BIN
public/images/bg-5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

BIN
public/images/bg-6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

BIN
public/images/bg-7.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

BIN
public/images/bg-8.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

BIN
public/images/bg-9.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

View File

@@ -1,48 +0,0 @@
import mitt from 'mitt';
import settings from './settings';
/**
* Create global
*/
window.site = {};
site.modules = [];
site.events = mitt();
site.settings = settings;
/**
* Initialize the app
*/
function initialize() {
site.html = document.querySelector('html');
site.body = document.querySelector('body');
console.log('JS Start');
initializeModules();
}
/**
* Initialize the modules with all the components
*/
function initializeModules() {
for(let modulesItem = 0; modulesItem < settings.modules.length; modulesItem++) {
const module = settings.modules[modulesItem];
const moduleClass = require(`./modules/${module.group}/${module.module}`).default;
const domElements = document.querySelectorAll(module.el);
if (typeof moduleClass !== 'undefined' && domElements.length > 0) {
if(module.global) {
site.modules[module.module] = new moduleClass({
el: domElements
});
} else {
new moduleClass({
el: domElements
});
}
}
}
}
document.addEventListener("DOMContentLoaded", initialize);

View File

@@ -1,185 +0,0 @@
import io from 'socket.io-client';
import animejs from 'animejs';
export default class Socket {
constructor({el}) {
this.el = el;
this.socket = false;
this.uuid = false;
this.userSignedIn = false;
this.mainContainer = document.querySelector("#container");
this.signInContainer = document.querySelector("#sign-in");
this.voucherContainer = document.querySelector("#voucher");
this.errorContainer = document.querySelector("#error");
this.preloader = document.querySelector("#preloader");
document.querySelector("#voucher button").addEventListener("click", () => this.requestVoucher());
this.init();
}
/**
* Start the socket connection
*/
init() {
this.socket = io.connect(`//${expressConfig.hostname}`);
this.socket.on('connect', () => this.connect());
this.socket.on('disconnect', () => this.disconnect());
this.socket.on('error', () => this.error());
this.socket.on('uuid', (data) => this.setUuid(data));
this.socket.on('auth', (data) => this.auth(data));
this.socket.on('voucher', (data) => this.voucher(data));
}
/**
* Event when the socket is connected
*/
connect() {
console.log('[SOCKET] Connected!');
if (!this.uuid) {
console.log('[SOCKET] Requesting UUID!');
this.socket.emit('uuid');
}
animejs({
targets: this.mainContainer,
duration: 250,
opacity: [1, 0],
easing: 'linear',
complete: () => {
if (!this.userSignedIn) {
this.signInContainer.classList.remove("hidden");
} else {
this.voucherContainer.classList.remove("hidden");
}
this.errorContainer.classList.add("hidden");
animejs({
targets: this.mainContainer,
duration: 250,
opacity: [0, 1],
easing: 'linear',
});
}
});
}
/**
* Event when the socket disconnects
*/
disconnect() {
console.log('[SOCKET] Disconnected!');
animejs({
targets: this.mainContainer,
duration: 250,
opacity: [1, 0],
easing: 'linear',
complete: () => {
this.signInContainer.classList.add("hidden");
this.voucherContainer.classList.add("hidden");
this.errorContainer.classList.remove("hidden");
animejs({
targets: this.mainContainer,
duration: 250,
opacity: [0, 1],
easing: 'linear'
});
}
});
}
/**
* Event when the socket error's
*/
error() {
console.log('[SOCKET] Error!');
animejs({
targets: this.mainContainer,
duration: 250,
opacity: [1, 0],
easing: 'linear',
complete: () => {
this.signInContainer.classList.add("hidden");
this.voucherContainer.classList.add("hidden");
this.errorContainer.classList.remove("hidden");
animejs({
targets: this.mainContainer,
duration: 250,
opacity: [0, 1],
easing: 'linear'
});
}
});
}
/**
* Event for getting the UUID
*/
setUuid(data) {
this.uuid = data.uuid;
}
/**
* Event for getting the auth result
*/
auth(data) {
if (data.success) {
this.userSignedIn = true;
animejs({
targets: this.mainContainer,
duration: 250,
opacity: [1, 0],
easing: 'linear',
complete: () => {
site.modules.Signin.resetForm();
this.signInContainer.classList.add("hidden");
this.voucherContainer.classList.remove("hidden");
animejs({
targets: this.mainContainer,
duration: 250,
opacity: [0, 1],
easing: 'linear'
});
}
});
} else {
site.modules.Signin.invalidCode();
}
}
/**
* Request a guest voucher
*/
requestVoucher() {
if (this.userSignedIn) {
this.preloader.classList.remove("completed");
this.socket.emit('voucher', {
uuid: this.uuid
});
}
}
/**
* Process the voucher
*
* @param data
*/
voucher(data) {
this.preloader.classList.add("completed");
if (data.success) {
this.voucherContainer.querySelector("h4").innerHTML = data.voucher;
}
}
}

View File

@@ -1,5 +0,0 @@
export default class Index {
constructor({el}) {
this.el = el;
}
}

View File

@@ -1,17 +0,0 @@
import "particles.js";
export default class Particles {
constructor({el}) {
this.el = el;
this.init();
this.preloader = document.querySelector("#preloader");
}
init() {
window.particlesJS.load('particles', "/json/particles.json", () => {
console.log('particles.js Loaded!');
this.preloader.classList.add("completed");
});
}
}

View File

@@ -1,69 +0,0 @@
export default class Signin {
constructor({el}) {
this.el = el;
this.fields = document.querySelectorAll(".access-fields");
for(let field = 0; field < this.fields.length; field++) {
this.fields[field].addEventListener("keyup", () => this.moveOnMax(this.fields[field], field));
}
this.fields[0].addEventListener("keydown", () => this.removeError());
}
/**
* Move focus to next field
*
* @param field
* @param currentId
*/
moveOnMax(field, currentId) {
if (field.value.length === 1) {
if(currentId < (this.fields.length - 1)) {
const nextField = currentId + 1;
this.fields[nextField].focus();
} else {
this.validate();
}
}
}
/**
* Check if the code is correct
*/
validate() {
site.modules.Socket.socket.emit('auth', {
uuid: site.modules.Socket.uuid,
code: `${this.fields[0].value}${this.fields[1].value}${this.fields[2].value}${this.fields[3].value}`
});
}
/**
* Remove the error state from the inputs
*/
removeError() {
for(let field = 0; field < this.fields.length; field++) {
this.fields[field].classList.remove("error");
}
}
/**
* Add the invalid state to the inputs
*/
invalidCode() {
this.fields[0].focus();
for(let field = 0; field < this.fields.length; field++) {
this.fields[field].classList.add("error");
this.fields[field].value = "";
}
}
/**
* Clear the inputs
*/
resetForm() {
for(let field = 0; field < this.fields.length; field++) {
this.fields[field].value = "";
}
}
}

View File

@@ -1,26 +0,0 @@
export default {
modules: [
{
module: "Socket",
group: "default",
el: "main",
global: true
},
{
module: "Index",
group: "index",
el: "#home"
},
{
module: "Particles",
group: "index",
el: "#particles"
},
{
module: "Signin",
group: "index",
el: "#sign-in",
global: true
}
]
}

View File

@@ -1,77 +0,0 @@
{
"particles": {
"number": {
"value": 45,
"density": {
"enable": true,
"value_area": 800
}
},
"color": {
"value": "#ffffff"
},
"shape": {
"type": "circle",
"stroke": {
"width": 0,
"color": "#000000"
},
"polygon": {
"nb_sides": 5
}
},
"opacity": {
"value": 0.5,
"random": true,
"anim": {
"enable": false,
"speed": 1,
"opacity_min": 0.1,
"sync": false
}
},
"size": {
"value": 3,
"random": true,
"anim": {
"enable": false,
"speed": 40,
"size_min": 0.1,
"sync": false
}
},
"line_linked": {
"enable": true,
"distance": 150,
"color": "#ffffff",
"opacity": 0.4,
"width": 1
},
"move": {
"enable": true,
"speed": 6,
"direction": "none",
"random": false,
"straight": false,
"out_mode": "out",
"bounce": false,
"attract": {
"enable": false,
"rotateX": 600,
"rotateY": 1200
}
},
"interactivity": {
"events": {
"onhover": {
"enable": false
},
"onclick": {
"enable": false
},
"resize": true
}
}
},
"retina_detect": true
}

View File

@@ -1,26 +0,0 @@
#container {
position: absolute;
width: 100%;
height: 100%;
}
#center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 5px;
border-radius: 25px;
background-color: rgba($white, 0.3);
color: $white;
width: 90%;
height: auto;
text-align: center;
img {
width: 100px;
border-radius: 25px;
background-color: rgba($white, 0.3);
}
}

View File

@@ -1,9 +0,0 @@
footer {
display: block;
position: fixed;
bottom: 0;
width: 100%;
background-color: rgba($white, 0.7);
text-align: center;
}

View File

@@ -1,32 +0,0 @@
input {
padding: 10px;
border: 0;
width: 23px;
height: 23px;
text-align: center;
font-size: 30px;
color: $black;
font-family: "text-security-disc";
margin-bottom: 2px;
border: #FFF solid 2px;
border-radius: 10px;
&.error {
border: #F00 solid 2px;
}
}
button {
background-image: linear-gradient(to bottom, #3498db, #2980b9);
border-radius: 7px;
box-shadow: 0px 1px 3px #666666;
color: #ffffff;
font-size: 18px;
padding: 6px;
text-decoration: none;
}
button:hover {
background-image: linear-gradient(to bottom, #3cb0fd, #3498db);
text-decoration: none;
}

View File

@@ -1,9 +0,0 @@
#preloader {
opacity: 1;
transition: opacity 1.5s ease-in-out;
&.completed {
opacity: 0;
pointer-events: none;
}
}

View File

@@ -1,8 +0,0 @@
@font-face {
font-family: 'text-security-disc';
src: url('/fonts/text-security-disc.eot');
src: url('/fonts/text-security-disc.eot?#iefix') format('embedded-opentype'),
url('/fonts/text-security-disc.woff') format('woff'),
url('/fonts/text-security-disc.ttf') format('truetype'),
url('/fonts/text-security-disc.svg#text-security') format('svg');
}

View File

@@ -1,20 +0,0 @@
//
// General
//
body {
background-color: $blue;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
h2 {
margin: 0;
}

View File

@@ -1,259 +0,0 @@
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
/**
* 1. Set default font family to sans-serif.
* 2. Prevent iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-family: sans-serif; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/**
* Remove default margin.
*/
body {
margin: 0;
}
/* HTML5 display definitions
========================================================================== */
/**
* Correct `block` display not defined for any HTML5 element in IE 8/9.
* Correct `block` display not defined for `details` or `summary` in IE 10/11
* and Firefox.
* Correct `block` display not defined for `main` in IE 11.
*/
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
menu,
nav,
section,
summary {
display: block;
}
/**
* 1. Correct `inline-block` display not defined in IE 8/9.
* 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
*/
audio,
canvas,
progress,
video {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Prevent modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Address `[hidden]` styling not present in IE 8/9/10.
* Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.
*/
[hidden],
template {
display: none;
}
/* Links
========================================================================== */
/**
* Remove the gray background color from active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* Improve readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/* Embedded content
========================================================================== */
/**
* Remove border when inside `a` element in IE 8/9/10.
*/
img {
border: 0;
}
/**
* Correct overflow not hidden in IE 9/10/11.
*/
svg:not(:root) {
overflow: hidden;
}
/* Forms
========================================================================== */
/**
* Known limitation: by default, Chrome and Safari on OS X allow very limited
* styling of `select`, unless a `border` property is set.
*/
/**
* 1. Correct color not being inherited.
* Known issue: affects color of disabled elements.
* 2. Correct font properties not being inherited.
* 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
*/
button,
input,
optgroup,
select,
textarea {
color: inherit; /* 1 */
font: inherit; /* 2 */
margin: 0; /* 3 */
}
/**
* Address `overflow` set to `hidden` in IE 8/9/10/11.
*/
button {
overflow: visible;
}
/**
* Address inconsistent `text-transform` inheritance for `button` and `select`.
* All other form control elements do not inherit `text-transform` values.
* Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
* Correct `select` style inheritance in Firefox.
*/
button,
select {
text-transform: none;
}
/**
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Correct inability to style clickable `input` types in iOS.
* 3. Improve usability and consistency of cursor style between image-type
* `input` and others.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
appearance: none; /* 2 */
border: none;
cursor: pointer; /* 3 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* Remove inner padding and border in Firefox 4+.
*/
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0;
}
/**
* Address Firefox 4+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
input {
line-height: normal;
}
/**
* 1. Address `appearance` set to `searchfield` in Safari and Chrome.
* 2. Address `box-sizing` set to `border-box` in Safari and Chrome
* (include `-moz` to future-proof).
*/
input[type="search"] {
-webkit-appearance: textfield; /* 1 */
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/**
* Remove inner padding and search cancel button in Safari and Chrome on OS X.
* Safari (but not Chrome) clips the cancel button when the search input has
* padding (and `textfield` appearance).
*/
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Remove default vertical scrollbar in IE 8/9/10/11.
*/
textarea {
overflow: auto;
}
/* Tables
========================================================================== */
/**
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}
td,
th {
padding: 0;
}

View File

@@ -1,5 +0,0 @@
// Visibility
.hidden {
opacity: 0;
display: none;
}

View File

@@ -1,24 +0,0 @@
@charset "UTF-8";
//
// General
//
// Variables
@import "variables/colors";
// Global
@import "global/normalize";
@import "global/utils";
@import "global/fonts";
@import "global/general";
//
// Local App
//
// Components
@import "components/preloader";
@import "components/form";
@import "components/container";
@import "components/footer";

View File

@@ -1,4 +0,0 @@
// colors
$black: #000000;
$white: #ffffff;
$blue: #1875b6;

114
server.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* Import base packages
*/
const express = require('express');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const app = express();
/**
* Import own modules
*/
const unifi = require('./modules/unifi');
/**
* Define global functions
*/
const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
/**
* Trust proxy
*/
app.enable('trust proxy');
/**
* Set template engine
*/
app.set('view engine', 'ejs');
app.set('views', `${__dirname}/template`);
/**
* Enable multer
*/
app.use(multer().none());
/**
* Request logger
*/
app.use((req, res, next) => {
console.log(`[Web][REQUEST]: ${req.originalUrl}`);
next();
});
/**
* Serve static public dir
*/
app.use(express.static(`${__dirname}/public`));
/**
* Configure routers
*/
app.get('/', (req, res) => {
const hour = new Date().getHours();
const timeHeader = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
res.render('home', {
error: typeof req.query.error === 'string' && req.query.error !== '',
error_text: req.query.error || '',
banner_image: process.env.BANNER_IMAGE || `/images/bg-${random(1, 10)}.jpg`,
app_header: timeHeader,
sid: uuidv4()
});
});
app.post('/', async (req, res) => {
const check = req.body.password === (process.env.SECURITY_CODE || "0000");
if(!check) {
res.redirect(encodeURI(`/?error=Invalid password!`));
return;
}
res.redirect(encodeURI(`/voucher?code=${req.body.password}`));
});
app.get('/voucher', async (req, res) => {
if(req.query.code !== (process.env.SECURITY_CODE || "0000")) {
res.status(403).send();
return;
}
const hour = new Date().getHours();
const timeHeader = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
const voucherCode = await unifi();
console.log(voucherCode);
res.render('voucher', {
error: typeof req.query.error === 'string' && req.query.error !== '',
error_text: req.query.error || '',
banner_image: process.env.BANNER_IMAGE || `/images/bg-${random(1, 10)}.jpg`,
app_header: timeHeader,
code: req.query.code,
voucher_code: voucherCode,
sid: uuidv4()
});
});
/**
* Setup default 404 message
*/
app.use((req, res) => {
res.status(404);
res.send('Not Found!');
});
/**
* Disable powered by header for security reasons
*/
app.disable('x-powered-by');
/**
* Start listening on port
*/
app.listen(3000, '0.0.0.0', () => {
console.log(`App is running on: 0.0.0.0:3000`);
});

12
tailwind.config.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
mode: 'jit',
content: ["./template/**/*.{html,js,ejs}"],
darkMode: 'media',
theme: {
extend: {}
},
variants: {
extend: {}
},
plugins: []
};

72
template/home.ejs Normal file
View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Login | UniFi Voucher</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="UniFi Voucher">
<meta name="author" content="Glenn de Haan">
<meta property="og:title" content="Login | UniFi Voucher"/>
<meta property="og:type" content="website"/>
<meta property="og:description" content="UniFi Voucher"/>
<link rel="manifest" href="/manifest.json">
<link rel="shortcut icon" href="/images/favicon.ico">
<link rel="apple-touch-icon" href="/images/icon/logo_256x256.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1875b6">
<link rel="preload" href="<%= banner_image %>" as="image">
<link rel="preload" href="/images/unifi-icon.png" as="image">
<link rel="preload" href="/dist/style.css" as="style">
<link href="/dist/style.css" rel="stylesheet">
</head>
<body class="bg-white dark:bg-neutral-900 dark:text-gray-100 h-screen">
<div class="w-full flex flex-wrap">
<div class="w-full md:w-1/2 flex flex-col">
<div class="flex justify-center md:justify-start pt-12 md:pl-12 md:-mb-24">
<img class="h-20 w-20" src="/images/unifi-icon.png"/>
</div>
<div class="flex flex-col justify-center md:justify-start my-auto pt-8 md:pt-0 px-8 md:px-24 lg:px-32">
<p class="text-center text-3xl"><%= app_header %></p>
<% if(error) { %>
<div class="bg-red-500 text-white p-3 mt-8 rounded shadow-lg flex items-center">
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<div><%= error_text %></div>
</div>
<% } %>
<form class="flex flex-col pt-3 md:pt-8" action="/" method="post" enctype="multipart/form-data">
<div class="flex flex-col pt-4">
<label for="password" class="text-lg">Password</label>
<input type="password" id="password" name="password" placeholder="Password" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mt-1 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-800 dark:border-neutral-700 dark:text-gray-100" required>
</div>
<input type="submit" value="Create Voucher" class="bg-black text-white font-bold text-lg hover:bg-gray-700 p-2 mt-8 cursor-pointer transition-colors dark:text-black dark:bg-gray-200 dark:hover:bg-white">
</form>
</div>
<div class="text-center text-gray-400 text-sm italic pt-12 pb-12">
<p>
Powered by: <a href="https://glenndehaan.com" class="underline font-semibold">Glenn de Haan</a>.<br/>
Want your own portal? Checkout the project on: <a href="https://github.com/glenndehaan/unifi-voucher-site" class="underline font-semibold">GitHub</a>
</p>
<p class="text-[10px] not-italic">
SID: <%= sid %>
</p>
</div>
</div>
<div class="w-1/2 shadow-2xl">
<img class="object-cover w-full h-screen hidden md:block" src="<%= banner_image %>">
</div>
</div>
</body>
</html>

68
template/voucher.ejs Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Voucher | UniFi Voucher</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="UniFi Voucher">
<meta name="author" content="Glenn de Haan">
<meta property="og:title" content="Voucher | UniFi Voucher"/>
<meta property="og:type" content="website"/>
<meta property="og:description" content="UniFi Voucher"/>
<link rel="manifest" href="/manifest.json">
<link rel="shortcut icon" href="/images/favicon.ico">
<link rel="apple-touch-icon" href="/images/icon/logo_256x256.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1875b6">
<link rel="preload" href="<%= banner_image %>" as="image">
<link rel="preload" href="/images/unifi-icon.png" as="image">
<link rel="preload" href="/dist/style.css" as="style">
<link href="/dist/style.css" rel="stylesheet">
</head>
<body class="bg-white dark:bg-neutral-900 dark:text-gray-100 h-screen">
<div class="w-full flex flex-wrap">
<div class="w-full md:w-1/2 flex flex-col">
<div class="flex justify-center md:justify-start pt-12 md:pl-12 md:-mb-24">
<img class="h-20 w-20" src="/images/unifi-icon.png"/>
</div>
<div class="flex flex-col justify-center md:justify-start my-auto pt-8 md:pt-0 px-8 md:px-24 lg:px-32">
<p class="text-center text-3xl"><%= app_header %></p>
<p class="mt-4 text-center">
Voucher generated successfully!
</p>
<form class="flex flex-col pt-3 md:pt-8" action="/" method="post" enctype="multipart/form-data">
<div class="flex flex-col pt-4">
<label for="voucher" class="text-lg">Voucher</label>
<input type="text" id="voucher" name="voucher" disabled value="<%= voucher_code %>" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mt-1 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-800 dark:border-neutral-700 dark:text-gray-100">
</div>
<input type="hidden" id="password" name="password" value="<%= code %>"/>
<input type="submit" value="Create Another Voucher" class="bg-black text-white font-bold text-lg hover:bg-gray-700 p-2 mt-8 cursor-pointer transition-colors dark:text-black dark:bg-gray-200 dark:hover:bg-white">
</form>
</div>
<div class="text-center text-gray-400 text-sm italic pt-12 pb-12">
<p>
Powered by: <a href="https://glenndehaan.com" class="underline font-semibold">Glenn de Haan</a>.<br/>
Want your own portal? Checkout the project on: <a href="https://github.com/glenndehaan/unifi-voucher-site" class="underline font-semibold">GitHub</a>
</p>
<p class="text-[10px] not-italic">
SID: <%= sid %>
</p>
</div>
</div>
<div class="w-1/2 shadow-2xl">
<img class="object-cover w-full h-screen hidden md:block" src="<%= banner_image %>">
</div>
</div>
</body>
</html>