File size: 4,249 Bytes
e518a85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import {
  DeleteObjectCommand,
  GetObjectCommand,
  PutObjectCommand,
  S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import * as fs from 'fs';
import * as path from 'path';
import { pipeline } from 'stream/promises';
import { Readable } from 'stream';
import { config } from '../config';

const R2_PREFIX = 'r2://';
let client: S3Client | null = null;

export interface R2ObjectRef {
  bucket: string;
  key: string;
}

function getR2Client(): S3Client {
  if (!client) {
    client = new S3Client({
      region: config.r2Region,
      endpoint: config.r2Endpoint,
      forcePathStyle: true,
      credentials: {
        accessKeyId: config.r2AccessKeyId,
        secretAccessKey: config.r2SecretAccessKey,
      },
    });
  }
  return client;
}

export function isR2ObjectRef(value: string): boolean {
  return value.startsWith(R2_PREFIX);
}

export function createR2ObjectRef(bucket: string, key: string): string {
  return `${R2_PREFIX}${bucket}/${key.replace(/^\/+/, '')}`;
}

export function parseR2ObjectRef(value: string): R2ObjectRef {
  if (!isR2ObjectRef(value)) {
    throw new Error('Not an R2 object reference');
  }

  const withoutScheme = value.slice(R2_PREFIX.length);
  const slashIndex = withoutScheme.indexOf('/');
  if (slashIndex <= 0 || slashIndex === withoutScheme.length - 1) {
    throw new Error('Invalid R2 object reference');
  }

  return {
    bucket: withoutScheme.slice(0, slashIndex),
    key: withoutScheme.slice(slashIndex + 1),
  };
}

export async function putR2Buffer(
  bucket: string,
  key: string,
  body: Buffer,
  contentType: string,
): Promise<string> {
  await getR2Client().send(
    new PutObjectCommand({
      Bucket: bucket,
      Key: key,
      Body: body,
      ContentLength: body.length,
      ContentType: contentType,
    }),
  );
  return createR2ObjectRef(bucket, key);
}

export async function putR2File(
  bucket: string,
  key: string,
  localPath: string,
  contentType: string,
): Promise<string> {
  const stat = await fs.promises.stat(localPath);
  await getR2Client().send(
    new PutObjectCommand({
      Bucket: bucket,
      Key: key,
      Body: fs.createReadStream(localPath),
      ContentLength: stat.size,
      ContentType: contentType,
    }),
  );
  return createR2ObjectRef(bucket, key);
}

export async function downloadR2File(
  objectRef: string,
  localPath: string,
): Promise<void> {
  const { bucket, key } = parseR2ObjectRef(objectRef);
  const response = await getR2Client().send(
    new GetObjectCommand({ Bucket: bucket, Key: key }),
  );
  if (!response.Body) throw new Error(`R2 object body is empty: ${objectRef}`);

  await fs.promises.mkdir(path.dirname(localPath), { recursive: true });
  await pipeline(response.Body as Readable, fs.createWriteStream(localPath));
}

export async function readR2Text(objectRef: string): Promise<string | null> {
  const { bucket, key } = parseR2ObjectRef(objectRef);
  try {
    const response = await getR2Client().send(
      new GetObjectCommand({ Bucket: bucket, Key: key }),
    );
    if (!response.Body) return null;
    return await response.Body.transformToString('utf-8');
  } catch (error: unknown) {
    const name = error instanceof Error ? error.name : '';
    const status = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
      ?.httpStatusCode;
    if (name === 'NoSuchKey' || name === 'NotFound' || status === 404) return null;
    throw error;
  }
}

export async function deleteR2Object(objectRef: string): Promise<void> {
  const { bucket, key } = parseR2ObjectRef(objectRef);
  await getR2Client().send(
    new DeleteObjectCommand({ Bucket: bucket, Key: key }),
  );
}

export async function createR2SignedDownloadUrl(
  objectRef: string,
  expiresInSeconds: number,
  downloadFileName?: string,
): Promise<string> {
  const { bucket, key } = parseR2ObjectRef(objectRef);
  const disposition = downloadFileName
    ? `attachment; filename*=UTF-8''${encodeURIComponent(downloadFileName)}`
    : undefined;
  return getSignedUrl(
    getR2Client(),
    new GetObjectCommand({
      Bucket: bucket,
      Key: key,
      ResponseContentDisposition: disposition,
    }),
    { expiresIn: expiresInSeconds },
  );
}