|
| 1 | +#!/usr/bin/env node |
| 2 | +/* |
| 3 | + * Copyright (c) 2025, Salesforce, Inc. |
| 4 | + * All rights reserved. |
| 5 | + * SPDX-License-Identifier: BSD-3-Clause |
| 6 | + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * Simple script to update the CSP directives in ssr.js by merging in the new configuration |
| 11 | + */ |
| 12 | + |
| 13 | +const fs = require('fs') |
| 14 | +const path = require('path') |
| 15 | + |
| 16 | +// Default path to the ssr.js file |
| 17 | +const DEFAULT_SSR_FILE_PATH = path.join( |
| 18 | + __dirname, |
| 19 | + '../packages/template-retail-react-app/app/ssr.js' |
| 20 | +) |
| 21 | + |
| 22 | +/** |
| 23 | + * Get the SSR file path from command line args or use default |
| 24 | + */ |
| 25 | +function getSSRFilePath(args) { |
| 26 | + const pathIndex = args.indexOf('--ssr-path') |
| 27 | + if (pathIndex !== -1 && pathIndex + 1 < args.length) { |
| 28 | + return args[pathIndex + 1] |
| 29 | + } |
| 30 | + return DEFAULT_SSR_FILE_PATH |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * Parse current CSP directives from ssr.js |
| 35 | + */ |
| 36 | +function getCurrentCSPConfig(ssrFilePath = DEFAULT_SSR_FILE_PATH) { |
| 37 | + const content = fs.readFileSync(ssrFilePath, 'utf8') |
| 38 | + const directivesMatch = content.match(/directives:\s*{([^}]+)}/s) |
| 39 | + |
| 40 | + if (!directivesMatch) { |
| 41 | + throw new Error('Could not find CSP directives in ssr.js') |
| 42 | + } |
| 43 | + |
| 44 | + const directivesContent = directivesMatch[1] |
| 45 | + const config = {} |
| 46 | + |
| 47 | + // Match each directive and its array |
| 48 | + const directiveMatches = directivesContent.matchAll(/'([^']+)':\s*\[([^\]]+)\]/gs) |
| 49 | + |
| 50 | + Array.from(directiveMatches).forEach((match) => { |
| 51 | + const directiveName = match[1] |
| 52 | + const valuesContent = match[2] |
| 53 | + |
| 54 | + config[directiveName] = [] |
| 55 | + |
| 56 | + // Parse values and comments from existing CSP |
| 57 | + const lines = valuesContent.split('\n') |
| 58 | + let currentComment = null |
| 59 | + |
| 60 | + lines.forEach((line) => { |
| 61 | + const trimmedLine = line.trim() |
| 62 | + |
| 63 | + if (trimmedLine.startsWith('//')) { |
| 64 | + currentComment = trimmedLine.substring(2).trim() |
| 65 | + } else if (trimmedLine.includes("'")) { |
| 66 | + const valueMatch = trimmedLine.match(/'([^']+)'/) |
| 67 | + if (valueMatch) { |
| 68 | + config[directiveName].push({ |
| 69 | + comment: currentComment, |
| 70 | + value: valueMatch[1] |
| 71 | + }) |
| 72 | + currentComment = null |
| 73 | + } |
| 74 | + } |
| 75 | + }) |
| 76 | + }) |
| 77 | + |
| 78 | + return config |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * Merge new CSP configuration with existing configuration |
| 83 | + */ |
| 84 | +function mergeCSPConfig(existingConfig, newConfig) { |
| 85 | + const mergedConfig = {...existingConfig} |
| 86 | + |
| 87 | + Object.entries(newConfig).forEach(([directiveName, newEntries]) => { |
| 88 | + if (!mergedConfig[directiveName]) { |
| 89 | + mergedConfig[directiveName] = [] |
| 90 | + } |
| 91 | + |
| 92 | + newEntries.forEach((newEntry) => { |
| 93 | + // Handle both string values and object format for backward compatibility |
| 94 | + const newValue = typeof newEntry === 'string' ? newEntry : newEntry.value |
| 95 | + |
| 96 | + // Check if value already exists |
| 97 | + const existingEntry = mergedConfig[directiveName].find( |
| 98 | + (existing) => existing.value === newValue |
| 99 | + ) |
| 100 | + |
| 101 | + if (!existingEntry) { |
| 102 | + // Add new entry (no comment since input format is simplified) |
| 103 | + mergedConfig[directiveName].push({ |
| 104 | + value: newValue |
| 105 | + }) |
| 106 | + } |
| 107 | + }) |
| 108 | + }) |
| 109 | + |
| 110 | + return mergedConfig |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * Generate CSP directives string from config |
| 115 | + */ |
| 116 | +function generateCSPDirectives(config) { |
| 117 | + const directiveLines = [] |
| 118 | + |
| 119 | + Object.entries(config).forEach(([directiveName, entries]) => { |
| 120 | + if (entries.length === 0) return |
| 121 | + |
| 122 | + const valueLines = [] |
| 123 | + entries.forEach((entry, i) => { |
| 124 | + if (entry.comment) { |
| 125 | + valueLines.push(` // ${entry.comment}`) |
| 126 | + } |
| 127 | + valueLines.push( |
| 128 | + ` '${entry.value}'${i < entries.length - 1 ? ',' : ''}` |
| 129 | + ) |
| 130 | + }) |
| 131 | + |
| 132 | + const valuesString = valueLines.join('\n') |
| 133 | + directiveLines.push( |
| 134 | + ` '${directiveName}': [\n${valuesString}\n ]` |
| 135 | + ) |
| 136 | + }) |
| 137 | + |
| 138 | + return directiveLines.join(',\n') |
| 139 | +} |
| 140 | + |
| 141 | +/** |
| 142 | + * Parse input from file path |
| 143 | + */ |
| 144 | +function parseInputFile(filePath) { |
| 145 | + if (!fs.existsSync(filePath)) { |
| 146 | + throw new Error(`Config file not found: ${filePath}`) |
| 147 | + } |
| 148 | + |
| 149 | + const configContent = fs.readFileSync(filePath, 'utf8') |
| 150 | + return JSON.parse(configContent) |
| 151 | +} |
| 152 | + |
| 153 | +/** |
| 154 | + * Parse input from stdin |
| 155 | + */ |
| 156 | +function parseInputFromStdin() { |
| 157 | + return new Promise((resolve, reject) => { |
| 158 | + let stdinData = '' |
| 159 | + |
| 160 | + process.stdin.setEncoding('utf8') |
| 161 | + |
| 162 | + process.stdin.on('data', (chunk) => { |
| 163 | + stdinData += chunk |
| 164 | + }) |
| 165 | + |
| 166 | + process.stdin.on('end', () => { |
| 167 | + try { |
| 168 | + const config = JSON.parse(stdinData.trim()) |
| 169 | + resolve(config) |
| 170 | + } catch (error) { |
| 171 | + reject(new Error(`Invalid JSON from stdin: ${error.message}`)) |
| 172 | + } |
| 173 | + }) |
| 174 | + |
| 175 | + process.stdin.on('error', (error) => { |
| 176 | + reject(new Error(`Error reading from stdin: ${error.message}`)) |
| 177 | + }) |
| 178 | + }) |
| 179 | +} |
| 180 | + |
| 181 | +/** |
| 182 | + * Update ssr.js with new CSP configuration |
| 183 | + */ |
| 184 | +function updateSSRFile(config, ssrFilePath = DEFAULT_SSR_FILE_PATH) { |
| 185 | + const content = fs.readFileSync(ssrFilePath, 'utf8') |
| 186 | + const directivesString = generateCSPDirectives(config) |
| 187 | + |
| 188 | + const newContent = content.replace( |
| 189 | + /directives:\s*{[^}]+}/s, |
| 190 | + `directives: {\n${directivesString}\n }` |
| 191 | + ) |
| 192 | + |
| 193 | + fs.writeFileSync(ssrFilePath, newContent, 'utf8') |
| 194 | + console.log('✅ Successfully updated CSP directives in ssr.js') |
| 195 | +} |
| 196 | + |
| 197 | +/** |
| 198 | + * Main function |
| 199 | + */ |
| 200 | +async function main() { |
| 201 | + const args = process.argv.slice(2) |
| 202 | + const filteredArgs = args.filter((arg, index) => { |
| 203 | + return !(arg === '--ssr-path' || args[index - 1] === '--ssr-path') |
| 204 | + }) |
| 205 | + |
| 206 | + // Show help if no file argument and stdin is a TTY (not piped) |
| 207 | + if (filteredArgs.length === 0 && process.stdin.isTTY) { |
| 208 | + console.log(` |
| 209 | +Usage: |
| 210 | + node scripts/update-csp-directives-simple.js <config.json> |
| 211 | + echo '{"img-src": ["*.example.com"]}' | node scripts/update-csp-directives-simple.js |
| 212 | +
|
| 213 | +Options: |
| 214 | + --ssr-path <path> - Custom path to ssr.js file (default: packages/template-retail-react-app/app/ssr.js) |
| 215 | +
|
| 216 | +Examples: |
| 217 | + # Merge CSP from configuration file |
| 218 | + node scripts/update-csp-directives-simple.js csp-config.json |
| 219 | +
|
| 220 | + # Merge CSP from JSON via stdin/pipe |
| 221 | + echo '{"img-src": ["*.example.com"]}' | node scripts/update-csp-directives-simple.js |
| 222 | + cat csp-config.json | node scripts/update-csp-directives-simple.js |
| 223 | +
|
| 224 | + # Use custom ssr.js path |
| 225 | + node scripts/update-csp-directives-simple.js --ssr-path custom/path/ssr.js csp-config.json |
| 226 | + echo '{"script-src": ["cdn.example.com"]}' | node scripts/update-csp-directives-simple.js --ssr-path custom/path/ssr.js |
| 227 | +
|
| 228 | +Config JSON Format: |
| 229 | +{ |
| 230 | + "img-src": [ |
| 231 | + "*.commercecloud.salesforce.com", |
| 232 | + "*.example.com" |
| 233 | + ], |
| 234 | + "script-src": [ |
| 235 | + "cdn.example.com", |
| 236 | + "another-cdn.com" |
| 237 | + ] |
| 238 | +} |
| 239 | + `) |
| 240 | + process.exit(1) |
| 241 | + } |
| 242 | + |
| 243 | + const ssrFilePath = getSSRFilePath(args) |
| 244 | + |
| 245 | + try { |
| 246 | + let newConfig |
| 247 | + |
| 248 | + // Check if we have a file argument or should read from stdin |
| 249 | + if (filteredArgs.length > 0) { |
| 250 | + // Read from file |
| 251 | + const configFile = filteredArgs[0] |
| 252 | + newConfig = parseInputFile(configFile) |
| 253 | + } else { |
| 254 | + // Read from stdin |
| 255 | + newConfig = await parseInputFromStdin() |
| 256 | + } |
| 257 | + |
| 258 | + // Validate config structure |
| 259 | + Object.entries(newConfig).forEach(([directive, entries]) => { |
| 260 | + if (!Array.isArray(entries)) { |
| 261 | + throw new Error(`Directive '${directive}' must be an array`) |
| 262 | + } |
| 263 | + |
| 264 | + entries.forEach((entry) => { |
| 265 | + if (typeof entry !== 'string' && (typeof entry !== 'object' || !entry.value)) { |
| 266 | + throw new Error(`Each entry must be a string or object with 'value' property`) |
| 267 | + } |
| 268 | + }) |
| 269 | + }) |
| 270 | + |
| 271 | + // Get current configuration and merge with new configuration |
| 272 | + const currentConfig = getCurrentCSPConfig(ssrFilePath) |
| 273 | + const mergedConfig = mergeCSPConfig(currentConfig, newConfig) |
| 274 | + |
| 275 | + updateSSRFile(mergedConfig, ssrFilePath) |
| 276 | + console.log(`✅ Merged CSP directives successfully`) |
| 277 | + } catch (error) { |
| 278 | + console.error('❌ Error:', error.message) |
| 279 | + process.exit(1) |
| 280 | + } |
| 281 | +} |
| 282 | + |
| 283 | +// Run the script |
| 284 | +if (require.main === module) { |
| 285 | + main().catch((error) => { |
| 286 | + console.error('❌ Error:', error.message) |
| 287 | + process.exit(1) |
| 288 | + }) |
| 289 | +} |
| 290 | + |
| 291 | +module.exports = { |
| 292 | + getCurrentCSPConfig, |
| 293 | + mergeCSPConfig, |
| 294 | + generateCSPDirectives, |
| 295 | + updateSSRFile, |
| 296 | + getSSRFilePath, |
| 297 | + parseInputFile, |
| 298 | + parseInputFromStdin |
| 299 | +} |
0 commit comments