import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { randomUUID } from 'node:crypto'; import { PassThrough, type Readable } from 'node:stream'; const bucket = process.env.R2_BUCKET; const s3 = new S3Client({ region: 'auto', endpoint: process.env.R2_ACCOUNT_ID ? `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` : undefined, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID || '', secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || '', }, }); function objectKey(id: string) { return `article-images/${id}`; } export function articleImageFile(id: string) { if (!bucket) throw new Error('App Storage is not configured.'); const key = objectKey(id); return { async getMetadata() { const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); return [{ contentType: head.ContentType }] as const; }, createReadStream() { const stream = new PassThrough(); s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })) .then((object) => { const body = object.Body as Readable; body.on('error', (err) => stream.emit('error', err)); body.pipe(stream); }) .catch((err) => stream.emit('error', err)); return stream; }, async save(bytes: Buffer, options: { contentType: string }) { await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: bytes, ContentType: options.contentType })); }, }; } // Reject renamed HTML/SVG and executable files, not just incorrect MIME labels. export function imageMime(bytes: Buffer) { if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png'; if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg'; if (/^GIF8[79]a$/.test(bytes.subarray(0, 6).toString())) return 'image/gif'; if (bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP') return 'image/webp'; return null; } export async function saveArticleImage(bytes: Buffer) { const contentType = imageMime(bytes); if (!contentType) throw new Error('Choose a JPG, PNG, WEBP or GIF image.'); const id = randomUUID(); await articleImageFile(id).save(bytes, { contentType }); return `/api/article-images/${id}`; }