All files / src/utils r2.js

100% Statements 38/38
100% Branches 31/31
100% Functions 7/7
100% Lines 36/36

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 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146                              6x   6x 6x     6x 4x 4x 4x 4x             2x 2x                     6x           4x                   3x       3x 2x       3x       5x   4x 6x 6x     3505x             7x     7x 10x               9x 7x   9x     6x               5x     5x 6x               5x 3x   5x     4x     6x                      
/**
 * R2 / MinIO storage client via S3-compatible API.
 *
 * In local mode (NODE_ENV=local), connects to MinIO.
 * In production/dev, connects to Cloudflare R2.
 * All endpoints configurable via env vars.
 */
 
const {
  S3Client,
  PutObjectCommand,
  GetObjectCommand,
  DeleteObjectCommand,
  DeleteObjectsCommand,
  ListObjectsV2Command,
} = require('@aws-sdk/client-s3');
 
const isLocal = process.env.NODE_ENV === 'local';
const bucketName = process.env.R2_BUCKET_NAME || 'shytalk-media';
 
let s3;
if (isLocal) {
  const minioEndpoint = process.env.MINIO_ENDPOINT || 'http://localhost:9002';
  const minioUser = process.env.MINIO_ROOT_USER || 'minioadmin';
  const minioPass = process.env.MINIO_ROOT_PASSWORD || 'minioadmin';
  s3 = new S3Client({
    endpoint: minioEndpoint,
    region: 'us-east-1',
    credentials: { accessKeyId: minioUser, secretAccessKey: minioPass },
    forcePathStyle: true,
  });
} else {
  const accountId = process.env.R2_ACCOUNT_ID;
  s3 = new S3Client({
    region: 'auto',
    endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
    credentials: {
      accessKeyId: process.env.R2_ACCESS_KEY_ID,
      secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
    },
  });
}
 
const CDN_URL =
  process.env.CDN_URL ||
  (isLocal
    ? `${process.env.MINIO_ENDPOINT || 'http://localhost:9002'}/${bucketName}`
    : 'https://images.shytalk.shyden.co.uk');
 
async function putObject(key, body, contentType, metadata = {}, options = {}) {
  await s3.send(
    new PutObjectCommand({
      Bucket: bucketName,
      Key: key,
      Body: body,
      ContentType: contentType,
      CacheControl: options.cacheControl || 'public, max-age=31536000, immutable',
      Metadata: metadata,
    }),
  );
  return `${CDN_URL}/${key}`;
}
 
async function getObject(key) {
  const resp = await s3.send(new GetObjectCommand({ Bucket: bucketName, Key: key }));
  return resp;
}
 
async function deleteObject(key) {
  await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }));
}
 
async function deleteObjects(keys) {
  if (keys.length === 0) return;
  // S3 DeleteObjects supports up to 1000 keys per call
  for (let i = 0; i < keys.length; i += 1000) {
    const batch = keys.slice(i, i + 1000);
    await s3.send(
      new DeleteObjectsCommand({
        Bucket: bucketName,
        Delete: { Objects: batch.map((k) => ({ Key: k })) },
      }),
    );
  }
}
 
async function listObjects(prefix, maxKeys = 1000) {
  const allKeys = [];
  let continuationToken;
 
  do {
    const resp = await s3.send(
      new ListObjectsV2Command({
        Bucket: bucketName,
        Prefix: prefix,
        MaxKeys: maxKeys,
        ContinuationToken: continuationToken,
      }),
    );
    for (const obj of resp.Contents || []) {
      allKeys.push(obj.Key);
    }
    continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined;
  } while (continuationToken);
 
  return allKeys;
}
 
/**
 * List R2 objects under a prefix with full metadata (size, lastModified).
 * Used by admin backup/cleanup routes for audit and display.
 */
async function listObjectsWithMetadata(prefix) {
  const objects = [];
  let continuationToken;
 
  do {
    const resp = await s3.send(
      new ListObjectsV2Command({
        Bucket: bucketName,
        Prefix: prefix,
        MaxKeys: 1000,
        ContinuationToken: continuationToken,
      }),
    );
    for (const obj of resp.Contents || []) {
      objects.push({ key: obj.Key, size: obj.Size, lastModified: obj.LastModified });
    }
    continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined;
  } while (continuationToken);
 
  return objects;
}
 
module.exports = {
  s3,
  bucketName,
  putObject,
  getObject,
  deleteObject,
  deleteObjects,
  listObjects,
  listObjectsWithMetadata,
  CDN_URL,
};