v_2.3
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
const { db } = require('../db');
|
||||
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO logs (actor, actor_id, action, page, detail, ip)
|
||||
VALUES (@actor, @actor_id, @action, @page, @detail, @ip)`
|
||||
);
|
||||
|
||||
/**
|
||||
* Record an auditable action.
|
||||
* @param {object} req Express request (for user + ip). Can be null for system events.
|
||||
* @param {string} action Short machine code, e.g. FILE_UPLOAD
|
||||
* @param {string} page Human page name
|
||||
* @param {string} detail Free-text description
|
||||
*/
|
||||
function log(req, action, page, detail) {
|
||||
const user = req && req.session && req.session.user;
|
||||
insert.run({
|
||||
actor: user ? user.username : 'system',
|
||||
actor_id: user ? user.id : null,
|
||||
action,
|
||||
page: page || null,
|
||||
detail: detail || null,
|
||||
ip: req ? (req.headers['x-forwarded-for'] || req.socket.remoteAddress || null) : null,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { log };
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Dates used by folders.
|
||||
*
|
||||
* "Recorded Date" is chosen by the administrator when the folder is created.
|
||||
* "Legal Validity" is always derived from it: recorded date + 30 days. It is
|
||||
* never stored, so it can never drift out of step with the recorded date.
|
||||
*/
|
||||
|
||||
const VALIDITY_DAYS = 30;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** True when the value is a real calendar date in YYYY-MM-DD form. */
|
||||
function isValidDate(value) {
|
||||
if (!DATE_RE.test(String(value || ''))) return false;
|
||||
const [y, m, d] = String(value).split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
||||
}
|
||||
|
||||
/** Recorded date + 30 days, as YYYY-MM-DD. Returns null for a missing date. */
|
||||
function legalValidity(recordedDate) {
|
||||
if (!isValidDate(recordedDate)) return null;
|
||||
const [y, m, d] = String(recordedDate).split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
dt.setUTCDate(dt.getUTCDate() + VALIDITY_DAYS);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Attach the derived validity date to a folder row. */
|
||||
function withValidity(folder) {
|
||||
return { ...folder, legal_validity: legalValidity(folder.recorded_date) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display formatting.
|
||||
// Dates are STORED as ISO (YYYY-MM-DD / YYYY-MM-DD HH:MM:SS) so they sort and
|
||||
// compare correctly, and PRESENTED as DD-MM-YYYY.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** ISO date (or datetime) -> "DD-MM-YYYY". Returns '' for anything unusable. */
|
||||
function toDisplayDate(value) {
|
||||
if (!value) return '';
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return String(value);
|
||||
return `${m[3]}-${m[2]}-${m[1]}`;
|
||||
}
|
||||
|
||||
/** ISO datetime -> "DD-MM-YYYY HH:MM:SS" (or DD-MM-YYYY when no time part). */
|
||||
function toDisplayDateTime(value) {
|
||||
if (!value) return '';
|
||||
const str = String(value).replace('T', ' ').replace('Z', '');
|
||||
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ ](\d{2}:\d{2})(:\d{2})?)?/);
|
||||
if (!m) return str;
|
||||
const date = `${m[3]}-${m[2]}-${m[1]}`;
|
||||
return m[4] ? `${date} ${m[4]}${m[5] || ''}` : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a date typed as DD-MM-YYYY (what users see) or YYYY-MM-DD (what the
|
||||
* browser's native date input sends) and return ISO, or '' when unusable.
|
||||
*/
|
||||
function parseInputDate(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return '';
|
||||
let iso = '';
|
||||
const dmy = v.match(/^(\d{2})-(\d{2})-(\d{4})$/);
|
||||
if (dmy) iso = `${dmy[3]}-${dmy[2]}-${dmy[1]}`;
|
||||
else if (DATE_RE.test(v)) iso = v;
|
||||
else return '';
|
||||
return isValidDate(iso) ? iso : '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Access validity windows, chosen by an administrator when approving.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DURATIONS = {
|
||||
'24h': { label: 'Valid for 24 hours', ms: 24 * 60 * 60 * 1000 },
|
||||
'15d': { label: 'Valid for 15 days', ms: 15 * 24 * 60 * 60 * 1000 },
|
||||
'30d': { label: 'Valid for 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
|
||||
forever: { label: 'Valid forever', ms: null },
|
||||
};
|
||||
|
||||
const DURATION_KEYS = Object.keys(DURATIONS);
|
||||
|
||||
/** Current UTC time as "YYYY-MM-DD HH:MM:SS". */
|
||||
function nowIso() {
|
||||
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expiry timestamp for a duration, measured from the moment of approval.
|
||||
* Returns null for "forever" (no expiry).
|
||||
*/
|
||||
function expiryFor(durationKey, fromDate) {
|
||||
const d = DURATIONS[durationKey];
|
||||
if (!d) return undefined; // caller should treat as invalid
|
||||
if (d.ms === null) return null; // forever
|
||||
const base = fromDate ? new Date(fromDate) : new Date();
|
||||
return new Date(base.getTime() + d.ms).toISOString().replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
/** True when an expiry timestamp is in the past. */
|
||||
function isExpired(expiresAt) {
|
||||
if (!expiresAt) return false; // null = forever
|
||||
return new Date(String(expiresAt).replace(' ', 'T') + 'Z').getTime() <= Date.now();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALIDITY_DAYS, DATE_RE, isValidDate, legalValidity, withValidity,
|
||||
toDisplayDate, toDisplayDateTime, parseInputDate,
|
||||
DURATIONS, DURATION_KEYS, nowIso, expiryFor, isExpired,
|
||||
};
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
'use strict';
|
||||
const nodemailer = require('nodemailer');
|
||||
const { ImapFlow } = require('imapflow');
|
||||
const { db } = require('../db');
|
||||
const { log } = require('./audit');
|
||||
const { getJSON, setJSON } = require('./settings');
|
||||
|
||||
const SETTINGS_KEY = 'mail_config';
|
||||
|
||||
/**
|
||||
* Effective mail configuration.
|
||||
* Settings saved in Data Management win; anything not set there falls back to
|
||||
* the environment, so an existing .env-based install keeps working untouched.
|
||||
*/
|
||||
function getMailConfig() {
|
||||
const s = getJSON(SETTINGS_KEY, {});
|
||||
const pick = (a, b) => (a === undefined || a === null || a === '' ? b : a);
|
||||
return {
|
||||
host: pick(s.host, process.env.SMTP_HOST || ''),
|
||||
port: Number(pick(s.port, process.env.SMTP_PORT || 587)),
|
||||
secure: String(pick(s.secure, process.env.SMTP_SECURE || 'false')) === 'true',
|
||||
user: pick(s.user, process.env.SMTP_USER || ''),
|
||||
pass: pick(s.pass, process.env.SMTP_PASS || ''),
|
||||
from: pick(s.from, process.env.MAIL_FROM || 'Infosec <no-reply@martinhal.local>'),
|
||||
reject_unauthorized: s.reject_unauthorized === undefined ? true : !!s.reject_unauthorized,
|
||||
// Used to build clickable links inside notification emails.
|
||||
base_url: String(pick(s.base_url, process.env.BASE_URL || '')).replace(/\/+$/, ''),
|
||||
// IMAP, used only to place a copy of each sent message in the Sent folder.
|
||||
// When the host is blank this feature is simply off.
|
||||
imap_host: pick(s.imap_host, process.env.IMAP_HOST || ''),
|
||||
imap_port: Number(pick(s.imap_port, process.env.IMAP_PORT || 993)),
|
||||
imap_secure: s.imap_secure === undefined
|
||||
? String(process.env.IMAP_SECURE || 'true') !== 'false'
|
||||
: !!s.imap_secure,
|
||||
// Falls back to the SMTP credentials, which is the common case.
|
||||
imap_user: pick(s.imap_user, pick(s.user, process.env.IMAP_USER || process.env.SMTP_USER || '')),
|
||||
imap_pass: pick(s.imap_pass, pick(s.pass, process.env.IMAP_PASS || process.env.SMTP_PASS || '')),
|
||||
imap_sent_folder: pick(s.imap_sent_folder, process.env.IMAP_SENT_FOLDER || ''),
|
||||
source: s.host ? 'settings' : (process.env.SMTP_HOST ? 'environment' : 'none'),
|
||||
};
|
||||
}
|
||||
|
||||
function saveMailConfig(cfg) {
|
||||
setJSON(SETTINGS_KEY, cfg);
|
||||
}
|
||||
|
||||
/** Build a transport from the given configuration (null when unconfigured). */
|
||||
function buildTransport(cfg) {
|
||||
const c = cfg || getMailConfig();
|
||||
if (!c.host) return null;
|
||||
return nodemailer.createTransport({
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
secure: c.secure,
|
||||
auth: c.user ? { user: c.user, pass: c.pass } : undefined,
|
||||
tls: { rejectUnauthorized: c.reject_unauthorized !== false },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Mail log ---------------------------------------------------------
|
||||
const insertMail = db.prepare(
|
||||
`INSERT INTO mail_log (direction, status, from_addr, to_addr, subject, body_preview, attachments, error, context, actor)
|
||||
VALUES (@direction, @status, @from_addr, @to_addr, @subject, @body_preview, @attachments, @error, @context, @actor)`
|
||||
);
|
||||
|
||||
function stripHtml(html) {
|
||||
return String(html || '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 400);
|
||||
}
|
||||
|
||||
function recordMail(entry) {
|
||||
insertMail.run({
|
||||
direction: entry.direction || 'sent',
|
||||
status: entry.status,
|
||||
from_addr: entry.from_addr || null,
|
||||
to_addr: entry.to_addr || null,
|
||||
subject: entry.subject || null,
|
||||
body_preview: entry.body_preview || null,
|
||||
attachments: entry.attachments || null,
|
||||
error: entry.error || null,
|
||||
context: entry.context || null,
|
||||
actor: entry.actor || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email and record it in the mail log.
|
||||
* When SMTP is not configured the message is queued (status 'queued') rather
|
||||
* than lost, so the system stays usable without a mail server.
|
||||
*/
|
||||
async function sendMail({ to, subject, html, attachments, context, actor }) {
|
||||
const cfg = getMailConfig();
|
||||
const transporter = buildTransport(cfg);
|
||||
const base = {
|
||||
direction: 'sent',
|
||||
from_addr: cfg.from,
|
||||
to_addr: to,
|
||||
subject,
|
||||
body_preview: stripHtml(html),
|
||||
attachments: (attachments || []).map((a) => a.filename).join(', ') || null,
|
||||
context: context || null,
|
||||
actor: actor || null,
|
||||
};
|
||||
|
||||
if (!transporter) {
|
||||
recordMail({ ...base, status: 'queued', error: 'No mail server configured' });
|
||||
log(null, 'MAIL_OUTBOX', 'System', `SMTP not configured. Queued mail -> ${to} | ${subject}`);
|
||||
return { delivered: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Build the message once so the exact bytes we send can also be filed in
|
||||
// the Sent folder.
|
||||
const info = await transporter.sendMail({ from: cfg.from, to, subject, html, attachments });
|
||||
recordMail({ ...base, status: 'delivered' });
|
||||
|
||||
// Place a copy in the mail server's Sent folder, best-effort: a failure
|
||||
// here must never turn a successfully-sent message into an error.
|
||||
let sentCopy = null;
|
||||
if (cfg.imap_host && info && info.message) {
|
||||
try {
|
||||
const folder = await appendToSent(cfg, info.message);
|
||||
sentCopy = { ok: true, folder };
|
||||
} catch (e) {
|
||||
sentCopy = { ok: false, error: e.message };
|
||||
log(null, 'MAIL_SENT_COPY_FAILED', 'System',
|
||||
`Could not copy message to the Sent folder: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { delivered: true, sentCopy };
|
||||
} catch (e) {
|
||||
recordMail({ ...base, status: 'failed', error: e.message });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a raw RFC822 message to the mail server's Sent folder over IMAP.
|
||||
* Tries the configured folder name, then the server's special-use \\Sent
|
||||
* mailbox, then common names. Marks the copy as \\Seen.
|
||||
*/
|
||||
async function appendToSent(cfg, raw) {
|
||||
const client = new ImapFlow({
|
||||
host: cfg.imap_host,
|
||||
port: cfg.imap_port,
|
||||
secure: cfg.imap_secure,
|
||||
auth: { user: cfg.imap_user, pass: cfg.imap_pass },
|
||||
tls: { rejectUnauthorized: cfg.reject_unauthorized !== false },
|
||||
logger: false,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const candidates = [];
|
||||
if (cfg.imap_sent_folder) candidates.push(cfg.imap_sent_folder);
|
||||
// Prefer whatever the server marks as its Sent mailbox.
|
||||
try {
|
||||
for await (const box of client.list()) {
|
||||
const flags = box.flags || new Set();
|
||||
if ((box.specialUse === '\\Sent') || flags.has('\\Sent')) candidates.push(box.path);
|
||||
}
|
||||
} catch (_) { /* listing not supported; fall through to common names */ }
|
||||
candidates.push('Sent', 'Sent Items', 'INBOX.Sent', '[Gmail]/Sent Mail');
|
||||
|
||||
let lastErr = null;
|
||||
for (const path of candidates) {
|
||||
if (!path) continue;
|
||||
try {
|
||||
await client.append(path, raw, ['\\Seen']);
|
||||
return path;
|
||||
} catch (e) { lastErr = e; }
|
||||
}
|
||||
throw lastErr || new Error('No Sent folder could be found on the mail server.');
|
||||
} finally {
|
||||
try { await client.logout(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify IMAP credentials and confirm a Sent folder can be found. */
|
||||
async function verifyImap(cfg) {
|
||||
if (!cfg.imap_host) throw new Error('No IMAP host configured.');
|
||||
const client = new ImapFlow({
|
||||
host: cfg.imap_host,
|
||||
port: cfg.imap_port,
|
||||
secure: cfg.imap_secure,
|
||||
auth: { user: cfg.imap_user, pass: cfg.imap_pass },
|
||||
tls: { rejectUnauthorized: cfg.reject_unauthorized !== false },
|
||||
logger: false,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
let sent = cfg.imap_sent_folder || null;
|
||||
if (!sent) {
|
||||
for await (const box of client.list()) {
|
||||
const flags = box.flags || new Set();
|
||||
if (box.specialUse === '\\Sent' || flags.has('\\Sent')) { sent = box.path; break; }
|
||||
}
|
||||
}
|
||||
return { ok: true, sent_folder: sent || '(will try common names)' };
|
||||
} finally {
|
||||
try { await client.logout(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a configuration by opening a connection (used by "Test connection"). */
|
||||
async function verifyConfig(cfg) {
|
||||
const transporter = buildTransport(cfg);
|
||||
if (!transporter) throw new Error('No mail server host configured.');
|
||||
await transporter.verify();
|
||||
return true;
|
||||
}
|
||||
|
||||
function adminEmails() {
|
||||
const rows = db.prepare(`SELECT email FROM users WHERE role = 'admin'`).all();
|
||||
return rows.map((r) => r.email);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendMail, adminEmails, getMailConfig, saveMailConfig, verifyConfig, verifyImap, recordMail, stripHtml,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
const { db } = require('../db');
|
||||
|
||||
function getSetting(key, fallback = null) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : fallback;
|
||||
}
|
||||
|
||||
function setSetting(key, value) {
|
||||
db.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
).run(key, value == null ? null : String(value));
|
||||
}
|
||||
|
||||
function getJSON(key, fallback = {}) {
|
||||
const raw = getSetting(key);
|
||||
if (!raw) return fallback;
|
||||
try { return JSON.parse(raw); } catch { return fallback; }
|
||||
}
|
||||
|
||||
function setJSON(key, obj) {
|
||||
setSetting(key, JSON.stringify(obj));
|
||||
}
|
||||
|
||||
module.exports = { getSetting, setSetting, getJSON, setJSON };
|
||||
@@ -0,0 +1,85 @@
|
||||
'use strict';
|
||||
const { db } = require('../db');
|
||||
|
||||
const DEFAULTS = {
|
||||
request_to_admin: {
|
||||
subject: 'Action needed: access request from {{username}} awaiting approval',
|
||||
body_html:
|
||||
'<h2>Access request pending approval</h2>' +
|
||||
'<p><strong>{{username}}</strong> ({{email}}) has requested access to ' +
|
||||
'the {{target_type}} <strong>{{target_name}}</strong>.</p>' +
|
||||
'<p>Requested at {{created_at}}. Request reference #{{request_id}}.</p>' +
|
||||
'<p><strong>{{pending_count}}</strong> request(s) are currently waiting for a decision.</p>' +
|
||||
'<p><a href="{{approvals_url}}" style="display:inline-block;background:#1f7a70;color:#fff;' +
|
||||
'padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600">' +
|
||||
'Review pending requests</a></p>' +
|
||||
'<p style="font-size:12px;color:#6b7280">If the button does not work, open: {{approvals_url}}</p>',
|
||||
},
|
||||
approval_to_user: {
|
||||
subject: 'Your access request was approved',
|
||||
body_html:
|
||||
'<h2>Request approved</h2>' +
|
||||
'<p>Hello {{username}},</p>' +
|
||||
'<p>Your request to access the {{target_type}} <strong>{{target_name}}</strong> ' +
|
||||
'has been <strong>approved</strong>. You may now open it from the View Data page.</p>' +
|
||||
'<p><strong>{{validity}}</strong> — access expires on {{expires_at}}.</p>' +
|
||||
'<p>A live countdown is shown next to the folder on the View Data page.</p>',
|
||||
},
|
||||
denial_to_user: {
|
||||
subject: 'Your access request was declined',
|
||||
body_html:
|
||||
'<h2>Request declined</h2>' +
|
||||
'<p>Hello {{username}},</p>' +
|
||||
'<p>Unfortunately your request to access the {{target_type}} ' +
|
||||
'<strong>{{target_name}}</strong> was not approved at this time.</p>',
|
||||
},
|
||||
};
|
||||
|
||||
function seedDefaults() {
|
||||
const exists = db.prepare('SELECT key FROM email_templates WHERE key = ?');
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO email_templates (key, subject, body_html) VALUES (?, ?, ?)'
|
||||
);
|
||||
for (const [key, t] of Object.entries(DEFAULTS)) {
|
||||
if (!exists.get(key)) insert.run(key, t.subject, t.body_html);
|
||||
}
|
||||
}
|
||||
|
||||
function getTemplate(key) {
|
||||
return (
|
||||
db.prepare('SELECT * FROM email_templates WHERE key = ?').get(key) ||
|
||||
DEFAULTS[key]
|
||||
);
|
||||
}
|
||||
|
||||
function render(str, vars) {
|
||||
return String(str).replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) =>
|
||||
vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : ''
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns { subject, html } with placeholders filled and footer appended. */
|
||||
function buildEmail(key, vars) {
|
||||
const t = getTemplate(key);
|
||||
const footer =
|
||||
'<hr style="margin-top:32px;border:none;border-top:1px solid #d8d8d8">' +
|
||||
'<p style="color:#8a8a8a;font-size:12px;margin-top:12px">' +
|
||||
'© 2026 Martinhal IT - Joao Vaz - Version 2.3</p>';
|
||||
return {
|
||||
subject: render(t.subject, vars),
|
||||
html: render(t.body_html, vars) + footer,
|
||||
};
|
||||
}
|
||||
|
||||
const PLACEHOLDERS = {
|
||||
request_to_admin: ['username', 'email', 'target_type', 'target_name', 'created_at',
|
||||
'request_id', 'pending_count', 'approvals_url', 'portal_url'],
|
||||
approval_to_user: ['username', 'email', 'target_type', 'target_name', 'portal_url',
|
||||
'validity', 'expires_at'],
|
||||
denial_to_user: ['username', 'email', 'target_type', 'target_name', 'portal_url'],
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
seedDefaults, getTemplate, render, buildEmail,
|
||||
DEFAULTS, PLACEHOLDERS, KEYS: Object.keys(DEFAULTS),
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared validation for user account fields.
|
||||
* Email is MANDATORY on every account: it is how approval notifications and
|
||||
* system messages reach people, so an account without one cannot function.
|
||||
*/
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
const USERNAME_RE = /^[A-Za-z0-9._-]{3,32}$/;
|
||||
|
||||
/**
|
||||
* Normalise an email for storage: trim surrounding whitespace.
|
||||
* Returns '' for null/undefined so callers can treat "missing" and "blank"
|
||||
* the same way.
|
||||
*/
|
||||
function normaliseEmail(value) {
|
||||
return value === undefined || value === null ? '' : String(value).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a mandatory email address.
|
||||
* @returns {string|null} an error message, or null when valid.
|
||||
*/
|
||||
function validateEmail(value) {
|
||||
const email = normaliseEmail(value);
|
||||
if (!email) return 'An email address is required.';
|
||||
if (email.length > 254) return 'That email address is too long.';
|
||||
if (!EMAIL_RE.test(email)) return 'Please enter a valid email address.';
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateUsername(value) {
|
||||
const username = value === undefined || value === null ? '' : String(value).trim();
|
||||
if (!username) return 'A username is required.';
|
||||
if (!USERNAME_RE.test(username)) {
|
||||
return 'Username must be 3-32 characters: letters, numbers, dot, underscore or hyphen.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validatePassword(value) {
|
||||
const password = value === undefined || value === null ? '' : String(value);
|
||||
if (!password) return 'A password is required.';
|
||||
if (password.length < 10) return 'Password must be at least 10 characters long.';
|
||||
if (!/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) {
|
||||
return 'Password must contain at least one letter and one number.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EMAIL_RE, USERNAME_RE, normaliseEmail, validateEmail, validateUsername, validatePassword,
|
||||
};
|
||||
Reference in New Issue
Block a user