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,
};
|