Updated to . Fixed bulk-print.ejs notes view. Updated details.ejs view with additional voucher metadata. Updated voucher.ejs notes view. Created notes.js utils for extracting voucher notes and metadata. Updated docker-compose.yml. Updated README.md. Implemented voucher note check for seperator misuse. Implemented new notes-metadata embed. Implemented new voucher filter within existing chain. Fixed notes sort to only filter on the notes not the metadata. Removed unused variable forward to voucher.ejs

This commit is contained in:
Glenn de Haan
2025-07-30 19:19:43 +02:00
parent 131f726d1c
commit 0c9aeac7a5
8 changed files with 93 additions and 88 deletions

View File

@@ -103,12 +103,9 @@ services:
AUTH_OIDC_CLIENT_ID: '' AUTH_OIDC_CLIENT_ID: ''
# OIDC client secret provided by oauth provider # OIDC client secret provided by oauth provider
AUTH_OIDC_CLIENT_SECRET: '' AUTH_OIDC_CLIENT_SECRET: ''
# In campus environments with multiple organizations sharing a single UniFi Controller instance, it may be desirable to limit voucher administrators to managing only vouchers associated with their own organization. # In environments with multiple organizations sharing a single UniFi Controller instance, it may be desirable to limit users to only manage vouchers associated with their own organization.
# This restriction is based on the domain part of the administrator's email address. # This restriction is based on the domain (including subdomains and TLDs) of the users email address.
# When enabled, the system automatically assigns the administrators email domain as a note to each created voucher. AUTH_OIDC_RESTRICT_VISIBILITY: 'false'
# The voucher will contain both the email domain and any user-provided note.
# Vouchers without a domain note will be hidden from all users.
PIN_OIDC_USER_TO_OWN_DOMAIN: 'false'
# Disables the login/authentication for the portal and API # Disables the login/authentication for the portal and API
AUTH_DISABLE: 'false' AUTH_DISABLE: 'false'
# Voucher Types, format: expiration in minutes (required),single-use or multi-use vouchers value - '0' is for multi-use (unlimited) - '1' is for single-use - 'N' is for multi-use (Nx) (optional),upload speed limit in kbps (optional),download speed limit in kbps (optional),data transfer limit in MB (optional) # Voucher Types, format: expiration in minutes (required),single-use or multi-use vouchers value - '0' is for multi-use (unlimited) - '1' is for single-use - 'N' is for multi-use (Nx) (optional),upload speed limit in kbps (optional),download speed limit in kbps (optional),data transfer limit in MB (optional)
@@ -187,6 +184,7 @@ The structure of the file should use lowercase versions of the environment varia
"auth_oidc_app_base_url": "", "auth_oidc_app_base_url": "",
"auth_oidc_client_id": "", "auth_oidc_client_id": "",
"auth_oidc_client_secret": "", "auth_oidc_client_secret": "",
"auth_oidc_restrict_visibility": false,
"auth_disable": false, "auth_disable": false,
"voucher_types": "480,1,,,;", "voucher_types": "480,1,,,;",
"voucher_custom": true, "voucher_custom": true,
@@ -478,7 +476,7 @@ AUTH_INTERNAL_PASSWORD: '0000'
The UniFi Voucher Site allows seamless integration with OpenID Connect (OIDC), enabling users to authenticate through their preferred identity provider (IdP). Configuration is easy using environment variables to align with your existing OIDC provider. The UniFi Voucher Site allows seamless integration with OpenID Connect (OIDC), enabling users to authenticate through their preferred identity provider (IdP). Configuration is easy using environment variables to align with your existing OIDC provider.
#### Configuration #### Required Configuration
To enable OIDC authentication, set the following environment variables in your applications environment: To enable OIDC authentication, set the following environment variables in your applications environment:
@@ -499,6 +497,14 @@ To enable OIDC authentication, set the following environment variables in your a
> Ensure your idP supports **Confidential Clients** with the **Authorization Code Flow** > Ensure your idP supports **Confidential Clients** with the **Authorization Code Flow**
#### Optional Configuration
* **`AUTH_OIDC_RESTRICT_VISIBILITY`**:
Restricts user access to only vouchers created within their own organization.
When enabled (`true`), users can only manage vouchers tied to their organization's domain, determined by the domain of their email address (including subdomains and top-level domains).
This is useful in environments where multiple organizations share a single UniFi Controller instance.
**Default:** `false`
#### Determine Supported Client Types #### Determine Supported Client Types
To identify which client types your OpenID Connect (OIDC) provider supports, you can check the `.well-known/openid-configuration` endpoint. This endpoint contains metadata about the OIDC provider, including the supported flows and grant types. To identify which client types your OpenID Connect (OIDC) provider supports, you can check the `.well-known/openid-configuration` endpoint. This endpoint contains metadata about the OIDC provider, including the supported flows and grant types.
@@ -840,7 +846,7 @@ When upgrading from 6.x to 7.x, the following changes need to be made:
``` ```
* Update your environment configuration to use the new `KIOSK_VOUCHER_TYPES` variable. * Update your environment configuration to use the new `KIOSK_VOUCHER_TYPES` variable.
* Ensure the values provided are valid and supported — refer to the [Kiosk Configuration](#configuration-3) for the complete list of accepted types and formatting rules. * Ensure the values provided are valid and supported — refer to the [Kiosk Configuration](#configuration-2) for the complete list of accepted types and formatting rules.
> Make sure to remove the deprecated `KIOSK_VOUCHER_TYPE` and follow the structure outlined in the documentation to avoid misconfiguration issues during deployment. > Make sure to remove the deprecated `KIOSK_VOUCHER_TYPE` and follow the structure outlined in the documentation to avoid misconfiguration issues during deployment.

View File

@@ -19,7 +19,7 @@ services:
AUTH_OIDC_APP_BASE_URL: '' AUTH_OIDC_APP_BASE_URL: ''
AUTH_OIDC_CLIENT_ID: '' AUTH_OIDC_CLIENT_ID: ''
AUTH_OIDC_CLIENT_SECRET: '' AUTH_OIDC_CLIENT_SECRET: ''
PIN_OIDC_USER_TO_OWN_DOMAIN: 'false' AUTH_OIDC_RESTRICT_VISIBILITY: 'false'
AUTH_DISABLE: 'false' AUTH_DISABLE: 'false'
VOUCHER_TYPES: '480,1,,,;' VOUCHER_TYPES: '480,1,,,;'
VOUCHER_CUSTOM: 'true' VOUCHER_CUSTOM: 'true'

View File

@@ -31,7 +31,7 @@ module.exports = {
authOidcAppBaseUrl: config('auth_oidc_app_base_url') || process.env.AUTH_OIDC_APP_BASE_URL || '', authOidcAppBaseUrl: config('auth_oidc_app_base_url') || process.env.AUTH_OIDC_APP_BASE_URL || '',
authOidcClientId: config('auth_oidc_client_id') || process.env.AUTH_OIDC_CLIENT_ID || '', authOidcClientId: config('auth_oidc_client_id') || process.env.AUTH_OIDC_CLIENT_ID || '',
authOidcClientSecret: config('auth_oidc_client_secret') || process.env.AUTH_OIDC_CLIENT_SECRET || '', authOidcClientSecret: config('auth_oidc_client_secret') || process.env.AUTH_OIDC_CLIENT_SECRET || '',
pinOidcUserToOwnDomain: config('pin_oidc_user_to_own_domain') !== null ? config('pin_oidc_user_to_own_domain') : (process.env.PIN_OIDC_USER_TO_OWN_DOMAIN === 'true') || false, authOidcRestrictVisibility: config('auth_oidc_restrict_visibility') !== null ? config('auth_oidc_restrict_visibility') : (process.env.AUTH_OIDC_RESTRICT_VISIBILITY === 'true') || false,
authDisabled: config('auth_disable') || (process.env.AUTH_DISABLE === 'true') || false, authDisabled: config('auth_disable') || (process.env.AUTH_DISABLE === 'true') || false,
printers: config('printers') || process.env.PRINTERS || '', printers: config('printers') || process.env.PRINTERS || '',
smtpFrom: config('smtp_from') || process.env.SMTP_FROM || '', smtpFrom: config('smtp_from') || process.env.SMTP_FROM || '',

View File

@@ -35,6 +35,7 @@ const flashMessage = require('./middlewares/flashMessage');
*/ */
const {updateCache} = require('./utils/cache'); const {updateCache} = require('./utils/cache');
const types = require('./utils/types'); const types = require('./utils/types');
const notes = require('./utils/notes');
const time = require('./utils/time'); const time = require('./utils/time');
const bytes = require('./utils/bytes'); const bytes = require('./utils/bytes');
const status = require('./utils/status'); const status = require('./utils/status');
@@ -225,8 +226,15 @@ if(variables.serviceWeb) {
return; return;
} }
if(variables.kioskNameRequired && req.body['voucher-note'] !== '' && req.body['voucher-note'].includes('||;;||')) {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'Invalid Notes!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/kiosk`);
return;
}
const voucherNote = `${variables.kioskNameRequired ? req.body['voucher-note'] : ''}||;;||kiosk||;;||local||;;||`;
// Create voucher code // Create voucher code
const voucherCode = await unifi.create(types(req.body['voucher-type'], true), 1, variables.kioskNameRequired ? req.body['voucher-note'] : null).catch((e) => { const voucherCode = await unifi.create(types(req.body['voucher-type'], true), 1, voucherNote).catch((e) => {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/kiosk`); res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/kiosk`);
}); });
@@ -342,36 +350,16 @@ if(variables.serviceWeb) {
} }
} }
let voucherNote = null; if(req.body['voucher-note'] !== '' && req.body['voucher-note'].includes('||;;||')) {
if (variables.pinOidcUserToOwnDomain && req.oidc) { res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'Invalid Notes!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`);
try { return;
const user = await req.oidc.fetchUserInfo();
if (user && user.email && user.email.includes('@')) {
const domain = user.email.split('@')[1];
if (req.body['voucher-note'] && req.body['voucher-note'] !== '') {
voucherNote = `${req.body['voucher-note']}|||${domain}`;
} else {
voucherNote = domain;
}
}
} catch (e) {
// Ignore errors, if Userinfo can not be loaded
}
} else {
voucherNote = req.body['voucher-note'] !== '' ? req.body['voucher-note'] : null;
} }
const user = req.oidc ? await req.oidc.fetchUserInfo() : { email: null };
const voucherNote = `${req.body['voucher-note'] !== '' ? req.body['voucher-note'] : ''}||;;||web||;;||${req.oidc ? 'oidc' : 'local'}||;;||${req.oidc ? user.email.split('@')[1].toLowerCase() : ''}`;
// Create voucher code // Create voucher code
const voucherCode = await unifi.create( const voucherCode = await unifi.create(types(req.body['voucher-type'] === 'custom' ? `${req.body['voucher-duration-type'] === 'day' ? (parseInt(req.body['voucher-duration']) * 24 * 60) : req.body['voucher-duration-type'] === 'hour' ? (parseInt(req.body['voucher-duration']) * 60) : parseInt(req.body['voucher-duration'])},${req.body['voucher-usage'] === '-1' ? req.body['voucher-quota'] : req.body['voucher-usage']},${req.body['voucher-upload-limit']},${req.body['voucher-download-limit']},${req.body['voucher-data-limit']};` : req.body['voucher-type'], true), parseInt(req.body['voucher-amount']), voucherNote).catch((e) => {
types(
req.body['voucher-type'] === 'custom'
? `${req.body['voucher-duration-type'] === 'day' ? (parseInt(req.body['voucher-duration']) * 24 * 60) : req.body['voucher-duration-type'] === 'hour' ? (parseInt(req.body['voucher-duration']) * 60) : parseInt(req.body['voucher-duration'])},${req.body['voucher-usage'] === '-1' ? req.body['voucher-quota'] : req.body['voucher-usage']},${req.body['voucher-upload-limit']},${req.body['voucher-download-limit']},${req.body['voucher-data-limit']};`
: req.body['voucher-type'],
true
),
parseInt(req.body['voucher-amount']),
voucherNote
).catch((e) => {
res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`); res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`);
}); });
@@ -571,24 +559,6 @@ if(variables.serviceWeb) {
const user = req.oidc ? await req.oidc.fetchUserInfo() : { email: 'admin' }; const user = req.oidc ? await req.oidc.fetchUserInfo() : { email: 'admin' };
let filteredVouchers = cache.vouchers;
if (
variables.pinOidcUserToOwnDomain &&
req.oidc &&
user.email &&
user.email.includes('@')
) {
const userDomain = user.email.split('@')[1].toLowerCase();
filteredVouchers = filteredVouchers.filter(v => {
const note = (v.note || '').toLowerCase();
if (note.includes('|||')) {
return note.split('|||')[1] === userDomain;
} else {
return note === userDomain;
}
});
}
res.render('voucher', { res.render('voucher', {
baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '', baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '',
gitTag: variables.gitTag, gitTag: variables.gitTag,
@@ -603,11 +573,18 @@ if(variables.serviceWeb) {
kioskEnabled: variables.kioskEnabled, kioskEnabled: variables.kioskEnabled,
timeConvert: time, timeConvert: time,
bytesConvert: bytes, bytesConvert: bytes,
notesConvert: notes,
email_enabled: variables.smtpFrom !== '' && variables.smtpHost !== '' && variables.smtpPort !== '', email_enabled: variables.smtpFrom !== '' && variables.smtpHost !== '' && variables.smtpPort !== '',
printer_enabled: variables.printers !== '', printer_enabled: variables.printers !== '',
voucher_types: types(variables.voucherTypes), voucher_types: types(variables.voucherTypes),
voucher_custom: variables.voucherCustom, voucher_custom: variables.voucherCustom,
vouchers: filteredVouchers.filter((item) => { vouchers: cache.vouchers.filter((item) => {
if(variables.authOidcRestrictVisibility && req.oidc) {
return notes(item.note).auth_oidc_domain === user.email.split('@')[1].toLowerCase();
}
return true;
}).filter((item) => {
if(req.query.status === 'available') { if(req.query.status === 'available') {
return item.used === 0 && item.status !== 'EXPIRED'; return item.used === 0 && item.status !== 'EXPIRED';
} }
@@ -638,8 +615,8 @@ if(variables.serviceWeb) {
} }
if(req.query.sort === 'note') { if(req.query.sort === 'note') {
if ((a.note || '') > (b.note || '')) return -1; if ((notes(a.note).note || '') > (notes(b.note).note || '')) return -1;
if ((a.note || '') < (b.note || '')) return 1; if ((notes(a.note).note || '') < (notes(b.note).note || '')) return 1;
} }
if(req.query.sort === 'duration') { if(req.query.sort === 'duration') {
@@ -657,8 +634,7 @@ if(variables.serviceWeb) {
status: req.query.status, status: req.query.status,
quota: req.query.quota quota: req.query.quota
}, },
sort: req.query.sort, sort: req.query.sort
pinOidcUserToOwnDomain: variables.pinOidcUserToOwnDomain
}); });
}); });
app.get('/voucher/:id', [authorization.web], async (req, res) => { app.get('/voucher/:id', [authorization.web], async (req, res) => {
@@ -674,6 +650,7 @@ if(variables.serviceWeb) {
baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '', baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '',
timeConvert: time, timeConvert: time,
bytesConvert: bytes, bytesConvert: bytes,
notesConvert: notes,
voucher, voucher,
guests, guests,
updated: cache.updated updated: cache.updated
@@ -709,6 +686,7 @@ if(variables.serviceWeb) {
baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '', baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '',
timeConvert: time, timeConvert: time,
bytesConvert: bytes, bytesConvert: bytes,
notesConvert: notes,
languages, languages,
defaultLanguage: variables.translationDefault, defaultLanguage: variables.translationDefault,
printers: variables.printers.split(','), printers: variables.printers.split(','),
@@ -904,7 +882,7 @@ if(variables.serviceApi) {
} }
// Create voucher code // Create voucher code
const voucherCode = await unifi.create(types(req.body.type, true)).catch((e) => { const voucherCode = await unifi.create(types(req.body.type, true), 1, `||;;||api||;;||local||;;||`).catch((e) => {
res.status(500).json({ res.status(500).json({
error: e, error: e,
data: {} data: {}

View File

@@ -64,9 +64,9 @@
</div> </div>
<% } %> <% } %>
<% } %> <% } %>
<% if (voucher.note) { %> <% if (notesConvert(voucher.note).note) { %>
<div class="hidden sm:block rounded-md flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-gray-50 text-gray-800 ring-gray-600/20 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20"> <div class="hidden sm:block rounded-md flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-gray-50 text-gray-800 ring-gray-600/20 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20">
<%= voucher.note %> <%= notesConvert(voucher.note).note %>
</div> </div>
<% } %> <% } %>
</div> </div>

View File

@@ -43,17 +43,30 @@
<% } %> <% } %>
</dd> </dd>
</div> </div>
<div class="py-2 grid grid-cols-3 gap-4 px-0"> <% if(notesConvert(voucher.note).note) { %>
<dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Notes</dt> <div class="py-2 grid grid-cols-3 gap-4 px-0">
<dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"> <dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Notes</dt>
<% if (voucher.note && voucher.note.includes('|||')) { %> <dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= notesConvert(voucher.note).note %></dd>
<strong>Description:</strong> <%= voucher.note.split('|||')[0] %><br/> </div>
<strong>Domain:</strong> <%= voucher.note.split('|||')[1] %> <% } %>
<% } else { %> <% if(notesConvert(voucher.note).source) { %>
<%= voucher.note ? voucher.note : '-' %> <div class="py-2 grid grid-cols-3 gap-4 px-0">
<% } %> <dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Source</dt>
</dd> <dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= notesConvert(voucher.note).source %></dd>
</div> </div>
<% } %>
<% if(notesConvert(voucher.note).auth_type) { %>
<div class="py-2 grid grid-cols-3 gap-4 px-0">
<dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Authentication</dt>
<dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= notesConvert(voucher.note).auth_type %></dd>
</div>
<% } %>
<% if(notesConvert(voucher.note).auth_oidc_domain) { %>
<div class="py-2 grid grid-cols-3 gap-4 px-0">
<dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">OIDC Domain</dt>
<dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= notesConvert(voucher.note).auth_oidc_domain %></dd>
</div>
<% } %>
<div class="py-2 grid grid-cols-3 gap-4 px-0"> <div class="py-2 grid grid-cols-3 gap-4 px-0">
<dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Type</dt> <dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Type</dt>
<dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= voucher.quota === 1 ? 'Single-use' : voucher.quota === 0 ? 'Multi-use (Unlimited)' : `Multi-use (${voucher.quota}x)` %></dd> <dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= voucher.quota === 1 ? 'Single-use' : voucher.quota === 0 ? 'Multi-use (Unlimited)' : `Multi-use (${voucher.quota}x)` %></dd>

View File

@@ -166,19 +166,10 @@
</div> </div>
<% } %> <% } %>
<% } %> <% } %>
<% if (voucher.note) { %> <% if (notesConvert(voucher.note).note) { %>
<% if (voucher.note.includes('|||')) { %> <div class="hidden sm:block rounded-md flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-gray-50 text-gray-800 ring-gray-600/20 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20">
<div class="hidden sm:block rounded-md flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-gray-50 text-gray-800 ring-gray-600/20 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20"> <%= notesConvert(voucher.note).note %>
<%= voucher.note.split('|||')[0] %> </div>
</div>
<div class="hidden sm:block rounded-md flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-gray-50 text-gray-800 ring-gray-600/20 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20">
<%= voucher.note.split('|||')[1] %>
</div>
<% } else { %>
<div class="hidden sm:block rounded-md flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-gray-50 text-gray-800 ring-gray-600/20 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20">
<%= voucher.note %>
</div>
<% } %>
<% } %> <% } %>
</div> </div>
</h2> </h2>

17
utils/notes.js Normal file
View File

@@ -0,0 +1,17 @@
/**
* Returns an object of voucher notes
*
* @param string
* @returns {*}
*/
module.exports = (string) => {
const match = string.match(/^(?:(?<note>.*?)\|\|;;\|\|(?<source>[^|]*)\|\|;;\|\|(?<auth_type>[^|]*)\|\|;;\|\|(?<auth_oidc_domain>[^|]*)|(?<note_only>.+))$/);
const { note, source, auth_type, auth_oidc_domain, note_only } = match.groups;
return {
note: note || note_only,
source: source || null,
auth_type: auth_type || null,
auth_oidc_domain: auth_oidc_domain || null
};
}