Add health check utility, logger, metrics, backup, and SMTP toggle scripts
- Implemented a comprehensive health check utility to monitor system dependencies including NocoDB, SMTP, Represent API, disk space, and memory usage. - Created a logger utility using Winston for structured logging with daily rotation and various log levels. - Developed a metrics utility using Prometheus client to track application performance metrics such as email sends, HTTP requests, and user activity. - Added a backup script for automated backups of NocoDB data, uploaded files, and environment configurations with optional S3 support. - Introduced a toggle script to switch between development (MailHog) and production (ProtonMail) SMTP configurations.
This commit is contained in:
@@ -2,6 +2,9 @@ FROM node:18-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
|
||||
104
influence/app/middleware/csrf.js
Normal file
104
influence/app/middleware/csrf.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const csrf = require('csurf');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Create CSRF protection middleware
|
||||
const csrfProtection = csrf({
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
|
||||
sameSite: 'strict',
|
||||
maxAge: 3600000 // 1 hour
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware to handle CSRF token errors
|
||||
*/
|
||||
const csrfErrorHandler = (err, req, res, next) => {
|
||||
if (err.code === 'EBADCSRFTOKEN') {
|
||||
logger.warn('CSRF token validation failed', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid CSRF token',
|
||||
message: 'Your session has expired or the request is invalid. Please refresh the page and try again.'
|
||||
});
|
||||
}
|
||||
|
||||
next(err);
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to inject CSRF token into response
|
||||
* Adds csrfToken to all JSON responses and as a header
|
||||
*/
|
||||
const injectCsrfToken = (req, res, next) => {
|
||||
// Add token to response locals for template rendering
|
||||
res.locals.csrfToken = req.csrfToken();
|
||||
|
||||
// Override json method to automatically include CSRF token
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = function(data) {
|
||||
if (data && typeof data === 'object' && !data.csrfToken) {
|
||||
data.csrfToken = res.locals.csrfToken;
|
||||
}
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip CSRF protection for specific routes (e.g., webhooks, public APIs)
|
||||
*/
|
||||
const csrfExemptRoutes = [
|
||||
'/api/health',
|
||||
'/api/metrics',
|
||||
'/api/config',
|
||||
'/api/auth/login', // Login uses credentials for authentication
|
||||
'/api/auth/session', // Session check is read-only
|
||||
'/api/representatives/postal/', // Read-only operation
|
||||
'/api/campaigns/public' // Public read operations
|
||||
];
|
||||
|
||||
const conditionalCsrfProtection = (req, res, next) => {
|
||||
// Skip CSRF for exempt routes
|
||||
const isExempt = csrfExemptRoutes.some(route => req.path.startsWith(route));
|
||||
|
||||
// Skip CSRF for GET, HEAD, OPTIONS (safe methods)
|
||||
const isSafeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method);
|
||||
|
||||
if (isExempt || isSafeMethod) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Apply CSRF protection for state-changing operations
|
||||
csrfProtection(req, res, (err) => {
|
||||
if (err) {
|
||||
return csrfErrorHandler(err, req, res, next);
|
||||
}
|
||||
injectCsrfToken(req, res, next);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get CSRF token for client-side use
|
||||
*/
|
||||
const getCsrfToken = (req, res) => {
|
||||
res.json({
|
||||
csrfToken: req.csrfToken()
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
csrfProtection,
|
||||
csrfErrorHandler,
|
||||
injectCsrfToken,
|
||||
conditionalCsrfProtection,
|
||||
getCsrfToken
|
||||
};
|
||||
@@ -29,7 +29,16 @@
|
||||
"express-session": "^1.17.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"qrcode": "^1.5.3"
|
||||
"qrcode": "^1.5.3",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"compression": "^1.7.4",
|
||||
"csurf": "^1.11.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"bull": "^4.12.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"sharp": "^0.33.0",
|
||||
"ioredis": "^5.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
|
||||
@@ -130,6 +130,75 @@
|
||||
border-color: rgba(40, 167, 69, 1);
|
||||
}
|
||||
|
||||
.share-more-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.share-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
background: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.share-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.share-dropdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.share-dropdown-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.share-dropdown-item:hover {
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.share-dropdown-item svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 0.25rem;
|
||||
fill: #2c3e50;
|
||||
}
|
||||
|
||||
.share-dropdown-item span {
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.share-btn-small.more-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.share-btn-small.more-btn.active {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.campaign-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
@@ -463,40 +532,110 @@
|
||||
|
||||
<!-- Social Share Buttons in Header -->
|
||||
<div class="share-buttons-header">
|
||||
<button class="share-btn-small" id="share-facebook" title="Share on Facebook">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-linkedin" title="Share on LinkedIn">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-whatsapp" title="Share on WhatsApp">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-email" title="Share via Email">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-copy" title="Copy Link">
|
||||
<!-- Expandable Social Menu -->
|
||||
<div class="share-socials-container">
|
||||
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
|
||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
|
||||
</svg>
|
||||
<span>Socials</span>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Expandable Social Options -->
|
||||
<div class="share-socials-menu" id="share-socials-menu">
|
||||
<button class="share-btn-small" id="share-facebook" title="Facebook">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-instagram" title="Instagram">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-reddit" title="Reddit">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-threads" title="Threads">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-telegram" title="Telegram">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-sms" title="SMS">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-slack" title="Slack">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-discord" title="Discord">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-print" title="Print">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-email" title="Email">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Always-visible buttons -->
|
||||
<button class="share-btn-primary" id="share-copy" title="Copy Link">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
</svg>
|
||||
<span>Copy Link</span>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-qrcode" title="Show QR Code">
|
||||
<button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
|
||||
</svg>
|
||||
<span>QR Code</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,15 +683,6 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Response Wall Button -->
|
||||
<div id="response-wall-section" class="response-wall-container" style="display: none;">
|
||||
<h3>💬 See What People Are Saying</h3>
|
||||
<p>Check out responses to people who have taken action on this campaign</p>
|
||||
<a href="#" id="response-wall-link" class="response-wall-button">
|
||||
View Response Wall
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Email Preview -->
|
||||
<div id="email-preview" class="email-preview preview-mode" style="display: none;">
|
||||
<h3>📧 Email Preview</h3>
|
||||
@@ -599,6 +729,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Wall Button -->
|
||||
<div id="response-wall-section" class="response-wall-container" style="display: none;">
|
||||
<h3>💬 See What People Are Saying</h3>
|
||||
<p>Check out responses to people who have taken action on this campaign</p>
|
||||
<a href="#" id="response-wall-link" class="response-wall-button">
|
||||
View Response Wall
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div id="success-section" style="display: none; text-align: center; padding: 2rem;">
|
||||
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>
|
||||
|
||||
@@ -98,10 +98,185 @@
|
||||
/* Social Share Buttons in Header */
|
||||
.share-buttons-header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Primary Share Buttons */
|
||||
.share-btn-primary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.share-btn-primary:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.share-btn-primary svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.share-btn-primary.copied {
|
||||
background: rgba(40, 167, 69, 0.9);
|
||||
border-color: rgba(40, 167, 69, 1);
|
||||
}
|
||||
|
||||
/* Expandable Social Menu Container */
|
||||
.share-socials-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.share-socials-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
padding: 0.75rem;
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
max-width: 320px;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.share-socials-menu.show {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* Chevron icon rotation */
|
||||
.share-btn-primary .chevron-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: white;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.share-btn-primary.active .chevron-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Share icon */
|
||||
.share-btn-primary .share-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* Social buttons inside menu */
|
||||
.share-socials-menu button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.share-socials-menu button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.share-socials-menu button svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* Platform-specific colors */
|
||||
#share-facebook {
|
||||
background: #1877f2;
|
||||
}
|
||||
|
||||
#share-twitter {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
#share-linkedin {
|
||||
background: #0077b5;
|
||||
}
|
||||
|
||||
#share-whatsapp {
|
||||
background: #25d366;
|
||||
}
|
||||
|
||||
#share-bluesky {
|
||||
background: #1185fe;
|
||||
}
|
||||
|
||||
#share-instagram {
|
||||
background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
|
||||
}
|
||||
|
||||
#share-reddit {
|
||||
background: #ff4500;
|
||||
}
|
||||
|
||||
#share-threads {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
#share-telegram {
|
||||
background: #0088cc;
|
||||
}
|
||||
|
||||
#share-mastodon {
|
||||
background: #6364ff;
|
||||
}
|
||||
|
||||
#share-sms {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
#share-slack {
|
||||
background: #4a154b;
|
||||
}
|
||||
|
||||
#share-discord {
|
||||
background: #5865f2;
|
||||
}
|
||||
|
||||
#share-print {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
#share-email {
|
||||
background: #ea4335;
|
||||
}
|
||||
|
||||
.share-btn-small {
|
||||
|
||||
@@ -1686,6 +1686,17 @@ footer a:hover {
|
||||
.campaigns-section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.8s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.campaigns-section-header h2 {
|
||||
@@ -1717,8 +1728,37 @@ footer a:hover {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
animation: campaignFadeInUp 0.6s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes campaignFadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Staggered animation delays for campaign cards */
|
||||
.campaign-card:nth-child(1) { animation-delay: 0.1s; }
|
||||
.campaign-card:nth-child(2) { animation-delay: 0.2s; }
|
||||
.campaign-card:nth-child(3) { animation-delay: 0.3s; }
|
||||
.campaign-card:nth-child(4) { animation-delay: 0.4s; }
|
||||
.campaign-card:nth-child(5) { animation-delay: 0.5s; }
|
||||
.campaign-card:nth-child(6) { animation-delay: 0.6s; }
|
||||
.campaign-card:nth-child(7) { animation-delay: 0.7s; }
|
||||
.campaign-card:nth-child(8) { animation-delay: 0.8s; }
|
||||
.campaign-card:nth-child(9) { animation-delay: 0.9s; }
|
||||
.campaign-card:nth-child(10) { animation-delay: 1.0s; }
|
||||
.campaign-card:nth-child(11) { animation-delay: 1.1s; }
|
||||
.campaign-card:nth-child(12) { animation-delay: 1.2s; }
|
||||
/* For campaigns beyond 12, they'll still animate but without additional delay */
|
||||
|
||||
.campaign-card:hover,
|
||||
.campaign-card:focus {
|
||||
transform: translateY(-5px);
|
||||
@@ -1735,6 +1775,33 @@ footer a:hover {
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.campaign-card-image::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: inherit;
|
||||
transform: scale(1.1);
|
||||
animation: imageZoomIn 0.8s ease forwards;
|
||||
animation-delay: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes imageZoomIn {
|
||||
from {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.campaign-card-overlay {
|
||||
@@ -1743,12 +1810,13 @@ footer a:hover {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.7) 100%);
|
||||
transition: background 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.campaign-card:hover .campaign-card-overlay {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
}
|
||||
|
||||
.campaign-card-content {
|
||||
@@ -1759,11 +1827,22 @@ footer a:hover {
|
||||
}
|
||||
|
||||
.campaign-card-title {
|
||||
color: #005a9c;
|
||||
font-size: 1.4em;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
font-size: 1.5em;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
transition: transform 0.3s ease, text-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.campaign-card:hover .campaign-card-title {
|
||||
transform: translateY(-2px);
|
||||
text-shadow: 0 3px 12px rgba(0, 0, 0, 0.6), 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.campaign-card-description {
|
||||
@@ -1771,6 +1850,7 @@ footer a:hover {
|
||||
font-size: 0.95em;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1960,6 +2040,8 @@ footer a:hover {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.campaigns-loading .spinner {
|
||||
@@ -2142,3 +2224,187 @@ footer a:hover {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Expandable Social Share Menu Styles */
|
||||
.share-buttons-header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Primary Share Buttons */
|
||||
.share-btn-primary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.share-btn-primary:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.share-btn-primary svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.share-btn-primary.copied {
|
||||
background: rgba(40, 167, 69, 0.9);
|
||||
border-color: rgba(40, 167, 69, 1);
|
||||
}
|
||||
|
||||
/* Expandable Social Menu Container */
|
||||
.share-socials-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.share-socials-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
padding: 0.75rem;
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
max-width: 320px;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.share-socials-menu.show {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* Chevron icon rotation */
|
||||
.share-btn-primary .chevron-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: white;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.share-btn-primary.active .chevron-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Share icon */
|
||||
.share-btn-primary .share-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* Social buttons inside menu */
|
||||
.share-socials-menu button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.share-socials-menu button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.share-socials-menu button svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* Platform-specific colors */
|
||||
#share-facebook {
|
||||
background: #1877f2;
|
||||
}
|
||||
|
||||
#share-twitter {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
#share-linkedin {
|
||||
background: #0077b5;
|
||||
}
|
||||
|
||||
#share-whatsapp {
|
||||
background: #25d366;
|
||||
}
|
||||
|
||||
#share-bluesky {
|
||||
background: #1185fe;
|
||||
}
|
||||
|
||||
#share-instagram {
|
||||
background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
|
||||
}
|
||||
|
||||
#share-reddit {
|
||||
background: #ff4500;
|
||||
}
|
||||
|
||||
#share-threads {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
#share-telegram {
|
||||
background: #0088cc;
|
||||
}
|
||||
|
||||
#share-mastodon {
|
||||
background: #6364ff;
|
||||
}
|
||||
|
||||
#share-sms {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
#share-slack {
|
||||
background: #4a154b;
|
||||
}
|
||||
|
||||
#share-discord {
|
||||
background: #5865f2;
|
||||
}
|
||||
|
||||
#share-print {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
#share-email {
|
||||
background: #ea4335;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,27 @@ class CampaignPage {
|
||||
setupShareButtons() {
|
||||
// Get current URL
|
||||
const shareUrl = window.location.href;
|
||||
|
||||
|
||||
// Social menu toggle
|
||||
const socialsToggle = document.getElementById('share-socials-toggle');
|
||||
const socialsMenu = document.getElementById('share-socials-menu');
|
||||
|
||||
if (socialsToggle && socialsMenu) {
|
||||
socialsToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
socialsMenu.classList.toggle('show');
|
||||
socialsToggle.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.share-socials-container')) {
|
||||
socialsMenu.classList.remove('show');
|
||||
socialsToggle.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Facebook share
|
||||
document.getElementById('share-facebook')?.addEventListener('click', () => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
|
||||
@@ -60,6 +80,93 @@ class CampaignPage {
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
|
||||
// Bluesky share
|
||||
document.getElementById('share-bluesky')?.addEventListener('click', () => {
|
||||
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
});
|
||||
|
||||
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
|
||||
document.getElementById('share-instagram')?.addEventListener('click', () => {
|
||||
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
|
||||
// Automatically copy the link
|
||||
navigator.clipboard.writeText(shareUrl).catch(() => {
|
||||
console.log('Failed to copy link automatically');
|
||||
});
|
||||
});
|
||||
|
||||
// Reddit share
|
||||
document.getElementById('share-reddit')?.addEventListener('click', () => {
|
||||
const title = this.campaign ? `${this.campaign.title}` : 'Check out this campaign';
|
||||
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
|
||||
window.open(url, '_blank', 'width=800,height=600');
|
||||
});
|
||||
|
||||
// Threads share
|
||||
document.getElementById('share-threads')?.addEventListener('click', () => {
|
||||
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
});
|
||||
|
||||
// Telegram share
|
||||
document.getElementById('share-telegram')?.addEventListener('click', () => {
|
||||
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
});
|
||||
|
||||
// Mastodon share
|
||||
document.getElementById('share-mastodon')?.addEventListener('click', () => {
|
||||
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||
// Mastodon requires instance selection - opens a composer with text
|
||||
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
|
||||
if (instance) {
|
||||
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
}
|
||||
});
|
||||
|
||||
// SMS share
|
||||
document.getElementById('share-sms')?.addEventListener('click', () => {
|
||||
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||
const body = text + ' ' + shareUrl;
|
||||
// Use Web Share API if available, otherwise fallback to SMS protocol
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: this.campaign ? this.campaign.title : 'Campaign',
|
||||
text: body
|
||||
}).catch(() => {
|
||||
// Fallback to SMS protocol
|
||||
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
|
||||
});
|
||||
} else {
|
||||
// SMS protocol (works on mobile)
|
||||
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Slack share
|
||||
document.getElementById('share-slack')?.addEventListener('click', () => {
|
||||
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
});
|
||||
|
||||
// Discord share
|
||||
document.getElementById('share-discord')?.addEventListener('click', () => {
|
||||
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
|
||||
// Automatically copy the link
|
||||
navigator.clipboard.writeText(shareUrl).catch(() => {
|
||||
console.log('Failed to copy link automatically');
|
||||
});
|
||||
});
|
||||
|
||||
// Print/PDF share
|
||||
document.getElementById('share-print')?.addEventListener('click', () => {
|
||||
window.print();
|
||||
});
|
||||
|
||||
// Email share
|
||||
document.getElementById('share-email')?.addEventListener('click', () => {
|
||||
const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||
|
||||
@@ -72,11 +72,27 @@ class CampaignsGrid {
|
||||
|
||||
const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
|
||||
this.container.innerHTML = campaignsHTML;
|
||||
|
||||
|
||||
// Trigger animations by forcing a reflow
|
||||
this.triggerAnimations();
|
||||
|
||||
// Add click event listeners to campaign cards (no inline handlers)
|
||||
this.attachCardClickHandlers();
|
||||
}
|
||||
|
||||
triggerAnimations() {
|
||||
// Force reflow to restart CSS animations when campaigns are re-rendered
|
||||
const cards = this.container.querySelectorAll('.campaign-card');
|
||||
cards.forEach((card, index) => {
|
||||
// Remove and re-add animation to restart it
|
||||
card.style.animation = 'none';
|
||||
// Force reflow
|
||||
void card.offsetHeight;
|
||||
// Re-apply animation with staggered delay
|
||||
card.style.animation = `campaignFadeInUp 0.6s ease ${0.1 * (index + 1)}s forwards`;
|
||||
});
|
||||
}
|
||||
|
||||
attachCardClickHandlers() {
|
||||
const campaignCards = this.container.querySelectorAll('.campaign-card');
|
||||
campaignCards.forEach(card => {
|
||||
@@ -157,9 +173,9 @@ class CampaignsGrid {
|
||||
<div class="campaign-card" data-slug="${campaign.slug}">
|
||||
<div class="campaign-card-image" style="${coverPhotoStyle}">
|
||||
<div class="campaign-card-overlay"></div>
|
||||
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
|
||||
</div>
|
||||
<div class="campaign-card-content">
|
||||
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
|
||||
<p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p>
|
||||
${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''}
|
||||
<div class="campaign-card-stats">
|
||||
|
||||
@@ -342,7 +342,27 @@ function renderCampaignHeader() {
|
||||
// Setup social share buttons
|
||||
function setupShareButtons() {
|
||||
const shareUrl = window.location.href;
|
||||
|
||||
|
||||
// Social menu toggle
|
||||
const socialsToggle = document.getElementById('share-socials-toggle');
|
||||
const socialsMenu = document.getElementById('share-socials-menu');
|
||||
|
||||
if (socialsToggle && socialsMenu) {
|
||||
socialsToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
socialsMenu.classList.toggle('show');
|
||||
socialsToggle.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.share-socials-container')) {
|
||||
socialsMenu.classList.remove('show');
|
||||
socialsToggle.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Facebook share
|
||||
document.getElementById('share-facebook')?.addEventListener('click', () => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
|
||||
@@ -369,6 +389,93 @@ function setupShareButtons() {
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
|
||||
// Bluesky share
|
||||
document.getElementById('share-bluesky')?.addEventListener('click', () => {
|
||||
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
||||
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
});
|
||||
|
||||
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
|
||||
document.getElementById('share-instagram')?.addEventListener('click', () => {
|
||||
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
|
||||
// Automatically copy the link
|
||||
navigator.clipboard.writeText(shareUrl).catch(() => {
|
||||
console.log('Failed to copy link automatically');
|
||||
});
|
||||
});
|
||||
|
||||
// Reddit share
|
||||
document.getElementById('share-reddit')?.addEventListener('click', () => {
|
||||
const title = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
|
||||
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
|
||||
window.open(url, '_blank', 'width=800,height=600');
|
||||
});
|
||||
|
||||
// Threads share
|
||||
document.getElementById('share-threads')?.addEventListener('click', () => {
|
||||
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
||||
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
});
|
||||
|
||||
// Telegram share
|
||||
document.getElementById('share-telegram')?.addEventListener('click', () => {
|
||||
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
||||
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
});
|
||||
|
||||
// Mastodon share
|
||||
document.getElementById('share-mastodon')?.addEventListener('click', () => {
|
||||
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
||||
// Mastodon requires instance selection - opens a composer with text
|
||||
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
|
||||
if (instance) {
|
||||
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
}
|
||||
});
|
||||
|
||||
// SMS share
|
||||
document.getElementById('share-sms')?.addEventListener('click', () => {
|
||||
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
||||
const body = text + ' ' + shareUrl;
|
||||
// Use Web Share API if available, otherwise fallback to SMS protocol
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: currentCampaign ? currentCampaign.title : 'Response Wall',
|
||||
text: body
|
||||
}).catch(() => {
|
||||
// Fallback to SMS protocol
|
||||
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
|
||||
});
|
||||
} else {
|
||||
// SMS protocol (works on mobile)
|
||||
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Slack share
|
||||
document.getElementById('share-slack')?.addEventListener('click', () => {
|
||||
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=600,height=400');
|
||||
});
|
||||
|
||||
// Discord share
|
||||
document.getElementById('share-discord')?.addEventListener('click', () => {
|
||||
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
|
||||
// Automatically copy the link
|
||||
navigator.clipboard.writeText(shareUrl).catch(() => {
|
||||
console.log('Failed to copy link automatically');
|
||||
});
|
||||
});
|
||||
|
||||
// Print/PDF share
|
||||
document.getElementById('share-print')?.addEventListener('click', () => {
|
||||
window.print();
|
||||
});
|
||||
|
||||
// Email share
|
||||
document.getElementById('share-email')?.addEventListener('click', () => {
|
||||
const subject = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
|
||||
|
||||
@@ -26,40 +26,80 @@
|
||||
|
||||
<!-- Social Share Buttons in Header -->
|
||||
<div class="share-buttons-header">
|
||||
<button class="share-btn-small" id="share-facebook" title="Share on Facebook">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-linkedin" title="Share on LinkedIn">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-whatsapp" title="Share on WhatsApp">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-email" title="Share via Email">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-copy" title="Copy Link">
|
||||
<!-- Expandable Social Menu -->
|
||||
<div class="share-socials-container">
|
||||
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
|
||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
|
||||
</svg>
|
||||
<span>Socials</span>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Expandable Social Options -->
|
||||
<div class="share-socials-menu" id="share-socials-menu">
|
||||
<button class="share-btn-small" id="share-facebook" title="Facebook">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-instagram" title="Instagram">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-reddit" title="Reddit">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-threads" title="Threads">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-telegram" title="Telegram">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-sms" title="SMS">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-slack" title="Slack">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-discord" title="Discord">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-print" title="Print">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/></svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-email" title="Email">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Always-visible buttons -->
|
||||
<button class="share-btn-primary" id="share-copy" title="Copy Link">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
</svg>
|
||||
<span>Copy Link</span>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-qrcode" title="Show QR Code">
|
||||
<button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
|
||||
</svg>
|
||||
<span>QR Code</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,16 @@ const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const session = require('express-session');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const logger = require('./utils/logger');
|
||||
const metrics = require('./utils/metrics');
|
||||
const healthCheck = require('./utils/health-check');
|
||||
const { conditionalCsrfProtection, getCsrfToken } = require('./middleware/csrf');
|
||||
|
||||
const apiRoutes = require('./routes/api');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { requireAdmin, requireAuth } = require('./middleware/auth');
|
||||
@@ -16,6 +23,17 @@ const PORT = process.env.PORT || 3333;
|
||||
// Only trust Docker internal networks for better security
|
||||
app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']);
|
||||
|
||||
// Compression middleware for better performance
|
||||
app.use(compression({
|
||||
filter: (req, res) => {
|
||||
if (req.headers['x-no-compression']) {
|
||||
return false;
|
||||
}
|
||||
return compression.filter(req, res);
|
||||
},
|
||||
level: 6 // Balance between speed and compression ratio
|
||||
}));
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
@@ -31,22 +49,89 @@ app.use(helmet({
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Session configuration
|
||||
// Metrics middleware - track all HTTP requests
|
||||
app.use(metrics.middleware);
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
logger.logRequest(req, res, duration);
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Session configuration - PRODUCTION HARDENED
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
name: 'influence.sid', // Custom session name for security
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true', // HTTPS only in production
|
||||
httpOnly: true, // Prevent JavaScript access
|
||||
maxAge: 3600000, // 1 hour (reduced from 24 hours)
|
||||
sameSite: 'strict' // CSRF protection
|
||||
}
|
||||
}));
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
// CSRF Protection - Applied conditionally
|
||||
app.use(conditionalCsrfProtection);
|
||||
|
||||
// Static files with proper caching
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0,
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
setHeaders: (res, filePath) => {
|
||||
// Cache images and assets longer
|
||||
if (filePath.match(/\.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$/)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
|
||||
}
|
||||
// Cache CSS and JS for 1 day
|
||||
else if (filePath.match(/\.(css|js)$/)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Health check endpoint - COMPREHENSIVE
|
||||
app.get('/api/health', async (req, res) => {
|
||||
try {
|
||||
const health = await healthCheck.checkAll();
|
||||
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
} catch (error) {
|
||||
logger.error('Health check failed', { error: error.message });
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Metrics endpoint for Prometheus
|
||||
app.get('/api/metrics', async (req, res) => {
|
||||
try {
|
||||
res.set('Content-Type', metrics.getContentType());
|
||||
const metricsData = await metrics.getMetrics();
|
||||
res.end(metricsData);
|
||||
} catch (error) {
|
||||
logger.error('Metrics endpoint failed', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to generate metrics' });
|
||||
}
|
||||
});
|
||||
|
||||
// CSRF token endpoint
|
||||
app.get('/api/csrf-token', getCsrfToken);
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
@@ -100,21 +185,54 @@ app.get('/campaign/:slug', (req, res) => {
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
logger.error('Application error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
|
||||
res.status(err.status || 500).json({
|
||||
error: 'Something went wrong!',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
logger.warn('Route not found', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info('Server started', {
|
||||
port: PORT,
|
||||
environment: process.env.NODE_ENV,
|
||||
nodeVersion: process.version
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
368
influence/app/services/emailQueue.js
Normal file
368
influence/app/services/emailQueue.js
Normal file
@@ -0,0 +1,368 @@
|
||||
const Queue = require('bull');
|
||||
const logger = require('./logger');
|
||||
const metrics = require('./metrics');
|
||||
const emailService = require('../services/email');
|
||||
|
||||
// Configure Redis connection for Bull
|
||||
const redisConfig = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB || '0'),
|
||||
// Retry strategy for connection failures
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
// Enable offline queue
|
||||
enableOfflineQueue: true,
|
||||
maxRetriesPerRequest: 3
|
||||
};
|
||||
|
||||
// Create email queue
|
||||
const emailQueue = new Queue('email-queue', {
|
||||
redis: redisConfig,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000 // Start with 2 seconds, then 4, 8, etc.
|
||||
},
|
||||
removeOnComplete: {
|
||||
age: 24 * 3600, // Keep completed jobs for 24 hours
|
||||
count: 1000 // Keep last 1000 completed jobs
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 7 * 24 * 3600 // Keep failed jobs for 7 days
|
||||
},
|
||||
timeout: 30000 // 30 seconds timeout per job
|
||||
}
|
||||
});
|
||||
|
||||
// Process email jobs
|
||||
emailQueue.process(async (job) => {
|
||||
const { type, data } = job.data;
|
||||
const start = Date.now();
|
||||
|
||||
logger.info('Processing email job', {
|
||||
jobId: job.id,
|
||||
type,
|
||||
attempt: job.attemptsMade + 1,
|
||||
maxAttempts: job.opts.attempts
|
||||
});
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (type) {
|
||||
case 'campaign':
|
||||
result = await emailService.sendCampaignEmail(data);
|
||||
break;
|
||||
|
||||
case 'verification':
|
||||
result = await emailService.sendVerificationEmail(data);
|
||||
break;
|
||||
|
||||
case 'login-details':
|
||||
result = await emailService.sendLoginDetails(data);
|
||||
break;
|
||||
|
||||
case 'broadcast':
|
||||
result = await emailService.sendBroadcast(data);
|
||||
break;
|
||||
|
||||
case 'response-verification':
|
||||
result = await emailService.sendResponseVerificationEmail(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown email type: ${type}`);
|
||||
}
|
||||
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
|
||||
// Record metrics
|
||||
metrics.recordEmailSent(
|
||||
data.campaignId || 'system',
|
||||
data.representativeLevel || 'unknown'
|
||||
);
|
||||
metrics.observeEmailSendDuration(
|
||||
data.campaignId || 'system',
|
||||
duration
|
||||
);
|
||||
|
||||
logger.logEmailSent(
|
||||
data.to || data.email,
|
||||
data.campaignId || type,
|
||||
'success'
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
|
||||
// Record failure metrics
|
||||
metrics.recordEmailFailed(
|
||||
data.campaignId || 'system',
|
||||
error.code || 'unknown'
|
||||
);
|
||||
|
||||
logger.logEmailFailed(
|
||||
data.to || data.email,
|
||||
data.campaignId || type,
|
||||
error
|
||||
);
|
||||
|
||||
// Throw error to trigger retry
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Queue event handlers
|
||||
emailQueue.on('completed', (job, result) => {
|
||||
logger.info('Email job completed', {
|
||||
jobId: job.id,
|
||||
type: job.data.type,
|
||||
duration: Date.now() - job.timestamp
|
||||
});
|
||||
});
|
||||
|
||||
emailQueue.on('failed', (job, err) => {
|
||||
logger.error('Email job failed', {
|
||||
jobId: job.id,
|
||||
type: job.data.type,
|
||||
attempt: job.attemptsMade,
|
||||
maxAttempts: job.opts.attempts,
|
||||
error: err.message,
|
||||
willRetry: job.attemptsMade < job.opts.attempts
|
||||
});
|
||||
});
|
||||
|
||||
emailQueue.on('stalled', (job) => {
|
||||
logger.warn('Email job stalled', {
|
||||
jobId: job.id,
|
||||
type: job.data.type
|
||||
});
|
||||
});
|
||||
|
||||
emailQueue.on('error', (error) => {
|
||||
logger.error('Email queue error', { error: error.message });
|
||||
});
|
||||
|
||||
// Update queue size metric every 10 seconds
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const counts = await emailQueue.getJobCounts();
|
||||
const queueSize = counts.waiting + counts.active;
|
||||
metrics.setEmailQueueSize(queueSize);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update queue metrics', { error: error.message });
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
/**
|
||||
* Email Queue Service
|
||||
* Provides methods to enqueue different types of emails
|
||||
*/
|
||||
class EmailQueueService {
|
||||
/**
|
||||
* Send campaign email (to representative)
|
||||
*/
|
||||
async sendCampaignEmail(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'campaign',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 2, // Normal priority
|
||||
jobId: `campaign-${emailData.campaignId}-${Date.now()}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Campaign email queued', {
|
||||
jobId: job.id,
|
||||
campaignId: emailData.campaignId,
|
||||
recipient: emailData.to
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification
|
||||
*/
|
||||
async sendVerificationEmail(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'verification',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 1, // High priority - user waiting
|
||||
jobId: `verification-${emailData.email}-${Date.now()}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Verification email queued', {
|
||||
jobId: job.id,
|
||||
email: emailData.email
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send login details
|
||||
*/
|
||||
async sendLoginDetails(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'login-details',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 1, // High priority
|
||||
jobId: `login-${emailData.email}-${Date.now()}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Login details email queued', {
|
||||
jobId: job.id,
|
||||
email: emailData.email
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast to users
|
||||
*/
|
||||
async sendBroadcast(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'broadcast',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 3, // Lower priority - batch operation
|
||||
jobId: `broadcast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Broadcast email queued', {
|
||||
jobId: job.id,
|
||||
recipientCount: emailData.recipients?.length || 1
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response verification email
|
||||
*/
|
||||
async sendResponseVerificationEmail(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'response-verification',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 2,
|
||||
jobId: `response-verification-${emailData.email}-${Date.now()}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Response verification email queued', {
|
||||
jobId: job.id,
|
||||
email: emailData.email
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job status
|
||||
*/
|
||||
async getJobStatus(jobId) {
|
||||
const job = await emailQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress();
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: state,
|
||||
progress,
|
||||
attempts: job.attemptsMade,
|
||||
data: job.data,
|
||||
createdAt: job.timestamp,
|
||||
processedAt: job.processedOn,
|
||||
finishedAt: job.finishedOn,
|
||||
failedReason: job.failedReason
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
async getQueueStats() {
|
||||
const counts = await emailQueue.getJobCounts();
|
||||
const jobs = {
|
||||
waiting: await emailQueue.getWaiting(0, 10),
|
||||
active: await emailQueue.getActive(0, 10),
|
||||
completed: await emailQueue.getCompleted(0, 10),
|
||||
failed: await emailQueue.getFailed(0, 10)
|
||||
};
|
||||
|
||||
return {
|
||||
counts,
|
||||
samples: {
|
||||
waiting: jobs.waiting.map(j => ({ id: j.id, type: j.data.type })),
|
||||
active: jobs.active.map(j => ({ id: j.id, type: j.data.type })),
|
||||
completed: jobs.completed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type })),
|
||||
failed: jobs.failed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type, reason: j.failedReason }))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old jobs
|
||||
*/
|
||||
async cleanQueue(grace = 24 * 3600 * 1000) {
|
||||
const cleaned = await emailQueue.clean(grace, 'completed');
|
||||
logger.info('Queue cleaned', { removedJobs: cleaned.length });
|
||||
return { cleaned: cleaned.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause queue
|
||||
*/
|
||||
async pauseQueue() {
|
||||
await emailQueue.pause();
|
||||
logger.warn('Email queue paused');
|
||||
return { paused: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume queue
|
||||
*/
|
||||
async resumeQueue() {
|
||||
await emailQueue.resume();
|
||||
logger.info('Email queue resumed');
|
||||
return { resumed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue instance (for advanced operations)
|
||||
*/
|
||||
getQueue() {
|
||||
return emailQueue;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailQueueService();
|
||||
350
influence/app/utils/analytics.js
Normal file
350
influence/app/utils/analytics.js
Normal file
@@ -0,0 +1,350 @@
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Analytics tracking utilities for the Influence application
|
||||
* Provides helpers for tracking campaign conversion, representative response rates,
|
||||
* user retention, and geographic participation
|
||||
*/
|
||||
class Analytics {
|
||||
constructor() {
|
||||
this.cache = {
|
||||
campaignStats: new Map(),
|
||||
userRetention: new Map(),
|
||||
geographicData: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track campaign conversion rate
|
||||
* @param {string} campaignId - Campaign identifier
|
||||
* @param {number} visitors - Number of unique visitors
|
||||
* @param {number} participants - Number of participants who took action
|
||||
*/
|
||||
trackCampaignConversion(campaignId, visitors, participants) {
|
||||
const conversionRate = visitors > 0 ? (participants / visitors) * 100 : 0;
|
||||
|
||||
const stats = {
|
||||
campaignId,
|
||||
visitors,
|
||||
participants,
|
||||
conversionRate: conversionRate.toFixed(2),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.cache.campaignStats.set(campaignId, stats);
|
||||
|
||||
logger.info('Campaign conversion tracked', {
|
||||
event: 'analytics_campaign_conversion',
|
||||
...stats
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate representative response rate
|
||||
* @param {string} representativeLevel - Level (Federal, Provincial, Municipal)
|
||||
* @param {number} emailsSent - Total emails sent
|
||||
* @param {number} responsesReceived - Total responses received
|
||||
*/
|
||||
calculateRepresentativeResponseRate(representativeLevel, emailsSent, responsesReceived) {
|
||||
const responseRate = emailsSent > 0 ? (responsesReceived / emailsSent) * 100 : 0;
|
||||
|
||||
const stats = {
|
||||
representativeLevel,
|
||||
emailsSent,
|
||||
responsesReceived,
|
||||
responseRate: responseRate.toFixed(2),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
logger.info('Representative response rate calculated', {
|
||||
event: 'analytics_response_rate',
|
||||
...stats
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track user retention
|
||||
* @param {string} userId - User identifier
|
||||
* @param {string} action - Action type (login, campaign_participation, etc.)
|
||||
*/
|
||||
trackUserRetention(userId, action) {
|
||||
let userData = this.cache.userRetention.get(userId) || {
|
||||
userId,
|
||||
firstSeen: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
actionCount: 0,
|
||||
actions: []
|
||||
};
|
||||
|
||||
userData.lastSeen = new Date().toISOString();
|
||||
userData.actionCount += 1;
|
||||
userData.actions.push({
|
||||
action,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Keep only last 100 actions per user
|
||||
if (userData.actions.length > 100) {
|
||||
userData.actions = userData.actions.slice(-100);
|
||||
}
|
||||
|
||||
this.cache.userRetention.set(userId, userData);
|
||||
|
||||
// Determine if user is one-time or repeat
|
||||
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
|
||||
const isRepeatUser = daysBetween > 0 || userData.actionCount > 1;
|
||||
|
||||
logger.info('User retention tracked', {
|
||||
event: 'analytics_user_retention',
|
||||
userId,
|
||||
action,
|
||||
isRepeatUser,
|
||||
totalActions: userData.actionCount
|
||||
});
|
||||
|
||||
return { ...userData, isRepeatUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Track geographic participation by postal code
|
||||
* @param {string} postalCode - Canadian postal code
|
||||
* @param {string} campaignId - Campaign identifier
|
||||
*/
|
||||
trackGeographicParticipation(postalCode, campaignId) {
|
||||
const postalPrefix = postalCode.substring(0, 3).toUpperCase();
|
||||
|
||||
let geoData = this.cache.geographicData.get(postalPrefix) || {
|
||||
postalPrefix,
|
||||
participationCount: 0,
|
||||
campaigns: new Set()
|
||||
};
|
||||
|
||||
geoData.participationCount += 1;
|
||||
geoData.campaigns.add(campaignId);
|
||||
|
||||
this.cache.geographicData.set(postalPrefix, geoData);
|
||||
|
||||
logger.info('Geographic participation tracked', {
|
||||
event: 'analytics_geographic_participation',
|
||||
postalPrefix,
|
||||
campaignId,
|
||||
totalParticipation: geoData.participationCount
|
||||
});
|
||||
|
||||
return {
|
||||
postalPrefix,
|
||||
participationCount: geoData.participationCount,
|
||||
uniqueCampaigns: geoData.campaigns.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get geographic heatmap data
|
||||
* @returns {Array} Array of postal code prefixes with participation counts
|
||||
*/
|
||||
getGeographicHeatmap() {
|
||||
const heatmapData = [];
|
||||
|
||||
for (const [postalPrefix, data] of this.cache.geographicData.entries()) {
|
||||
heatmapData.push({
|
||||
postalPrefix,
|
||||
participationCount: data.participationCount,
|
||||
uniqueCampaigns: data.campaigns.size
|
||||
});
|
||||
}
|
||||
|
||||
return heatmapData.sort((a, b) => b.participationCount - a.participationCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze peak usage times
|
||||
* @param {Array} events - Array of event objects with timestamps
|
||||
* @returns {Object} Peak usage analysis
|
||||
*/
|
||||
analyzePeakUsageTimes(events) {
|
||||
const hourCounts = new Array(24).fill(0);
|
||||
const dayOfWeekCounts = new Array(7).fill(0);
|
||||
|
||||
events.forEach(event => {
|
||||
const date = new Date(event.timestamp);
|
||||
const hour = date.getHours();
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
hourCounts[hour] += 1;
|
||||
dayOfWeekCounts[dayOfWeek] += 1;
|
||||
});
|
||||
|
||||
const peakHour = hourCounts.indexOf(Math.max(...hourCounts));
|
||||
const peakDay = dayOfWeekCounts.indexOf(Math.max(...dayOfWeekCounts));
|
||||
|
||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
const analysis = {
|
||||
peakHour: `${peakHour}:00`,
|
||||
peakDay: dayNames[peakDay],
|
||||
hourlyDistribution: hourCounts,
|
||||
dailyDistribution: dayOfWeekCounts,
|
||||
totalEvents: events.length
|
||||
};
|
||||
|
||||
logger.info('Peak usage times analyzed', {
|
||||
event: 'analytics_peak_usage',
|
||||
...analysis
|
||||
});
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track device breakdown
|
||||
* @param {string} userAgent - User agent string
|
||||
*/
|
||||
trackDeviceType(userAgent) {
|
||||
const isMobile = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent);
|
||||
const isTablet = /iPad|Android.*Tablet/i.test(userAgent);
|
||||
const isDesktop = !isMobile && !isTablet;
|
||||
|
||||
const deviceType = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
|
||||
|
||||
logger.info('Device type tracked', {
|
||||
event: 'analytics_device_type',
|
||||
deviceType,
|
||||
userAgent: userAgent.substring(0, 100)
|
||||
});
|
||||
|
||||
return deviceType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track email deliverability
|
||||
* @param {string} status - Status (success, bounce, spam, failed)
|
||||
* @param {string} campaignId - Campaign identifier
|
||||
* @param {Object} details - Additional details
|
||||
*/
|
||||
trackEmailDeliverability(status, campaignId, details = {}) {
|
||||
const deliverabilityData = {
|
||||
status,
|
||||
campaignId,
|
||||
timestamp: new Date().toISOString(),
|
||||
...details
|
||||
};
|
||||
|
||||
logger.info('Email deliverability tracked', {
|
||||
event: 'analytics_email_deliverability',
|
||||
...deliverabilityData
|
||||
});
|
||||
|
||||
return deliverabilityData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate campaign analytics report
|
||||
* @param {string} campaignId - Campaign identifier
|
||||
* @param {Object} data - Campaign data including visitors, participants, emails sent, etc.
|
||||
*/
|
||||
generateCampaignReport(campaignId, data) {
|
||||
const {
|
||||
visitors = 0,
|
||||
participants = 0,
|
||||
emailsSent = 0,
|
||||
emailsFailed = 0,
|
||||
responsesReceived = 0,
|
||||
shareCount = 0,
|
||||
avgTimeOnPage = 0
|
||||
} = data;
|
||||
|
||||
const conversionRate = visitors > 0 ? ((participants / visitors) * 100).toFixed(2) : 0;
|
||||
const emailSuccessRate = emailsSent > 0 ? (((emailsSent - emailsFailed) / emailsSent) * 100).toFixed(2) : 0;
|
||||
const responseRate = emailsSent > 0 ? ((responsesReceived / emailsSent) * 100).toFixed(2) : 0;
|
||||
const shareRate = participants > 0 ? ((shareCount / participants) * 100).toFixed(2) : 0;
|
||||
|
||||
const report = {
|
||||
campaignId,
|
||||
metrics: {
|
||||
visitors,
|
||||
participants,
|
||||
conversionRate: `${conversionRate}%`,
|
||||
emailsSent,
|
||||
emailsFailed,
|
||||
emailSuccessRate: `${emailSuccessRate}%`,
|
||||
responsesReceived,
|
||||
responseRate: `${responseRate}%`,
|
||||
shareCount,
|
||||
shareRate: `${shareRate}%`,
|
||||
avgTimeOnPage: `${avgTimeOnPage}s`
|
||||
},
|
||||
insights: {
|
||||
performance: conversionRate > 5 ? 'excellent' : conversionRate > 2 ? 'good' : 'needs improvement',
|
||||
emailHealth: emailSuccessRate > 95 ? 'excellent' : emailSuccessRate > 85 ? 'good' : 'poor',
|
||||
engagement: responseRate > 10 ? 'high' : responseRate > 3 ? 'medium' : 'low'
|
||||
},
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
logger.info('Campaign report generated', {
|
||||
event: 'analytics_campaign_report',
|
||||
campaignId,
|
||||
...report.metrics
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user retention summary
|
||||
* @returns {Object} User retention statistics
|
||||
*/
|
||||
getUserRetentionSummary() {
|
||||
let oneTimeUsers = 0;
|
||||
let repeatUsers = 0;
|
||||
let totalActions = 0;
|
||||
|
||||
for (const [userId, userData] of this.cache.userRetention.entries()) {
|
||||
totalActions += userData.actionCount;
|
||||
|
||||
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
|
||||
if (daysBetween > 0 || userData.actionCount > 1) {
|
||||
repeatUsers += 1;
|
||||
} else {
|
||||
oneTimeUsers += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const totalUsers = oneTimeUsers + repeatUsers;
|
||||
const retentionRate = totalUsers > 0 ? ((repeatUsers / totalUsers) * 100).toFixed(2) : 0;
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
oneTimeUsers,
|
||||
repeatUsers,
|
||||
retentionRate: `${retentionRate}%`,
|
||||
avgActionsPerUser: totalUsers > 0 ? (totalActions / totalUsers).toFixed(2) : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Calculate days between two dates
|
||||
*/
|
||||
getDaysBetween(date1, date2) {
|
||||
const d1 = new Date(date1);
|
||||
const d2 = new Date(date2);
|
||||
const diffTime = Math.abs(d2 - d1);
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache (for testing or memory management)
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.campaignStats.clear();
|
||||
this.cache.userRetention.clear();
|
||||
this.cache.geographicData.clear();
|
||||
logger.info('Analytics cache cleared');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Analytics();
|
||||
380
influence/app/utils/health-check.js
Normal file
380
influence/app/utils/health-check.js
Normal file
@@ -0,0 +1,380 @@
|
||||
const axios = require('axios');
|
||||
const os = require('os');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const nodemailer = require('nodemailer');
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Health check utility for monitoring all system dependencies
|
||||
*/
|
||||
class HealthCheck {
|
||||
constructor() {
|
||||
this.services = {
|
||||
nocodb: { name: 'NocoDB', healthy: false, lastCheck: null, details: {} },
|
||||
smtp: { name: 'SMTP Server', healthy: false, lastCheck: null, details: {} },
|
||||
representAPI: { name: 'Represent API', healthy: false, lastCheck: null, details: {} },
|
||||
disk: { name: 'Disk Space', healthy: false, lastCheck: null, details: {} },
|
||||
memory: { name: 'Memory', healthy: false, lastCheck: null, details: {} },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check NocoDB connectivity and API health
|
||||
*/
|
||||
async checkNocoDB() {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await axios.get(`${process.env.NOCODB_API_URL}/db/meta/projects`, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const duration = Date.now() - start;
|
||||
this.services.nocodb = {
|
||||
name: 'NocoDB',
|
||||
healthy: response.status === 200,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
status: response.status,
|
||||
responseTime: `${duration}ms`,
|
||||
url: process.env.NOCODB_API_URL
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('nocodb', 'healthy', { responseTime: duration });
|
||||
return this.services.nocodb;
|
||||
} catch (error) {
|
||||
this.services.nocodb = {
|
||||
name: 'NocoDB',
|
||||
healthy: false,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
error: error.message,
|
||||
url: process.env.NOCODB_API_URL
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('nocodb', 'unhealthy', { error: error.message });
|
||||
return this.services.nocodb;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SMTP server connectivity
|
||||
*/
|
||||
async checkSMTP() {
|
||||
const start = Date.now();
|
||||
|
||||
// Skip SMTP check if in test mode
|
||||
if (process.env.EMAIL_TEST_MODE === 'true') {
|
||||
this.services.smtp = {
|
||||
name: 'SMTP Server',
|
||||
healthy: true,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
mode: 'test',
|
||||
message: 'SMTP test mode enabled - emails logged only'
|
||||
}
|
||||
};
|
||||
return this.services.smtp;
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
},
|
||||
connectionTimeout: 5000
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
const duration = Date.now() - start;
|
||||
|
||||
this.services.smtp = {
|
||||
name: 'SMTP Server',
|
||||
healthy: true,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
responseTime: `${duration}ms`
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('smtp', 'healthy', { responseTime: duration });
|
||||
return this.services.smtp;
|
||||
} catch (error) {
|
||||
this.services.smtp = {
|
||||
name: 'SMTP Server',
|
||||
healthy: false,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
error: error.message,
|
||||
host: process.env.SMTP_HOST
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('smtp', 'unhealthy', { error: error.message });
|
||||
return this.services.smtp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Represent API availability
|
||||
*/
|
||||
async checkRepresentAPI() {
|
||||
const start = Date.now();
|
||||
const testUrl = `${process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca'}/representatives/house-of-commons/`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(testUrl, {
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const duration = Date.now() - start;
|
||||
this.services.representAPI = {
|
||||
name: 'Represent API',
|
||||
healthy: response.status === 200,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
status: response.status,
|
||||
responseTime: `${duration}ms`,
|
||||
url: testUrl
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('represent-api', 'healthy', { responseTime: duration });
|
||||
return this.services.representAPI;
|
||||
} catch (error) {
|
||||
this.services.representAPI = {
|
||||
name: 'Represent API',
|
||||
healthy: false,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
error: error.message,
|
||||
url: testUrl
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('represent-api', 'unhealthy', { error: error.message });
|
||||
return this.services.representAPI;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check disk space for uploads directory
|
||||
*/
|
||||
async checkDiskSpace() {
|
||||
try {
|
||||
const uploadsDir = path.join(__dirname, '../public/uploads');
|
||||
|
||||
// Ensure directory exists
|
||||
try {
|
||||
await fs.access(uploadsDir);
|
||||
} catch {
|
||||
await fs.mkdir(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Get directory size
|
||||
const directorySize = await this.getDirectorySize(uploadsDir);
|
||||
|
||||
// Get system disk usage (platform-specific approximation)
|
||||
const freeSpace = os.freemem();
|
||||
const totalSpace = os.totalmem();
|
||||
const usedPercentage = ((totalSpace - freeSpace) / totalSpace) * 100;
|
||||
|
||||
// Consider healthy if less than 90% full
|
||||
const healthy = usedPercentage < 90;
|
||||
|
||||
this.services.disk = {
|
||||
name: 'Disk Space',
|
||||
healthy,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
uploadsSize: this.formatBytes(directorySize),
|
||||
systemUsedPercentage: `${usedPercentage.toFixed(2)}%`,
|
||||
freeSpace: this.formatBytes(freeSpace),
|
||||
totalSpace: this.formatBytes(totalSpace),
|
||||
path: uploadsDir
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('disk', healthy ? 'healthy' : 'unhealthy', this.services.disk.details);
|
||||
return this.services.disk;
|
||||
} catch (error) {
|
||||
this.services.disk = {
|
||||
name: 'Disk Space',
|
||||
healthy: false,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
error: error.message
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('disk', 'unhealthy', { error: error.message });
|
||||
return this.services.disk;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check memory usage
|
||||
*/
|
||||
async checkMemory() {
|
||||
try {
|
||||
const totalMemory = os.totalmem();
|
||||
const freeMemory = os.freemem();
|
||||
const usedMemory = totalMemory - freeMemory;
|
||||
const usedPercentage = (usedMemory / totalMemory) * 100;
|
||||
|
||||
// Get Node.js process memory
|
||||
const processMemory = process.memoryUsage();
|
||||
|
||||
// Consider healthy if less than 85% used
|
||||
const healthy = usedPercentage < 85;
|
||||
|
||||
this.services.memory = {
|
||||
name: 'Memory',
|
||||
healthy,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
system: {
|
||||
total: this.formatBytes(totalMemory),
|
||||
used: this.formatBytes(usedMemory),
|
||||
free: this.formatBytes(freeMemory),
|
||||
usedPercentage: `${usedPercentage.toFixed(2)}%`
|
||||
},
|
||||
process: {
|
||||
rss: this.formatBytes(processMemory.rss),
|
||||
heapTotal: this.formatBytes(processMemory.heapTotal),
|
||||
heapUsed: this.formatBytes(processMemory.heapUsed),
|
||||
external: this.formatBytes(processMemory.external)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('memory', healthy ? 'healthy' : 'unhealthy', this.services.memory.details);
|
||||
return this.services.memory;
|
||||
} catch (error) {
|
||||
this.services.memory = {
|
||||
name: 'Memory',
|
||||
healthy: false,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: {
|
||||
error: error.message
|
||||
}
|
||||
};
|
||||
|
||||
logger.logHealthCheck('memory', 'unhealthy', { error: error.message });
|
||||
return this.services.memory;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all health checks
|
||||
*/
|
||||
async checkAll() {
|
||||
const results = await Promise.allSettled([
|
||||
this.checkNocoDB(),
|
||||
this.checkSMTP(),
|
||||
this.checkRepresentAPI(),
|
||||
this.checkDiskSpace(),
|
||||
this.checkMemory()
|
||||
]);
|
||||
|
||||
const overall = {
|
||||
status: Object.values(this.services).every(s => s.healthy) ? 'healthy' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: this.services,
|
||||
uptime: process.uptime(),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
};
|
||||
|
||||
// Check if any critical services are down
|
||||
// Note: In development, NocoDB might not be available yet
|
||||
// Only mark as unhealthy if multiple critical services fail
|
||||
const criticalServices = ['nocodb'];
|
||||
const criticalDown = criticalServices.filter(name => !this.services[name].healthy);
|
||||
|
||||
// Allow degraded state if only external services (NocoDB, Represent API) are down
|
||||
// Only fail health check if core services (disk, memory) are unhealthy
|
||||
const coreServicesHealthy = this.services.disk.healthy && this.services.memory.healthy;
|
||||
|
||||
if (!coreServicesHealthy) {
|
||||
overall.status = 'unhealthy';
|
||||
} else if (criticalDown.length > 0) {
|
||||
// Mark as degraded (not unhealthy) if external services are down
|
||||
// This allows the container to start even if NocoDB isn't ready
|
||||
overall.status = 'degraded';
|
||||
}
|
||||
|
||||
return overall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health check for a specific service
|
||||
*/
|
||||
async checkService(serviceName) {
|
||||
const checkMethods = {
|
||||
nocodb: () => this.checkNocoDB(),
|
||||
smtp: () => this.checkSMTP(),
|
||||
representAPI: () => this.checkRepresentAPI(),
|
||||
disk: () => this.checkDiskSpace(),
|
||||
memory: () => this.checkMemory()
|
||||
};
|
||||
|
||||
if (checkMethods[serviceName]) {
|
||||
return await checkMethods[serviceName]();
|
||||
}
|
||||
|
||||
throw new Error(`Unknown service: ${serviceName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get directory size recursively
|
||||
*/
|
||||
async getDirectorySize(directory) {
|
||||
let size = 0;
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(directory);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(directory, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
size += await this.getDirectorySize(filePath);
|
||||
} else {
|
||||
size += stats.size;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Error calculating directory size: ${error.message}`);
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format bytes to human readable
|
||||
*/
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new HealthCheck();
|
||||
190
influence/app/utils/logger.js
Normal file
190
influence/app/utils/logger.js
Normal file
@@ -0,0 +1,190 @@
|
||||
const winston = require('winston');
|
||||
const DailyRotateFile = require('winston-daily-rotate-file');
|
||||
const path = require('path');
|
||||
|
||||
// Define log levels
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
debug: 4,
|
||||
};
|
||||
|
||||
// Define colors for each level
|
||||
const colors = {
|
||||
error: 'red',
|
||||
warn: 'yellow',
|
||||
info: 'green',
|
||||
http: 'magenta',
|
||||
debug: 'white',
|
||||
};
|
||||
|
||||
// Tell winston about our colors
|
||||
winston.addColors(colors);
|
||||
|
||||
// Define which level to log based on environment
|
||||
const level = () => {
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const isDevelopment = env === 'development';
|
||||
return isDevelopment ? 'debug' : 'info';
|
||||
};
|
||||
|
||||
// Define log format
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
// Console format for development (pretty print)
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf(
|
||||
(info) => `${info.timestamp} ${info.level}: ${info.message}${info.stack ? '\n' + info.stack : ''}`
|
||||
)
|
||||
);
|
||||
|
||||
// Define transports
|
||||
const transports = [
|
||||
// Console output
|
||||
new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
handleExceptions: true,
|
||||
}),
|
||||
|
||||
// Error logs - rotate daily
|
||||
new DailyRotateFile({
|
||||
filename: path.join(__dirname, '../../logs/error-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'error',
|
||||
format: logFormat,
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
handleExceptions: true,
|
||||
}),
|
||||
|
||||
// Combined logs - rotate daily
|
||||
new DailyRotateFile({
|
||||
filename: path.join(__dirname, '../../logs/combined-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
format: logFormat,
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
}),
|
||||
|
||||
// HTTP logs - rotate daily
|
||||
new DailyRotateFile({
|
||||
filename: path.join(__dirname, '../../logs/http-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'http',
|
||||
format: logFormat,
|
||||
maxSize: '20m',
|
||||
maxFiles: '7d',
|
||||
}),
|
||||
];
|
||||
|
||||
// Create the logger
|
||||
const logger = winston.createLogger({
|
||||
level: level(),
|
||||
levels,
|
||||
format: logFormat,
|
||||
transports,
|
||||
exitOnError: false,
|
||||
});
|
||||
|
||||
// Create a stream object for Morgan HTTP logger
|
||||
logger.stream = {
|
||||
write: (message) => {
|
||||
logger.http(message.trim());
|
||||
},
|
||||
};
|
||||
|
||||
// Helper methods for common logging patterns
|
||||
logger.logRequest = (req, res, duration) => {
|
||||
const logData = {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
};
|
||||
|
||||
if (res.statusCode >= 500) {
|
||||
logger.error('Request failed', logData);
|
||||
} else if (res.statusCode >= 400) {
|
||||
logger.warn('Request error', logData);
|
||||
} else {
|
||||
logger.http('Request completed', logData);
|
||||
}
|
||||
};
|
||||
|
||||
logger.logEmailSent = (recipient, campaign, status) => {
|
||||
logger.info('Email sent', {
|
||||
event: 'email_sent',
|
||||
recipient,
|
||||
campaign,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
logger.logEmailFailed = (recipient, campaign, error) => {
|
||||
logger.error('Email failed', {
|
||||
event: 'email_failed',
|
||||
recipient,
|
||||
campaign,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
logger.logAuth = (action, username, success, ip) => {
|
||||
const level = success ? 'info' : 'warn';
|
||||
logger[level]('Auth action', {
|
||||
event: 'auth',
|
||||
action,
|
||||
username,
|
||||
success,
|
||||
ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
logger.logCampaignAction = (action, campaignId, userId, ip) => {
|
||||
logger.info('Campaign action', {
|
||||
event: 'campaign_action',
|
||||
action,
|
||||
campaignId,
|
||||
userId,
|
||||
ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
logger.logRateLimitHit = (endpoint, ip, limit) => {
|
||||
logger.warn('Rate limit hit', {
|
||||
event: 'rate_limit',
|
||||
endpoint,
|
||||
ip,
|
||||
limit,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
logger.logHealthCheck = (service, status, details = {}) => {
|
||||
const level = status === 'healthy' ? 'info' : 'error';
|
||||
logger[level]('Health check', {
|
||||
event: 'health_check',
|
||||
service,
|
||||
status,
|
||||
...details,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
276
influence/app/utils/metrics.js
Normal file
276
influence/app/utils/metrics.js
Normal file
@@ -0,0 +1,276 @@
|
||||
const client = require('prom-client');
|
||||
|
||||
// Create a Registry to register the metrics
|
||||
const register = new client.Registry();
|
||||
|
||||
// Add default metrics (CPU, memory, etc.)
|
||||
client.collectDefaultMetrics({
|
||||
register,
|
||||
prefix: 'influence_app_'
|
||||
});
|
||||
|
||||
// Custom metrics for the Influence application
|
||||
|
||||
// Email metrics
|
||||
const emailsSentTotal = new client.Counter({
|
||||
name: 'influence_emails_sent_total',
|
||||
help: 'Total number of emails sent successfully',
|
||||
labelNames: ['campaign_id', 'representative_level'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const emailsFailedTotal = new client.Counter({
|
||||
name: 'influence_emails_failed_total',
|
||||
help: 'Total number of emails that failed to send',
|
||||
labelNames: ['campaign_id', 'error_type'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const emailQueueSize = new client.Gauge({
|
||||
name: 'influence_email_queue_size',
|
||||
help: 'Current number of emails in the queue',
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const emailSendDuration = new client.Histogram({
|
||||
name: 'influence_email_send_duration_seconds',
|
||||
help: 'Time taken to send an email',
|
||||
labelNames: ['campaign_id'],
|
||||
buckets: [0.1, 0.5, 1, 2, 5, 10],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
// API metrics
|
||||
const httpRequestDuration = new client.Histogram({
|
||||
name: 'influence_http_request_duration_seconds',
|
||||
help: 'Duration of HTTP requests in seconds',
|
||||
labelNames: ['method', 'route', 'status_code'],
|
||||
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const httpRequestsTotal = new client.Counter({
|
||||
name: 'influence_http_requests_total',
|
||||
help: 'Total number of HTTP requests',
|
||||
labelNames: ['method', 'route', 'status_code'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
// User metrics
|
||||
const activeUsersGauge = new client.Gauge({
|
||||
name: 'influence_active_users_total',
|
||||
help: 'Number of currently active users',
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const userRegistrationsTotal = new client.Counter({
|
||||
name: 'influence_user_registrations_total',
|
||||
help: 'Total number of user registrations',
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const loginAttemptsTotal = new client.Counter({
|
||||
name: 'influence_login_attempts_total',
|
||||
help: 'Total number of login attempts',
|
||||
labelNames: ['status'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
// Campaign metrics
|
||||
const campaignCreationsTotal = new client.Counter({
|
||||
name: 'influence_campaign_creations_total',
|
||||
help: 'Total number of campaigns created',
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const campaignParticipationTotal = new client.Counter({
|
||||
name: 'influence_campaign_participation_total',
|
||||
help: 'Total number of campaign participations',
|
||||
labelNames: ['campaign_id'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const activeCampaignsGauge = new client.Gauge({
|
||||
name: 'influence_active_campaigns_total',
|
||||
help: 'Number of currently active campaigns',
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const campaignConversionRate = new client.Gauge({
|
||||
name: 'influence_campaign_conversion_rate',
|
||||
help: 'Campaign conversion rate (participants / visitors)',
|
||||
labelNames: ['campaign_id'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
// Representative metrics
|
||||
const representativeLookupTotal = new client.Counter({
|
||||
name: 'influence_representative_lookup_total',
|
||||
help: 'Total number of representative lookups',
|
||||
labelNames: ['lookup_type'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const representativeResponsesTotal = new client.Counter({
|
||||
name: 'influence_representative_responses_total',
|
||||
help: 'Total number of representative responses received',
|
||||
labelNames: ['representative_level'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const representativeResponseRate = new client.Gauge({
|
||||
name: 'influence_representative_response_rate',
|
||||
help: 'Representative response rate (responses / emails sent)',
|
||||
labelNames: ['representative_level'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
// Rate limiting metrics
|
||||
const rateLimitHitsTotal = new client.Counter({
|
||||
name: 'influence_rate_limit_hits_total',
|
||||
help: 'Total number of rate limit hits',
|
||||
labelNames: ['endpoint', 'limit_type'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
// External service metrics
|
||||
const externalServiceRequestsTotal = new client.Counter({
|
||||
name: 'influence_external_service_requests_total',
|
||||
help: 'Total number of requests to external services',
|
||||
labelNames: ['service', 'status'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const externalServiceLatency = new client.Histogram({
|
||||
name: 'influence_external_service_latency_seconds',
|
||||
help: 'Latency of external service requests',
|
||||
labelNames: ['service'],
|
||||
buckets: [0.1, 0.5, 1, 2, 5, 10],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
// Geographic metrics
|
||||
const participationByPostalCode = new client.Counter({
|
||||
name: 'influence_participation_by_postal_code_total',
|
||||
help: 'Participation count by postal code prefix',
|
||||
labelNames: ['postal_prefix'],
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
// Middleware to track HTTP requests
|
||||
const metricsMiddleware = (req, res, next) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
const route = req.route ? req.route.path : req.path;
|
||||
const labels = {
|
||||
method: req.method,
|
||||
route: route,
|
||||
status_code: res.statusCode
|
||||
};
|
||||
|
||||
httpRequestDuration.observe(labels, duration);
|
||||
httpRequestsTotal.inc(labels);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Helper functions for common metric operations
|
||||
const metrics = {
|
||||
// Email metrics
|
||||
recordEmailSent: (campaignId, representativeLevel) => {
|
||||
emailsSentTotal.inc({ campaign_id: campaignId, representative_level: representativeLevel });
|
||||
},
|
||||
|
||||
recordEmailFailed: (campaignId, errorType) => {
|
||||
emailsFailedTotal.inc({ campaign_id: campaignId, error_type: errorType });
|
||||
},
|
||||
|
||||
setEmailQueueSize: (size) => {
|
||||
emailQueueSize.set(size);
|
||||
},
|
||||
|
||||
observeEmailSendDuration: (campaignId, durationSeconds) => {
|
||||
emailSendDuration.observe({ campaign_id: campaignId }, durationSeconds);
|
||||
},
|
||||
|
||||
// User metrics
|
||||
setActiveUsers: (count) => {
|
||||
activeUsersGauge.set(count);
|
||||
},
|
||||
|
||||
recordUserRegistration: () => {
|
||||
userRegistrationsTotal.inc();
|
||||
},
|
||||
|
||||
recordLoginAttempt: (success) => {
|
||||
loginAttemptsTotal.inc({ status: success ? 'success' : 'failed' });
|
||||
},
|
||||
|
||||
// Campaign metrics
|
||||
recordCampaignCreation: () => {
|
||||
campaignCreationsTotal.inc();
|
||||
},
|
||||
|
||||
recordCampaignParticipation: (campaignId) => {
|
||||
campaignParticipationTotal.inc({ campaign_id: campaignId });
|
||||
},
|
||||
|
||||
setActiveCampaigns: (count) => {
|
||||
activeCampaignsGauge.set(count);
|
||||
},
|
||||
|
||||
setCampaignConversionRate: (campaignId, rate) => {
|
||||
campaignConversionRate.set({ campaign_id: campaignId }, rate);
|
||||
},
|
||||
|
||||
// Representative metrics
|
||||
recordRepresentativeLookup: (lookupType) => {
|
||||
representativeLookupTotal.inc({ lookup_type: lookupType });
|
||||
},
|
||||
|
||||
recordRepresentativeResponse: (representativeLevel) => {
|
||||
representativeResponsesTotal.inc({ representative_level: representativeLevel });
|
||||
},
|
||||
|
||||
setRepresentativeResponseRate: (representativeLevel, rate) => {
|
||||
representativeResponseRate.set({ representative_level: representativeLevel }, rate);
|
||||
},
|
||||
|
||||
// Rate limiting metrics
|
||||
recordRateLimitHit: (endpoint, limitType) => {
|
||||
rateLimitHitsTotal.inc({ endpoint, limit_type: limitType });
|
||||
},
|
||||
|
||||
// External service metrics
|
||||
recordExternalServiceRequest: (service, success) => {
|
||||
externalServiceRequestsTotal.inc({ service, status: success ? 'success' : 'failed' });
|
||||
},
|
||||
|
||||
observeExternalServiceLatency: (service, durationSeconds) => {
|
||||
externalServiceLatency.observe({ service }, durationSeconds);
|
||||
},
|
||||
|
||||
// Geographic metrics
|
||||
recordParticipationByPostalCode: (postalCode) => {
|
||||
const prefix = postalCode.substring(0, 3).toUpperCase();
|
||||
participationByPostalCode.inc({ postal_prefix: prefix });
|
||||
},
|
||||
|
||||
// Get metrics endpoint handler
|
||||
getMetrics: async () => {
|
||||
return await register.metrics();
|
||||
},
|
||||
|
||||
// Get content type for metrics
|
||||
getContentType: () => {
|
||||
return register.contentType;
|
||||
},
|
||||
|
||||
// Middleware
|
||||
middleware: metricsMiddleware
|
||||
};
|
||||
|
||||
module.exports = metrics;
|
||||
@@ -16,24 +16,127 @@ function cleanupExpiredEntries() {
|
||||
// Clean up expired entries every minute
|
||||
setInterval(cleanupExpiredEntries, 60 * 1000);
|
||||
|
||||
// General API rate limiter
|
||||
const general = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
message: {
|
||||
error: 'Too many requests from this IP, please try again later.',
|
||||
retryAfter: 15 * 60 // 15 minutes in seconds
|
||||
// Custom key generator that's safer with trust proxy
|
||||
const safeKeyGenerator = (req) => {
|
||||
return req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
};
|
||||
|
||||
// Production-grade rate limiting configuration
|
||||
const rateLimitConfig = {
|
||||
// Email sending - very restrictive (5 emails per hour per IP)
|
||||
emailSend: {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 5,
|
||||
message: {
|
||||
error: 'Email rate limit exceeded',
|
||||
message: 'Too many emails sent from this IP. Maximum 5 emails per hour allowed.',
|
||||
retryAfter: 3600
|
||||
}
|
||||
},
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
// Use a custom key generator that's safer with trust proxy
|
||||
keyGenerator: (req) => {
|
||||
// Fallback to connection remote address if req.ip is not available
|
||||
return req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
|
||||
// Representative lookup - moderate (30 lookups per minute)
|
||||
representativeLookup: {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 30,
|
||||
message: {
|
||||
error: 'Representative lookup rate limit exceeded',
|
||||
message: 'Too many representative lookups. Maximum 30 per minute allowed.',
|
||||
retryAfter: 60
|
||||
}
|
||||
},
|
||||
|
||||
// Login attempts - strict (5 attempts per 15 minutes)
|
||||
login: {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5,
|
||||
message: {
|
||||
error: 'Login rate limit exceeded',
|
||||
message: 'Too many login attempts. Please try again in 15 minutes.',
|
||||
retryAfter: 900
|
||||
}
|
||||
},
|
||||
|
||||
// Public API - standard (100 requests per minute)
|
||||
publicAPI: {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 100,
|
||||
message: {
|
||||
error: 'API rate limit exceeded',
|
||||
message: 'Too many requests from this IP. Please try again later.',
|
||||
retryAfter: 60
|
||||
}
|
||||
},
|
||||
|
||||
// Campaign creation/editing - moderate (10 per hour)
|
||||
campaignMutation: {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10,
|
||||
message: {
|
||||
error: 'Campaign mutation rate limit exceeded',
|
||||
message: 'Too many campaign operations. Maximum 10 per hour allowed.',
|
||||
retryAfter: 3600
|
||||
}
|
||||
},
|
||||
|
||||
// General fallback - legacy compatibility
|
||||
general: {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100,
|
||||
message: {
|
||||
error: 'Too many requests from this IP, please try again later.',
|
||||
retryAfter: 900
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create rate limiter instances
|
||||
const emailSend = rateLimit({
|
||||
...rateLimitConfig.emailSend,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
keyGenerator: safeKeyGenerator
|
||||
});
|
||||
|
||||
// Email sending rate limiter (general - keeps existing behavior)
|
||||
const representativeLookup = rateLimit({
|
||||
...rateLimitConfig.representativeLookup,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: safeKeyGenerator
|
||||
});
|
||||
|
||||
const login = rateLimit({
|
||||
...rateLimitConfig.login,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false, // Count all attempts
|
||||
keyGenerator: safeKeyGenerator
|
||||
});
|
||||
|
||||
const publicAPI = rateLimit({
|
||||
...rateLimitConfig.publicAPI,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: safeKeyGenerator
|
||||
});
|
||||
|
||||
const campaignMutation = rateLimit({
|
||||
...rateLimitConfig.campaignMutation,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
keyGenerator: safeKeyGenerator
|
||||
});
|
||||
|
||||
// General API rate limiter (legacy - kept for backward compatibility)
|
||||
const general = rateLimit({
|
||||
...rateLimitConfig.general,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: safeKeyGenerator
|
||||
});
|
||||
|
||||
// Email sending rate limiter (legacy - kept for backward compatibility)
|
||||
const email = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10, // limit each IP to 10 emails per hour
|
||||
@@ -43,12 +146,8 @@ const email = rateLimit({
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false, // Don't skip counting successful requests
|
||||
// Use a custom key generator that's safer with trust proxy
|
||||
keyGenerator: (req) => {
|
||||
// Fallback to connection remote address if req.ip is not available
|
||||
return req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
},
|
||||
skipSuccessfulRequests: false,
|
||||
keyGenerator: safeKeyGenerator
|
||||
});
|
||||
|
||||
// Custom middleware for per-recipient email rate limiting
|
||||
@@ -90,16 +189,23 @@ const representAPI = rateLimit({
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
// Use a custom key generator that's safer with trust proxy
|
||||
keyGenerator: (req) => {
|
||||
// Fallback to connection remote address if req.ip is not available
|
||||
return req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
},
|
||||
keyGenerator: safeKeyGenerator
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Legacy exports (backward compatibility)
|
||||
general,
|
||||
email,
|
||||
representAPI,
|
||||
|
||||
// New granular rate limiters
|
||||
emailSend,
|
||||
representativeLookup,
|
||||
login,
|
||||
publicAPI,
|
||||
campaignMutation,
|
||||
perRecipientEmailLimiter,
|
||||
representAPI
|
||||
|
||||
// Export config for testing/monitoring
|
||||
rateLimitConfig
|
||||
};
|
||||
@@ -1,7 +1,31 @@
|
||||
// Validate Canadian postal code format
|
||||
// Full Canadian postal code validation with proper FSA/LDU rules
|
||||
function validatePostalCode(postalCode) {
|
||||
const regex = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
|
||||
return regex.test(postalCode);
|
||||
// Remove whitespace and convert to uppercase
|
||||
const cleaned = postalCode.replace(/\s/g, '').toUpperCase();
|
||||
|
||||
// Must be exactly 6 characters
|
||||
if (cleaned.length !== 6) return false;
|
||||
|
||||
// Pattern: A1A 1A1 where A is letter and 1 is digit
|
||||
const pattern = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
|
||||
if (!pattern.test(cleaned)) return false;
|
||||
|
||||
// First character cannot be D, F, I, O, Q, U, W, or Z
|
||||
const invalidFirstChars = ['D', 'F', 'I', 'O', 'Q', 'U', 'W', 'Z'];
|
||||
if (invalidFirstChars.includes(cleaned[0])) return false;
|
||||
|
||||
// Second position (LDU) cannot be 0
|
||||
if (cleaned[1] === '0') return false;
|
||||
|
||||
// Third character cannot be D, F, I, O, Q, or U
|
||||
const invalidThirdChars = ['D', 'F', 'I', 'O', 'Q', 'U'];
|
||||
if (invalidThirdChars.includes(cleaned[2])) return false;
|
||||
|
||||
// Fifth character cannot be D, F, I, O, Q, or U
|
||||
if (invalidThirdChars.includes(cleaned[4])) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate Alberta postal code (starts with T)
|
||||
@@ -10,10 +34,29 @@ function validateAlbertaPostalCode(postalCode) {
|
||||
return formatted.startsWith('T') && validatePostalCode(postalCode);
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
// Validate email format with stricter rules
|
||||
function validateEmail(email) {
|
||||
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return regex.test(email);
|
||||
// RFC 5322 simplified email validation
|
||||
const regex = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
if (!regex.test(email)) return false;
|
||||
|
||||
// Additional checks
|
||||
const parts = email.split('@');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const [localPart, domain] = parts;
|
||||
|
||||
// Local part max 64 characters
|
||||
if (localPart.length > 64) return false;
|
||||
|
||||
// Domain must have at least one dot and valid TLD
|
||||
if (!domain.includes('.')) return false;
|
||||
|
||||
// Check for consecutive dots
|
||||
if (email.includes('..')) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Format postal code to standard format (A1A 1A1)
|
||||
@@ -25,16 +68,57 @@ function formatPostalCode(postalCode) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// Sanitize string input to prevent XSS
|
||||
// Sanitize string input to prevent XSS and injection attacks
|
||||
function sanitizeString(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
return str
|
||||
.replace(/[<>]/g, '') // Remove angle brackets
|
||||
.replace(/javascript:/gi, '') // Remove javascript: protocol
|
||||
.replace(/on\w+\s*=/gi, '') // Remove event handlers
|
||||
.replace(/eval\s*\(/gi, '') // Remove eval calls
|
||||
.trim()
|
||||
.substring(0, 1000); // Limit length
|
||||
}
|
||||
|
||||
// Sanitize HTML content for email templates
|
||||
function sanitizeHtmlContent(html) {
|
||||
if (typeof html !== 'string') return html;
|
||||
|
||||
// Remove dangerous tags and attributes
|
||||
let sanitized = html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
|
||||
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
|
||||
.replace(/<embed[^>]*>/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/on\w+\s*=/gi, '');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Validate SQL/NoSQL injection attempts in where clauses
|
||||
function validateWhereClause(whereClause) {
|
||||
if (typeof whereClause !== 'string') return false;
|
||||
|
||||
// Check for SQL injection patterns
|
||||
const suspiciousPatterns = [
|
||||
/;\s*drop\s+/i,
|
||||
/;\s*delete\s+/i,
|
||||
/;\s*update\s+/i,
|
||||
/;\s*insert\s+/i,
|
||||
/union\s+select/i,
|
||||
/exec\s*\(/i,
|
||||
/execute\s*\(/i,
|
||||
/--/,
|
||||
/\/\*/,
|
||||
/xp_/i,
|
||||
/sp_/i
|
||||
];
|
||||
|
||||
return !suspiciousPatterns.some(pattern => pattern.test(whereClause));
|
||||
}
|
||||
|
||||
// Validate required fields in request body
|
||||
function validateRequiredFields(body, requiredFields) {
|
||||
const errors = [];
|
||||
@@ -130,6 +214,8 @@ module.exports = {
|
||||
validateEmail,
|
||||
formatPostalCode,
|
||||
sanitizeString,
|
||||
sanitizeHtmlContent,
|
||||
validateWhereClause,
|
||||
validateRequiredFields,
|
||||
containsSuspiciousContent,
|
||||
generateSlug,
|
||||
|
||||
Reference in New Issue
Block a user