All files / src/utils r2.js

96.96% Statements 64/66
100% Branches 62/62
92.85% Functions 13/14
96.87% Lines 62/64

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 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278                                28x 28x 28x   28x                             28x                                             9x 9x 5x   9x 9x 9x 8x 2x     9x     28x     28x 11x 11x 11x 11x               17x 17x                                             28x     191x                   190x       13x 12x                                   3x       69x   68x 70x 70x     3692x             620x     620x 623x               622x 463x   622x     619x               14x     14x 15x               14x 13x   14x     13x                                                 8x 2x   6x 2x   4x 4x       7x 1x   6x 1x   5x 1x   4x         4x     28x                              
/**
 * 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,
  HeadObjectCommand,
} = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { NodeHttpHandler } = require('@smithy/node-http-handler');
 
const isLocal = process.env.NODE_ENV === 'local';
 
/**
 * SHY-0501 — bound how long a storage call may wait to START.
 *
 * The SDK's defaults are no timeout at all, which is what turned a socket leak
 * into a silent hang: with all 50 pooled sockets stuck in CLOSE_WAIT, every new
 * request queued for a connection that would never come, and the caller waited
 * forever with nothing logged.
 *
 * These bound establishing and idling, NOT total transfer time, so a large
 * export streaming slowly is unaffected while a pool that cannot serve anybody
 * fails loudly instead of quietly.
 */
function storageRequestHandler() {
  return new NodeHttpHandler({
    connectionTimeout: 5_000,
    requestTimeout: 30_000,
  });
}
 
/**
 * Pipes a storage object body to an HTTP response, destroying it if the client
 * leaves first.
 *
 * SHY-0501. Four routes streamed an R2 body straight to the client and simply
 * dropped it when the client went away — a moderator scrubbing a reported
 * video, a browser cancelling a large zip, a phone losing signal. Nothing
 * closed this side of the connection, so it sat in CLOSE_WAIT forever. Fifty of
 * those exhausted the pool and every storage operation stopped, with no error
 * anywhere: attachments, exports and backups all dead until a restart.
 *
 * The `finished` flag is the whole subtlety. Express emits `close` on EVERY
 * response including successful ones, so destroying unconditionally would
 * truncate downloads that had already completed. Destroy only when the response
 * closed while the body was still going.
 */
function pipeToResponse(body, res) {
  let finished = false;
  const done = () => {
    finished = true;
  };
  body.once?.('end', done);
  body.once?.('error', done);
  res.once('close', () => {
    if (!finished && typeof body.destroy === 'function' && !body.destroyed) {
      body.destroy();
    }
  });
  return body.pipe(res);
}
 
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,
    requestHandler: storageRequestHandler(),
  });
} 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,
    },
    requestHandler: storageRequestHandler(),
  });
}
 
// Env-aware fallback. The previous shape was `isLocal ? local : prod`,
// which leaked the prod CDN URL on the dev environment (NODE_ENV !=
// local). The prod CDN host is publicly readable so this isn't a
// credential leak, but URLs returned by routes that consume
// `CDN_URL` (image responses, etc.) would silently point dev clients
// at prod-hosted media. Now: prod → prod CDN, local → MinIO,
// otherwise (dev / staging) → dev CDN. See feedback-environment-
// isolation memory.
// Single-line ternary kept (prettier-ignore) so the pre-commit URL-
// isolation guard sees the localhost fallback alongside the prod URL.
 
// prettier-ignore
const CDN_URL = process.env.CDN_URL || (isLocal ? `${process.env.MINIO_ENDPOINT || 'http://localhost:9002'}/${bucketName}` : process.env.NODE_ENV === 'production' ? 'https://images.shytalk.shyden.co.uk' : 'https://dev-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;
}
 
/**
 * What a stored object IS, without pulling its bytes (SHY-0420).
 *
 * The limits are checked against what was actually stored rather than what the
 * request claims, and a HEAD answers "what type, how big" for the cost of one
 * round trip instead of a download of somebody's video.
 *
 * @returns {Promise<{contentType: string|undefined, size: number|undefined}>}
 */
async function headObject(key) {
  const resp = await s3.send(new HeadObjectCommand({ Bucket: bucketName, Key: key }));
  return { contentType: resp.ContentType, size: resp.ContentLength };
}
 
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;
}
 
/**
 * Issues a short-lived signed PUT URL the client can upload directly
 * to (no Express proxy). Used by age-verification submission flow:
 * the client gets a URL for `age-verification/<uid>/<random>.jpg`,
 * PUTs the ID image, then notifies the API of the R2 key.
 *
 * Defaults to 5-minute expiry to limit replay if the URL is
 * intercepted. Hard-caps overrides at 1 hour — anything longer is a
 * code smell and the helper refuses.
 */
/**
 * Pre-sign a GET to a private R2 object (admin-only viewing).
 *
 * Used for age-verification ID image preview in the admin panel: the
 * browser fetches the image directly from R2 with a short-lived URL
 * rather than streaming through Express. Cuts server load and lets
 * the browser cache normally.
 *
 * Default 5-minute expiry mirrors getSignedPutUrl. Hard-caps at 1h —
 * anything longer is a code smell for ID-image preview.
 */
async function getSignedGetUrl(key, expiresInSec = 300) {
  if (typeof key !== 'string' || key.length === 0) {
    throw new Error('r2.getSignedGetUrl: key must be a non-empty string');
  }
  if (typeof expiresInSec !== 'number' || expiresInSec <= 0 || expiresInSec > 3600) {
    throw new Error('r2.getSignedGetUrl: expiresInSec must be in (0, 3600]');
  }
  const command = new GetObjectCommand({ Bucket: bucketName, Key: key });
  return getSignedUrl(s3, command, { expiresIn: expiresInSec });
}
 
async function getSignedPutUrl(key, contentType, expiresInSec = 300) {
  if (typeof key !== 'string' || key.length === 0) {
    throw new Error('r2.getSignedPutUrl: key must be a non-empty string');
  }
  if (typeof contentType !== 'string' || contentType.length === 0) {
    throw new Error('r2.getSignedPutUrl: contentType must be a non-empty string');
  }
  if (typeof expiresInSec !== 'number' || expiresInSec <= 0 || expiresInSec > 3600) {
    throw new Error('r2.getSignedPutUrl: expiresInSec must be in (0, 3600]');
  }
  const command = new PutObjectCommand({
    Bucket: bucketName,
    Key: key,
    ContentType: contentType,
  });
  return getSignedUrl(s3, command, { expiresIn: expiresInSec });
}
 
module.exports = {
  pipeToResponse,
  s3,
  bucketName,
  putObject,
  getObject,
  headObject,
  deleteObject,
  deleteObjects,
  listObjects,
  listObjectsWithMetadata,
  getSignedPutUrl,
  getSignedGetUrl,
  CDN_URL,
};