screenshot-storage.js
1 /** 2 * Screenshot Storage Utility 3 * Handles reading and writing screenshots to the file system 4 */ 5 6 // Safe: all paths from DB site IDs (integers) and env var base dirs; object keys from SCREENSHOT_FILES constant 7 /* eslint-disable security/detect-object-injection, security/detect-non-literal-fs-filename */ 8 9 import fs from 'fs/promises'; 10 import path from 'path'; 11 import { fileURLToPath } from 'url'; 12 13 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 14 const screenshotsBaseDir = process.env.SCREENSHOT_BASE_PATH 15 ? path.resolve(process.env.SCREENSHOT_BASE_PATH) 16 : path.join(__dirname, '../../screenshots'); 17 18 /** 19 * Screenshot file mapping 20 */ 21 export const SCREENSHOT_FILES = { 22 desktop_above: 'desktop_above.jpg', 23 desktop_above_uncropped: 'desktop_above_uncropped.jpg', 24 desktop_below: 'desktop_below.jpg', 25 desktop_below_uncropped: 'desktop_below_uncropped.jpg', 26 mobile_above: 'mobile_above.jpg', 27 mobile_above_uncropped: 'mobile_above_uncropped.jpg', 28 }; 29 30 /** 31 * Save screenshots to file system 32 * @param {number} siteId - Site ID 33 * @param {Object} screenshots - Screenshot buffers from capture module 34 * @param {Buffer} screenshots.desktop_above - Desktop above-fold (cropped) 35 * @param {Buffer} screenshots.desktop_below - Desktop below-fold (cropped) 36 * @param {Buffer} screenshots.mobile_above - Mobile above-fold (cropped) 37 * @param {Buffer} screenshots.desktop_above_uncropped - Desktop above-fold (uncropped) 38 * @param {Buffer} screenshots.desktop_below_uncropped - Desktop below-fold (uncropped) 39 * @param {Buffer} screenshots.mobile_above_uncropped - Mobile above-fold (uncropped) 40 * @returns {Promise<string>} Screenshot path (relative, for database storage) 41 */ 42 export async function saveScreenshots(siteId, screenshots) { 43 const siteDir = path.join(screenshotsBaseDir, siteId.toString()); // nosemgrep: path-join-resolve-traversal 44 const screenshotPath = `screenshots/${siteId}`; 45 46 // Create directory 47 await fs.mkdir(siteDir, { recursive: true }); 48 49 // Write each screenshot file 50 const writes = Object.entries(SCREENSHOT_FILES).map(async ([key, filename]) => { 51 if (screenshots[key]) { 52 const filePath = path.join(siteDir, filename); // nosemgrep: path-join-resolve-traversal 53 await fs.writeFile(filePath, screenshots[key]); 54 } 55 }); 56 57 await Promise.all(writes); 58 59 return screenshotPath; 60 } 61 62 /** 63 * Load screenshots from file system 64 * @param {string} screenshotPath - Screenshot path from database (e.g., "screenshots/123") 65 * @param {Object} options - Load options 66 * @param {boolean} options.includeUncropped - Include uncropped versions (default: false) 67 * @returns {Promise<Object>} Screenshot buffers 68 */ 69 export async function loadScreenshots(screenshotPath, options = {}) { 70 const { includeUncropped = false } = options; 71 72 if (!screenshotPath) { 73 throw new Error('Screenshot path is required'); 74 } 75 76 // Extract just the site ID from the path (e.g., "screenshots/1" -> "1") 77 const siteId = screenshotPath.split('/').pop(); 78 const siteDir = path.join(screenshotsBaseDir, siteId); // nosemgrep: path-join-resolve-traversal 79 80 const screenshots = {}; 81 82 // Load cropped screenshots 83 const croppedFiles = ['desktop_above', 'desktop_below', 'mobile_above']; 84 85 for (const key of croppedFiles) { 86 const filename = SCREENSHOT_FILES[key]; 87 const filePath = path.join(siteDir, filename); // nosemgrep: path-join-resolve-traversal 88 89 try { 90 screenshots[key] = await fs.readFile(filePath); 91 } catch (error) { 92 // File might not exist (e.g., desktop_below is optional) 93 if (error.code !== 'ENOENT') { 94 throw error; 95 } 96 } 97 } 98 99 // Load uncropped screenshots if requested 100 if (includeUncropped) { 101 const uncroppedFiles = [ 102 'desktop_above_uncropped', 103 'desktop_below_uncropped', 104 'mobile_above_uncropped', 105 ]; 106 107 for (const key of uncroppedFiles) { 108 const filename = SCREENSHOT_FILES[key]; 109 const filePath = path.join(siteDir, filename); // nosemgrep: path-join-resolve-traversal 110 111 try { 112 screenshots[key] = await fs.readFile(filePath); 113 } catch (error) { 114 if (error.code !== 'ENOENT') { 115 throw error; 116 } 117 } 118 } 119 } 120 121 return screenshots; 122 } 123 124 /** 125 * Load a single screenshot from file system 126 * @param {string} screenshotPath - Screenshot path from database 127 * @param {string} type - Screenshot type (e.g., 'desktop_above', 'mobile_above_uncropped') 128 * @returns {Promise<Buffer>} Screenshot buffer 129 */ 130 export function loadScreenshot(screenshotPath, type) { 131 if (!screenshotPath) { 132 throw new Error('Screenshot path is required'); 133 } 134 135 if (!SCREENSHOT_FILES[type]) { 136 throw new Error(`Invalid screenshot type: ${type}`); 137 } 138 139 const filename = SCREENSHOT_FILES[type]; 140 // Extract just the site ID from the path (e.g., "screenshots/1" -> "1") 141 const siteId = screenshotPath.split('/').pop(); 142 const filePath = path.join(screenshotsBaseDir, siteId, filename); // nosemgrep: path-join-resolve-traversal 143 144 return fs.readFile(filePath); 145 } 146 147 /** 148 * Check if screenshots exist for a site 149 * @param {string} screenshotPath - Screenshot path from database 150 * @returns {Promise<boolean>} True if screenshots exist 151 */ 152 export async function screenshotsExist(screenshotPath) { 153 if (!screenshotPath) { 154 return false; 155 } 156 157 try { 158 // Extract just the site ID from the path (e.g., "screenshots/1" -> "1") 159 const siteId = screenshotPath.split('/').pop(); 160 const siteDir = path.join(screenshotsBaseDir, siteId); // nosemgrep: path-join-resolve-traversal 161 const desktopAbove = path.join(siteDir, SCREENSHOT_FILES.desktop_above); // nosemgrep: path-join-resolve-traversal 162 await fs.access(desktopAbove); 163 return true; 164 } catch { 165 return false; 166 } 167 } 168 169 /** 170 * Check if cropped screenshots exist for a site 171 * Only checks for the 3 essential cropped images (not uncropped versions) 172 * @param {string} screenshotPath - Screenshot path from database 173 * @returns {Promise<Object>} Object with exists boolean and missing array 174 */ 175 export async function croppedScreenshotsExist(screenshotPath) { 176 if (!screenshotPath) { 177 return { exists: false, missing: ['desktop_above', 'desktop_below', 'mobile_above'] }; 178 } 179 180 // Extract just the site ID from the path (e.g., "screenshots/1" -> "1") 181 const siteId = screenshotPath.split('/').pop(); 182 const siteDir = path.join(screenshotsBaseDir, siteId); // nosemgrep: path-join-resolve-traversal 183 const missing = []; 184 185 // Only check for cropped versions (the 3 essential screenshots) 186 const croppedFiles = ['desktop_above', 'desktop_below', 'mobile_above']; 187 188 for (const key of croppedFiles) { 189 const filename = SCREENSHOT_FILES[key]; 190 const filePath = path.join(siteDir, filename); // nosemgrep: path-join-resolve-traversal 191 try { 192 await fs.access(filePath); 193 } catch { 194 missing.push(key); 195 } 196 } 197 198 return { 199 exists: missing.length === 0, 200 missing, 201 }; 202 } 203 204 /** 205 * Check if all 6 screenshots exist for a site 206 * @param {string} screenshotPath - Screenshot path from database 207 * @returns {Promise<Object>} Object with exists boolean and missing array 208 */ 209 export async function allScreenshotsExist(screenshotPath) { 210 if (!screenshotPath) { 211 return { exists: false, missing: Object.keys(SCREENSHOT_FILES) }; 212 } 213 214 // Extract just the site ID from the path (e.g., "screenshots/1" -> "1") 215 const siteId = screenshotPath.split('/').pop(); 216 const siteDir = path.join(screenshotsBaseDir, siteId); // nosemgrep: path-join-resolve-traversal 217 const missing = []; 218 219 for (const [key, filename] of Object.entries(SCREENSHOT_FILES)) { 220 const filePath = path.join(siteDir, filename); // nosemgrep: path-join-resolve-traversal 221 try { 222 await fs.access(filePath); 223 } catch { 224 missing.push(key); 225 } 226 } 227 228 return { 229 exists: missing.length === 0, 230 missing, 231 }; 232 } 233 234 /** 235 * Delete screenshots for a site 236 * @param {string} screenshotPath - Screenshot path from database 237 * @returns {Promise<void>} 238 */ 239 export async function deleteScreenshots(screenshotPath) { 240 if (!screenshotPath) { 241 return; 242 } 243 244 // Extract just the site ID from the path (e.g., "screenshots/1" -> "1") 245 const siteId = screenshotPath.split('/').pop(); 246 const siteDir = path.join(screenshotsBaseDir, siteId); // nosemgrep: path-join-resolve-traversal 247 248 try { 249 await fs.rm(siteDir, { recursive: true, force: true }); 250 } catch (error) { 251 // Ignore if directory doesn't exist 252 if (error.code !== 'ENOENT') { 253 throw error; 254 } 255 } 256 } 257 258 export default { 259 saveScreenshots, 260 loadScreenshots, 261 loadScreenshot, 262 screenshotsExist, 263 croppedScreenshotsExist, 264 allScreenshotsExist, 265 deleteScreenshots, 266 SCREENSHOT_FILES, 267 };