/ src / utils / screenshot-storage.js
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  };