security: Fix critical vulnerabilities and harden application
## Security Fixes ### CRITICAL: Command Injection (adminBackup.js) - Replaced exec() with spawn() using argument arrays - Added input sanitization for host, user, and ssh_key - Added regex validation for hostname/IP format - Added username format validation - Added SSH key file existence check - Prevents shell metacharacter injection attacks ### HIGH: Hardcoded Password (set-admin-password.js) - Removed hardcoded 'admin123' password - Now requires password as CLI argument or env variable - Added password strength validation (8+ chars, mixed case, numbers, special chars) - Added --help flag with usage instructions - Invalidates existing sessions on password change ### MEDIUM: XSS Vulnerability (WelcomeMessageEditor.tsx) - Added DOMPurify sanitization to getPreviewHtml() - Strips all HTML tags before rendering preview - Prevents script injection in admin preview ### LOW: Sample Password Exposure (EmailConfigPage.tsx) - Replaced plaintext sample password with masked placeholder - Uses '••••••••' instead of realistic password ## Dependency Updates - Fixed npm audit vulnerabilities (jws, qs, express) - Backend: 0 vulnerabilities - Frontend: 0 vulnerabilities
This commit is contained in:
Generated
+24
-48
@@ -4355,21 +4355,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/qs": {
|
|
||||||
"version": "6.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/type-is": {
|
"node_modules/body-parser/node_modules/type-is": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
@@ -5766,39 +5751,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.21.2",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "1.20.3",
|
"body-parser": "~1.20.3",
|
||||||
"content-disposition": "0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "0.7.1",
|
"cookie": "~0.7.1",
|
||||||
"cookie-signature": "1.0.6",
|
"cookie-signature": "~1.0.6",
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "2.0.0",
|
"depd": "2.0.0",
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "~2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "~1.0.3",
|
||||||
"etag": "~1.8.1",
|
"etag": "~1.8.1",
|
||||||
"finalhandler": "1.3.1",
|
"finalhandler": "~1.3.1",
|
||||||
"fresh": "0.5.2",
|
"fresh": "~0.5.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.0",
|
||||||
"merge-descriptors": "1.0.3",
|
"merge-descriptors": "1.0.3",
|
||||||
"methods": "~1.1.2",
|
"methods": "~1.1.2",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "6.13.0",
|
"qs": "~6.14.0",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "0.19.0",
|
"send": "~0.19.0",
|
||||||
"serve-static": "1.16.2",
|
"serve-static": "~1.16.2",
|
||||||
"setprototypeof": "1.2.0",
|
"setprototypeof": "1.2.0",
|
||||||
"statuses": "2.0.1",
|
"statuses": "~2.0.1",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"utils-merge": "1.0.1",
|
"utils-merge": "1.0.1",
|
||||||
"vary": "~1.1.2"
|
"vary": "~1.1.2"
|
||||||
@@ -5836,15 +5821,6 @@
|
|||||||
"node": ">= 8.0.0"
|
"node": ">= 8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express/node_modules/cookie": {
|
|
||||||
"version": "0.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
|
||||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/debug": {
|
"node_modules/express/node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -7837,12 +7813,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jws": {
|
"node_modules/jws": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jwa": "^1.4.1",
|
"jwa": "^1.4.2",
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -9694,12 +9670,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.0.6"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to set/reset admin password
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node set-admin-password.js <new-password>
|
||||||
|
* node set-admin-password.js --env (uses ADMIN_PASSWORD environment variable)
|
||||||
|
*
|
||||||
|
* Security: Password must be at least 8 characters with mixed case, numbers, and special characters
|
||||||
|
*/
|
||||||
|
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
@@ -16,22 +26,108 @@ const db = knex({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate password strength
|
||||||
|
*/
|
||||||
|
function validatePassword(password) {
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
return { valid: false, error: 'Password must be at least 8 characters long' };
|
||||||
|
}
|
||||||
|
if (!/[a-z]/.test(password)) {
|
||||||
|
return { valid: false, error: 'Password must contain at least one lowercase letter' };
|
||||||
|
}
|
||||||
|
if (!/[A-Z]/.test(password)) {
|
||||||
|
return { valid: false, error: 'Password must contain at least one uppercase letter' };
|
||||||
|
}
|
||||||
|
if (!/[0-9]/.test(password)) {
|
||||||
|
return { valid: false, error: 'Password must contain at least one number' };
|
||||||
|
}
|
||||||
|
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||||
|
return { valid: false, error: 'Password must contain at least one special character' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
Usage:
|
||||||
|
node set-admin-password.js <new-password>
|
||||||
|
node set-admin-password.js --env
|
||||||
|
|
||||||
|
Options:
|
||||||
|
<new-password> The new password to set (must meet security requirements)
|
||||||
|
--env Use ADMIN_PASSWORD environment variable
|
||||||
|
|
||||||
|
Security Requirements:
|
||||||
|
- At least 8 characters
|
||||||
|
- At least one lowercase letter
|
||||||
|
- At least one uppercase letter
|
||||||
|
- At least one number
|
||||||
|
- At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
node set-admin-password.js "MySecure@Pass123"
|
||||||
|
ADMIN_PASSWORD="MySecure@Pass123" node set-admin-password.js --env
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
async function setAdminPassword() {
|
async function setAdminPassword() {
|
||||||
try {
|
try {
|
||||||
const password = 'admin123';
|
// Get password from argument or environment
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const args = process.argv.slice(2);
|
||||||
|
let password;
|
||||||
|
|
||||||
await db('admin_users')
|
if (args.length === 0) {
|
||||||
|
console.error('❌ Error: No password provided\n');
|
||||||
|
printUsage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args[0] === '--env') {
|
||||||
|
password = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!password) {
|
||||||
|
console.error('❌ Error: ADMIN_PASSWORD environment variable not set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else if (args[0] === '--help' || args[0] === '-h') {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
password = args[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
const validation = validatePassword(password);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.error(`❌ Error: ${validation.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
const updated = await db('admin_users')
|
||||||
.where('username', 'admin')
|
.where('username', 'admin')
|
||||||
.update({
|
.update({
|
||||||
password_hash: hashedPassword,
|
password_hash: hashedPassword,
|
||||||
|
password_changed_at: new Date(),
|
||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Admin password set to: admin123');
|
if (updated === 0) {
|
||||||
|
console.error('❌ Error: Admin user not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Admin password updated successfully');
|
||||||
|
console.log(' Note: All existing sessions have been invalidated');
|
||||||
|
|
||||||
|
await db.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error setting password:', error);
|
console.error('❌ Error setting password:', error.message);
|
||||||
|
await db.destroy();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,25 +230,89 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'rsync':
|
case 'rsync':
|
||||||
// Test rsync connection
|
// Test rsync connection using spawn with argument arrays to prevent command injection
|
||||||
const { exec } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { promisify } = require('util');
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
const sshCommand = config.ssh_key
|
// Validate and sanitize inputs to prevent command injection
|
||||||
? `ssh -i ${config.ssh_key} -o StrictHostKeyChecking=no -o ConnectTimeout=10`
|
const sanitizeInput = (input) => {
|
||||||
: 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10';
|
if (!input || typeof input !== 'string') return null;
|
||||||
|
// Remove any shell metacharacters and limit length
|
||||||
|
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
|
||||||
|
};
|
||||||
|
|
||||||
const testCommand = config.user
|
const host = sanitizeInput(config.host);
|
||||||
? `${sshCommand} ${config.user}@${config.host} "echo 'Connection successful'"`
|
const user = sanitizeInput(config.user);
|
||||||
: `${sshCommand} ${config.host} "echo 'Connection successful'"`;
|
const sshKeyPath = sanitizeInput(config.ssh_key);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
res.json({ success: false, message: 'Invalid host specified' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate host format (hostname or IP only)
|
||||||
|
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||||
|
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
||||||
|
res.json({ success: false, message: 'Invalid host format' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username format if provided
|
||||||
|
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||||
|
res.json({ success: false, message: 'Invalid username format' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SSH arguments as array (safe from injection)
|
||||||
|
const sshArgs = [];
|
||||||
|
if (sshKeyPath) {
|
||||||
|
// Validate SSH key path exists and is a file
|
||||||
|
const fsSync = require('fs');
|
||||||
|
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
|
||||||
|
res.json({ success: false, message: 'SSH key file not found' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sshArgs.push('-i', sshKeyPath);
|
||||||
|
}
|
||||||
|
sshArgs.push('-o', 'StrictHostKeyChecking=no');
|
||||||
|
sshArgs.push('-o', 'ConnectTimeout=10');
|
||||||
|
sshArgs.push('-o', 'BatchMode=yes');
|
||||||
|
|
||||||
|
// Add target (user@host or just host)
|
||||||
|
const target = user ? `${user}@${host}` : host;
|
||||||
|
sshArgs.push(target);
|
||||||
|
sshArgs.push('echo', 'Connection successful');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(testCommand);
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
const sshProcess = spawn('ssh', sshArgs, {
|
||||||
|
timeout: 15000,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
sshProcess.stdout.on('data', (data) => { stdout += data; });
|
||||||
|
sshProcess.stderr.on('data', (data) => { stderr += data; });
|
||||||
|
|
||||||
|
sshProcess.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ success: true, stdout });
|
||||||
|
} else {
|
||||||
|
reject(new Error(stderr || `SSH exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sshProcess.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: 'Rsync connection successful' });
|
res.json({ success: true, message: 'Rsync connection successful' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Rsync connection test failed', {
|
logger.warn('Rsync connection test failed', {
|
||||||
destination: config.host || config.destination,
|
destination: host,
|
||||||
error: error.message
|
error: error.message
|
||||||
});
|
});
|
||||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { HelpCircle } from 'lucide-react';
|
import { HelpCircle } from 'lucide-react';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
interface WelcomeMessageEditorProps {
|
interface WelcomeMessageEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -18,9 +19,16 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
|||||||
onChange(e.target.value);
|
onChange(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert newlines to <br> tags for preview
|
// Convert newlines to <br> tags for preview with XSS sanitization
|
||||||
const getPreviewHtml = () => {
|
const getPreviewHtml = () => {
|
||||||
return value
|
// First sanitize the input to remove any malicious content
|
||||||
|
const sanitized = DOMPurify.sanitize(value, {
|
||||||
|
ALLOWED_TAGS: [], // Strip all HTML tags, only allow text
|
||||||
|
ALLOWED_ATTR: [],
|
||||||
|
KEEP_CONTENT: true
|
||||||
|
});
|
||||||
|
// Then convert newlines to <br> tags
|
||||||
|
return sanitized
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(line => line.trim())
|
.map(line => line.trim())
|
||||||
.filter(line => line.length > 0)
|
.filter(line => line.length > 0)
|
||||||
|
|||||||
@@ -227,10 +227,11 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
if (!selectedTemplateKey || !editedTemplate) return;
|
if (!selectedTemplateKey || !editedTemplate) return;
|
||||||
|
|
||||||
// Generate sample data based on the template
|
// Generate sample data based on the template
|
||||||
|
// Note: These are clearly marked placeholder values for template preview only
|
||||||
const sampleData: Record<string, string> = {
|
const sampleData: Record<string, string> = {
|
||||||
event_name: 'John & Jane Wedding',
|
event_name: 'John & Jane Wedding',
|
||||||
event_date: 'December 25, 2024',
|
event_date: 'December 25, 2024',
|
||||||
password: 'wedding2024',
|
password: '••••••••', // Masked placeholder for preview
|
||||||
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
|
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
|
||||||
expiration_date: 'January 25, 2025',
|
expiration_date: 'January 25, 2025',
|
||||||
welcome_message: 'Thank you for celebrating our special day with us!',
|
welcome_message: 'Thank you for celebrating our special day with us!',
|
||||||
|
|||||||
Reference in New Issue
Block a user