File size: 2,364 Bytes
74bfb65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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}`;
}