All files / src/routes storage.js

96.72% Statements 59/61
93.75% Branches 15/16
100% Functions 2/2
96.72% Lines 59/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126                    3x 3x 3x 3x 3x 3x 3x   3x 3x   3x                       3x 14x 14x 14x 14x   14x 1x 1x     13x 1x 1x     12x               12x 12x 3x 3x       9x 9x 9x 9x   9x 9x 8x 8x 8x 8x   1x           9x 9x   9x 9x 9x               3x 4x 4x 4x   4x 1x 1x     3x 3x 1x 1x     2x 1x 1x   1x         1x       3x  
/**
 * Storage routes — upload and delete files via R2.
 *
 * Converted from the standalone Cloudflare Worker storage proxy (worker/index.js)
 * into an Express route with multer for multipart file uploads.
 *
 * POST   /api/storage/upload  → Upload a file to R2, return public URL
 * DELETE /api/storage/delete  → Delete a file from R2 (owner-only)
 */
 
const crypto = require('node:crypto');
const express = require('express');
const multer = require('multer');
const r2 = require('../utils/r2');
const { getExtension } = require('../utils/helpers');
const log = require('../utils/log');
const { compressImage } = require('../utils/imageCompressor');
 
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
 
const ALLOWED_UPLOAD_PATHS = [
  'profiles',
  'covers',
  'messages',
  'groups',
  'evidence',
  'stickers',
  'banners',
  'starting-screens',
];
 
// POST /api/storage/upload
router.post('/storage/upload', upload.single('file'), async (req, res) => {
  try {
    const file = req.file;
    const path = req.body.path;
    const uniqueId = req.auth.uniqueId;
 
    if (!file || !path) {
      log.warn('storage', 'Upload missing params', { uniqueId, hasFile: !!file, hasPath: !!path });
      return res.status(400).json({ error: 'Missing file or path' });
    }
 
    if (!ALLOWED_UPLOAD_PATHS.includes(path)) {
      log.warn('storage', 'Upload to disallowed path', { uniqueId, path });
      return res.status(400).json({ error: 'Invalid upload path' });
    }
 
    const ALLOWED_MIME_TYPES = [
      'image/jpeg',
      'image/png',
      'image/webp',
      'image/gif',
      'image/heic',
      'image/heif',
    ];
    const contentType = file.mimetype || 'image/jpeg';
    if (!ALLOWED_MIME_TYPES.includes(contentType)) {
      log.warn('storage', 'Upload rejected: disallowed MIME type', { uniqueId, contentType });
      return res
        .status(400)
        .json({ error: 'Only image uploads are allowed (jpeg, png, webp, gif, heic, heif)' });
    }
    let uploadBuffer = file.buffer;
    let uploadMime = contentType;
    let originalSize = file.buffer.length;
    let compressedSize = file.buffer.length;
 
    try {
      const compressed = await compressImage(file.buffer, contentType);
      uploadBuffer = compressed.buffer;
      uploadMime = compressed.mimeType;
      originalSize = compressed.originalSize;
      compressedSize = compressed.compressedSize;
    } catch (compressionErr) {
      log.warn('storage', 'Compression failed, storing original', {
        error: compressionErr.message,
      });
    }
 
    // Compute extension and key AFTER compression (HEIC→JPEG changes MIME)
    const extension = getExtension(uploadMime);
    const key = `${path}/${uniqueId}/${Date.now()}-${crypto.randomBytes(4).toString('hex')}.${extension}`;
 
    const url = await r2.putObject(key, uploadBuffer, uploadMime);
    log.info('storage', 'File uploaded', { key, uniqueId, contentType: uploadMime });
    res.json({ url, originalSize, compressedSize });
  } catch (err) {
    log.error('storage', 'Upload failed', { uniqueId: req.auth?.uniqueId, error: err.message });
    res.status(500).json({ error: 'Upload failed' });
  }
});
 
// DELETE /api/storage/delete
router.delete('/storage/delete', async (req, res) => {
  try {
    const key = req.query.key;
    const uniqueId = req.auth.uniqueId;
 
    if (!key) {
      log.warn('storage', 'Delete missing key', { uniqueId });
      return res.status(400).json({ error: 'Missing key' });
    }
    // Verify the key belongs to this user: format is "{path}/{uniqueId}/{filename}"
    const keyParts = key.split('/');
    if (keyParts.length < 3 || keyParts[1] !== String(uniqueId)) {
      log.warn('storage', 'Delete forbidden — key does not belong to user', { uniqueId, key });
      return res.status(403).json({ error: 'Forbidden' });
    }
 
    await r2.deleteObject(key);
    log.info('storage', 'File deleted', { key, uniqueId });
    res.json({ success: true });
  } catch (err) {
    log.error('storage', 'Delete failed', {
      uniqueId: req.auth?.uniqueId,
      key: req.query.key,
      error: err.message,
    });
    res.status(500).json({ error: 'Delete failed' });
  }
});
 
module.exports = router;