mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
import {
|
|
AbortMultipartUploadCommand,
|
|
CompleteMultipartUploadCommand,
|
|
CopyObjectCommand,
|
|
CreateMultipartUploadCommand,
|
|
DeleteObjectCommand,
|
|
GetObjectCommand,
|
|
HeadObjectCommand,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
UploadPartCommand,
|
|
} from '@aws-sdk/client-s3'
|
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
import type { Storage } from '../../shared/types'
|
|
|
|
const DEFAULT_EXPIRES_IN = 3600
|
|
const MULTIPART_PART_SIZE = 5 * 1024 * 1024
|
|
const SMALL_STREAM_PUT_BUFFER_SIZE = 256 * 1024
|
|
|
|
export class S3Service {
|
|
createClient(storage: Storage): S3Client {
|
|
return new S3Client({
|
|
region: storage.region,
|
|
endpoint: storage.endpoint,
|
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
credentials: {
|
|
accessKeyId: storage.accessKey,
|
|
secretAccessKey: storage.secretKey,
|
|
},
|
|
forcePathStyle: true,
|
|
})
|
|
}
|
|
|
|
async presignUpload(
|
|
storage: Storage,
|
|
key: string,
|
|
contentType: string,
|
|
filenameOrExpiresIn?: string | number,
|
|
expiresIn = DEFAULT_EXPIRES_IN,
|
|
): Promise<string> {
|
|
let filename: string | undefined
|
|
let ttl = expiresIn
|
|
if (typeof filenameOrExpiresIn === 'string') {
|
|
filename = filenameOrExpiresIn
|
|
} else if (typeof filenameOrExpiresIn === 'number') {
|
|
ttl = filenameOrExpiresIn
|
|
}
|
|
|
|
const client = this.createClient(storage)
|
|
const command = new PutObjectCommand({
|
|
Bucket: storage.bucket,
|
|
Key: key,
|
|
ContentType: contentType,
|
|
...(filename
|
|
? {
|
|
ContentDisposition: `attachment; filename="${filename.replace(/"/g, '\\"')}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
}
|
|
: {}),
|
|
})
|
|
const url = await getSignedUrl(client, command, { expiresIn: ttl })
|
|
return url
|
|
}
|
|
|
|
async createMultipartUpload(storage: Storage, key: string, contentType: string): Promise<string> {
|
|
const client = this.createClient(storage)
|
|
const url = await getSignedUrl(
|
|
client,
|
|
new CreateMultipartUploadCommand({ Bucket: storage.bucket, Key: key, ContentType: contentType }),
|
|
{ expiresIn: DEFAULT_EXPIRES_IN },
|
|
)
|
|
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': contentType } })
|
|
const body = await response.text()
|
|
if (!response.ok) throw new Error(`S3 multipart upload create failed: ${response.status}: ${body.trim()}`)
|
|
const uploadId = xmlTag(body, 'UploadId')
|
|
if (!uploadId) throw new Error('S3 multipart upload did not return an upload id')
|
|
return uploadId
|
|
}
|
|
|
|
async presignUploadPart(
|
|
storage: Storage,
|
|
key: string,
|
|
uploadId: string,
|
|
partNumber: number,
|
|
expiresIn = DEFAULT_EXPIRES_IN,
|
|
): Promise<string> {
|
|
const client = this.createClient(storage)
|
|
return getSignedUrl(
|
|
client,
|
|
new UploadPartCommand({ Bucket: storage.bucket, Key: key, UploadId: uploadId, PartNumber: partNumber }),
|
|
{ expiresIn },
|
|
)
|
|
}
|
|
|
|
async completeMultipartUpload(
|
|
storage: Storage,
|
|
key: string,
|
|
uploadId: string,
|
|
parts: Array<{ etag: string; partNumber: number }>,
|
|
): Promise<void> {
|
|
const client = this.createClient(storage)
|
|
const sortedParts = parts
|
|
.map((part) => ({ ETag: part.etag, PartNumber: part.partNumber }))
|
|
.sort((a, b) => a.PartNumber - b.PartNumber)
|
|
const url = await getSignedUrl(
|
|
client,
|
|
new CompleteMultipartUploadCommand({
|
|
Bucket: storage.bucket,
|
|
Key: key,
|
|
UploadId: uploadId,
|
|
}),
|
|
{ expiresIn: DEFAULT_EXPIRES_IN },
|
|
)
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/xml' },
|
|
body: multipartCompleteXml(sortedParts),
|
|
})
|
|
const body = await response.text()
|
|
if (!response.ok) {
|
|
throw new Error(`S3 multipart upload complete failed: ${response.status}: ${body.trim()}`)
|
|
}
|
|
if (xmlTag(body, 'Error') !== null || xmlTag(body, 'Code') !== null) {
|
|
throw new Error(`S3 multipart upload complete failed: ${response.status}: ${body.trim()}`)
|
|
}
|
|
}
|
|
|
|
async abortMultipartUpload(storage: Storage, key: string, uploadId: string): Promise<void> {
|
|
const client = this.createClient(storage)
|
|
const url = await getSignedUrl(
|
|
client,
|
|
new AbortMultipartUploadCommand({ Bucket: storage.bucket, Key: key, UploadId: uploadId }),
|
|
{ expiresIn: DEFAULT_EXPIRES_IN },
|
|
)
|
|
const response = await fetch(url, { method: 'DELETE' })
|
|
if (!response.ok) {
|
|
const body = await response.text()
|
|
throw new Error(`S3 multipart upload abort failed: ${response.status}: ${body.trim()}`)
|
|
}
|
|
}
|
|
|
|
async presignDownload(
|
|
storage: Storage,
|
|
key: string,
|
|
filename: string,
|
|
expiresIn = DEFAULT_EXPIRES_IN,
|
|
): Promise<string> {
|
|
const client = this.createClient(storage)
|
|
const command = new GetObjectCommand({
|
|
Bucket: storage.bucket,
|
|
Key: key,
|
|
ResponseContentDisposition: `attachment; filename="${filename.replace(/"/g, '\\"')}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
})
|
|
const url = await getSignedUrl(client, command, { expiresIn })
|
|
return this.applyCustomHost(storage, url)
|
|
}
|
|
|
|
async presignInline(storage: Storage, key: string, mime: string, expiresIn = DEFAULT_EXPIRES_IN): Promise<string> {
|
|
const client = this.createClient(storage)
|
|
const command = new GetObjectCommand({
|
|
Bucket: storage.bucket,
|
|
Key: key,
|
|
ResponseContentDisposition: 'inline',
|
|
ResponseContentType: mime,
|
|
})
|
|
const url = await getSignedUrl(client, command, { expiresIn })
|
|
return this.applyCustomHost(storage, url)
|
|
}
|
|
|
|
private applyCustomHost(storage: Storage, url: string): string {
|
|
if (!storage.customHost) return url
|
|
|
|
let customHost = storage.customHost.trim()
|
|
if (!/^https?:\/\//i.test(customHost)) {
|
|
customHost = `https://${customHost}`
|
|
}
|
|
|
|
const parsed = new URL(url)
|
|
const custom = new URL(customHost)
|
|
|
|
parsed.protocol = custom.protocol
|
|
parsed.host = custom.host
|
|
|
|
const bucketPrefix = `/${storage.bucket}/`
|
|
if (parsed.pathname.startsWith(bucketPrefix)) {
|
|
parsed.pathname = `/${parsed.pathname.slice(bucketPrefix.length)}`
|
|
} else if (parsed.pathname === `/${storage.bucket}`) {
|
|
parsed.pathname = '/'
|
|
}
|
|
|
|
return parsed.toString()
|
|
}
|
|
|
|
getPublicUrl(storage: Storage, key: string): string {
|
|
if (storage.customHost) {
|
|
return `${storage.customHost.replace(/\/$/, '')}/${key}`
|
|
}
|
|
return `${storage.endpoint.replace(/\/$/, '')}/${storage.bucket}/${key}`
|
|
}
|
|
|
|
async headObject(storage: Storage, key: string): Promise<{ size: number; contentType: string }> {
|
|
const client = this.createClient(storage)
|
|
const result = await client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: key }))
|
|
return {
|
|
size: result.ContentLength ?? 0,
|
|
contentType: result.ContentType ?? 'application/octet-stream',
|
|
}
|
|
}
|
|
|
|
async getObjectBytes(storage: Storage, key: string, range?: string): Promise<Uint8Array> {
|
|
return bodyToBytes(await this.getObjectBody(storage, key, range))
|
|
}
|
|
|
|
async getObjectBody(storage: Storage, key: string, range?: string): Promise<BodyInit> {
|
|
const client = this.createClient(storage)
|
|
const url = await getSignedUrl(client, new GetObjectCommand({ Bucket: storage.bucket, Key: key }), {
|
|
expiresIn: DEFAULT_EXPIRES_IN,
|
|
})
|
|
const response = await fetch(url, range ? { headers: { Range: range } } : undefined)
|
|
if (!response.ok) throw new Error(`S3 object read failed: ${response.status}`)
|
|
if (!response.body) return response.arrayBuffer()
|
|
return response.body
|
|
}
|
|
|
|
async getObjectStream(storage: Storage, key: string, range?: string): Promise<ReadableStream<Uint8Array>> {
|
|
const body = await this.getObjectBody(storage, key, range)
|
|
return bodyToReadableStream(body)
|
|
}
|
|
|
|
async copyObject(srcStorage: Storage, srcKey: string, dstStorage: Storage, dstKey: string): Promise<void> {
|
|
const client = this.createClient(dstStorage)
|
|
await client.send(
|
|
new CopyObjectCommand({
|
|
CopySource: `${srcStorage.bucket}/${srcKey}`,
|
|
Bucket: dstStorage.bucket,
|
|
Key: dstKey,
|
|
}),
|
|
)
|
|
}
|
|
|
|
async streamCopy(srcStorage: Storage, srcKey: string, dstStorage: Storage, dstKey: string): Promise<void> {
|
|
const srcClient = this.createClient(srcStorage)
|
|
const getResult = await srcClient.send(new GetObjectCommand({ Bucket: srcStorage.bucket, Key: srcKey }))
|
|
if (!getResult.Body) throw new Error('Empty body from source object')
|
|
|
|
const dstClient = this.createClient(dstStorage)
|
|
await dstClient.send(
|
|
new PutObjectCommand({
|
|
Bucket: dstStorage.bucket,
|
|
Key: dstKey,
|
|
// biome-ignore lint/suspicious/noExplicitAny: AWS SDK stream type differs across Node and CF Workers runtimes
|
|
Body: getResult.Body as any,
|
|
ContentType: getResult.ContentType,
|
|
ContentLength: getResult.ContentLength,
|
|
}),
|
|
)
|
|
}
|
|
|
|
async putObject(
|
|
storage: Storage,
|
|
key: string,
|
|
body: ReadableStream | Uint8Array,
|
|
contentType: string,
|
|
contentLength?: number,
|
|
): Promise<number> {
|
|
if (body instanceof ReadableStream) {
|
|
if (contentLength === undefined) return this.putObjectMultipartStream(storage, key, body, contentType)
|
|
if (contentLength <= SMALL_STREAM_PUT_BUFFER_SIZE) {
|
|
const bytes = await streamToBytes(body)
|
|
if (bytes.byteLength !== contentLength) throw new Error('Request body length does not match Content-Length')
|
|
return this.putObject(storage, key, bytes, contentType)
|
|
}
|
|
await this.putObjectStream(storage, key, body, contentType, contentLength)
|
|
return contentLength
|
|
}
|
|
|
|
const client = this.createClient(storage)
|
|
await client.send(
|
|
new PutObjectCommand({
|
|
Bucket: storage.bucket,
|
|
Key: key,
|
|
Body: body,
|
|
ContentType: contentType,
|
|
ContentLength: body.byteLength,
|
|
}),
|
|
)
|
|
return body.byteLength
|
|
}
|
|
|
|
private async putObjectStream(
|
|
storage: Storage,
|
|
key: string,
|
|
body: ReadableStream,
|
|
contentType: string,
|
|
contentLength: number,
|
|
): Promise<void> {
|
|
const url = await this.presignUpload(storage, key, contentType)
|
|
const response = await fetch(url, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': contentType,
|
|
'Content-Length': String(contentLength),
|
|
},
|
|
body,
|
|
})
|
|
if (!response.ok) throw new Error(`S3 stream upload failed: ${response.status}`)
|
|
}
|
|
|
|
private async putObjectMultipartStream(
|
|
storage: Storage,
|
|
key: string,
|
|
body: ReadableStream,
|
|
contentType: string,
|
|
): Promise<number> {
|
|
const uploadId = await this.createMultipartUpload(storage, key, contentType)
|
|
const client = this.createClient(storage)
|
|
|
|
const reader = body.getReader()
|
|
const parts: Array<{ ETag: string; PartNumber: number }> = []
|
|
let pending = new Uint8Array() as Uint8Array<ArrayBufferLike>
|
|
let total = 0
|
|
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
pending = concatBytes(pending, value)
|
|
while (pending.byteLength >= MULTIPART_PART_SIZE) {
|
|
const part = pending.slice(0, MULTIPART_PART_SIZE)
|
|
pending = pending.slice(MULTIPART_PART_SIZE)
|
|
parts.push(await this.uploadPart(client, storage.bucket, key, uploadId, parts.length + 1, part))
|
|
total += part.byteLength
|
|
}
|
|
}
|
|
|
|
if (pending.byteLength > 0) {
|
|
parts.push(await this.uploadPart(client, storage.bucket, key, uploadId, parts.length + 1, pending))
|
|
total += pending.byteLength
|
|
}
|
|
|
|
if (parts.length === 0) {
|
|
await this.abortMultipartUpload(storage, key, uploadId)
|
|
await client.send(
|
|
new PutObjectCommand({
|
|
Bucket: storage.bucket,
|
|
Key: key,
|
|
Body: new Uint8Array(),
|
|
ContentType: contentType,
|
|
ContentLength: 0,
|
|
}),
|
|
)
|
|
return 0
|
|
}
|
|
|
|
await this.completeMultipartUpload(
|
|
storage,
|
|
key,
|
|
uploadId,
|
|
parts.map((part) => ({ etag: part.ETag, partNumber: part.PartNumber })),
|
|
)
|
|
return total
|
|
} catch (e) {
|
|
await this.abortMultipartUpload(storage, key, uploadId)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
private async uploadPart(
|
|
client: S3Client,
|
|
bucket: string,
|
|
key: string,
|
|
uploadId: string,
|
|
partNumber: number,
|
|
body: Uint8Array<ArrayBufferLike>,
|
|
): Promise<{ ETag: string; PartNumber: number }> {
|
|
const result = await client.send(
|
|
new UploadPartCommand({
|
|
Bucket: bucket,
|
|
Key: key,
|
|
UploadId: uploadId,
|
|
PartNumber: partNumber,
|
|
Body: body,
|
|
ContentLength: body.byteLength,
|
|
}),
|
|
)
|
|
return { ETag: result.ETag ?? `"part-${partNumber}"`, PartNumber: partNumber }
|
|
}
|
|
|
|
async deleteObject(storage: Storage, key: string): Promise<void> {
|
|
const client = this.createClient(storage)
|
|
await client.send(new DeleteObjectCommand({ Bucket: storage.bucket, Key: key }))
|
|
}
|
|
|
|
async deleteObjects(storage: Storage, keys: string[]): Promise<void> {
|
|
if (keys.length === 0) return
|
|
// DeleteObjectsCommand returns XML which requires DOMParser to parse.
|
|
// Cloudflare Workers doesn't have DOMParser, so we delete one-by-one.
|
|
await Promise.all(keys.map((key) => this.deleteObject(storage, key)))
|
|
}
|
|
}
|
|
|
|
async function bodyToBytes(body: unknown): Promise<Uint8Array> {
|
|
const streamBody = body as {
|
|
transformToByteArray?: () => Promise<Uint8Array>
|
|
arrayBuffer?: () => Promise<ArrayBuffer>
|
|
}
|
|
if (streamBody.transformToByteArray) return streamBody.transformToByteArray()
|
|
if (body instanceof Uint8Array) return body
|
|
if (body instanceof ReadableStream) return streamToBytes(body)
|
|
if (streamBody.arrayBuffer) return new Uint8Array(await streamBody.arrayBuffer())
|
|
|
|
throw new Error('Unsupported object body')
|
|
}
|
|
|
|
async function streamToBytes(body: ReadableStream): Promise<Uint8Array> {
|
|
return new Uint8Array(await new Response(body).arrayBuffer())
|
|
}
|
|
|
|
function bodyToReadableStream(body: BodyInit): ReadableStream<Uint8Array> {
|
|
if (body instanceof ReadableStream) return body as ReadableStream<Uint8Array>
|
|
if (body instanceof Uint8Array) return bytesToStream(body)
|
|
if (body instanceof ArrayBuffer) return bytesToStream(new Uint8Array(body))
|
|
if (body instanceof Blob) return body.stream()
|
|
throw new Error('Unsupported object body')
|
|
}
|
|
|
|
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(bytes)
|
|
controller.close()
|
|
},
|
|
})
|
|
}
|
|
|
|
function concatBytes(
|
|
left: Uint8Array<ArrayBufferLike>,
|
|
right: Uint8Array<ArrayBufferLike>,
|
|
): Uint8Array<ArrayBufferLike> {
|
|
if (left.byteLength === 0) return right
|
|
const merged = new Uint8Array(left.byteLength + right.byteLength)
|
|
merged.set(left)
|
|
merged.set(right, left.byteLength)
|
|
return merged
|
|
}
|
|
|
|
function multipartCompleteXml(parts: Array<{ ETag: string; PartNumber: number }>): string {
|
|
return `<CompleteMultipartUpload>${parts
|
|
.map((part) => `<Part><PartNumber>${part.PartNumber}</PartNumber><ETag>${xmlEscape(part.ETag)}</ETag></Part>`)
|
|
.join('')}</CompleteMultipartUpload>`
|
|
}
|
|
|
|
function xmlTag(xml: string, tag: string): string | null {
|
|
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
const match = new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`).exec(xml)
|
|
return match ? xmlUnescape(match[1].trim()) : null
|
|
}
|
|
|
|
function xmlEscape(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
function xmlUnescape(value: string): string {
|
|
return value
|
|
.replace(/'/g, "'")
|
|
.replace(/"/g, '"')
|
|
.replace(/>/g, '>')
|
|
.replace(/</g, '<')
|
|
.replace(/&/g, '&')
|
|
}
|