286 lines
9.1 KiB
TypeScript
286 lines
9.1 KiB
TypeScript
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
|
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
import { randomBytes } from 'crypto'
|
|
|
|
// S3 Client instance (will be initialized lazily)
|
|
let s3Client: S3Client | null = null
|
|
|
|
const SIGNED_URL_EXPIRATION = 60 * 60 * 24 * 7 // 7 days in seconds
|
|
|
|
// Get S3 settings from environment variables
|
|
function getS3Settings() {
|
|
// Try to get runtime config first (for production builds), then fallback to process.env
|
|
let S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET_NAME, S3_PUBLIC_BASE_URL
|
|
|
|
try {
|
|
// Use runtime config if available (production build)
|
|
const config = useRuntimeConfig()
|
|
S3_ENDPOINT = config.s3Endpoint || process.env.S3_ENDPOINT
|
|
S3_REGION = config.s3Region || process.env.S3_REGION
|
|
S3_ACCESS_KEY_ID = config.s3AccessKeyId || process.env.S3_ACCESS_KEY_ID
|
|
S3_SECRET_ACCESS_KEY = config.s3SecretAccessKey || process.env.S3_SECRET_ACCESS_KEY
|
|
S3_BUCKET_NAME = config.s3BucketName || process.env.S3_BUCKET_NAME
|
|
S3_PUBLIC_BASE_URL = config.s3PublicBaseUrl || process.env.S3_PUBLIC_BASE_URL
|
|
} catch {
|
|
// Fallback to process.env (development mode)
|
|
S3_ENDPOINT = process.env.S3_ENDPOINT
|
|
S3_REGION = process.env.S3_REGION
|
|
S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID
|
|
S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY
|
|
S3_BUCKET_NAME = process.env.S3_BUCKET_NAME
|
|
S3_PUBLIC_BASE_URL = process.env.S3_PUBLIC_BASE_URL
|
|
}
|
|
|
|
return {
|
|
endpoint: S3_ENDPOINT,
|
|
region: S3_REGION || 'us-west-004',
|
|
accessKeyId: S3_ACCESS_KEY_ID,
|
|
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
|
bucketName: S3_BUCKET_NAME,
|
|
publicBaseUrl: S3_PUBLIC_BASE_URL
|
|
}
|
|
}
|
|
|
|
// Initialize S3 client with current settings
|
|
function initializeS3Client() {
|
|
const settings = getS3Settings()
|
|
|
|
if (!settings.endpoint || !settings.accessKeyId || !settings.secretAccessKey || !settings.bucketName) {
|
|
throw new Error('S3 configuration is incomplete. Please set S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, and S3_BUCKET_NAME environment variables.')
|
|
}
|
|
|
|
s3Client = new S3Client({
|
|
endpoint: settings.endpoint,
|
|
region: settings.region,
|
|
credentials: {
|
|
accessKeyId: settings.accessKeyId,
|
|
secretAccessKey: settings.secretAccessKey,
|
|
},
|
|
forcePathStyle: true, // Required for B2
|
|
})
|
|
|
|
return s3Client
|
|
}
|
|
|
|
// Get S3 client (initialize if needed)
|
|
function getS3Client() {
|
|
if (!s3Client) {
|
|
return initializeS3Client()
|
|
}
|
|
return s3Client
|
|
}
|
|
|
|
export interface UploadResult {
|
|
url: string
|
|
key: string
|
|
filename: string
|
|
}
|
|
|
|
/**
|
|
* Upload a file to S3-compatible storage (Backblaze B2)
|
|
*/
|
|
export async function uploadToS3(
|
|
fileData: Buffer,
|
|
originalFilename: string,
|
|
contentType: string,
|
|
visibility: 'public' | 'private' = 'private'
|
|
): Promise<UploadResult> {
|
|
try {
|
|
const client = getS3Client()
|
|
const settings = getS3Settings()
|
|
|
|
// Generate unique filename
|
|
const ext = originalFilename.split('.').pop()
|
|
const randomName = randomBytes(16).toString('hex')
|
|
const key = `uploads/${randomName}.${ext}`
|
|
|
|
// Upload to S3
|
|
const command = new PutObjectCommand({
|
|
Bucket: settings.bucketName,
|
|
Key: key,
|
|
Body: fileData,
|
|
ContentType: contentType,
|
|
})
|
|
|
|
await client.send(command)
|
|
|
|
// Generate URL based on visibility: public returns a stable URL via the
|
|
// configured public base, private returns a time-limited signed URL
|
|
const url = visibility === 'public' ? getPublicUrl(key) : await generateSignedUrl(key)
|
|
|
|
return {
|
|
url,
|
|
key,
|
|
filename: originalFilename,
|
|
}
|
|
} catch (error: any) {
|
|
console.error('S3 upload error:', error)
|
|
throw new Error(`Failed to upload file to storage: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a stable public URL for an object key, using S3_PUBLIC_BASE_URL
|
|
* (e.g. a Cloudflare R2 custom domain). Throws if the base URL is not set.
|
|
*/
|
|
export function getPublicUrl(key: string): string {
|
|
const settings = getS3Settings()
|
|
if (!settings.publicBaseUrl) {
|
|
throw new Error('S3_PUBLIC_BASE_URL is not configured. Set it to your bucket\'s public custom domain to use public uploads or public URLs.')
|
|
}
|
|
const base = settings.publicBaseUrl.replace(/\/$/, '')
|
|
return `${base}/${key}`
|
|
}
|
|
|
|
/**
|
|
* Delete a file from S3-compatible storage
|
|
*/
|
|
export async function deleteFromS3(key: string): Promise<void> {
|
|
try {
|
|
const client = getS3Client()
|
|
const settings = getS3Settings()
|
|
|
|
const command = new DeleteObjectCommand({
|
|
Bucket: settings.bucketName,
|
|
Key: key,
|
|
})
|
|
|
|
await client.send(command)
|
|
} catch (error: any) {
|
|
console.error('S3 delete error:', error)
|
|
throw new Error(`Failed to delete file from storage: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a signed URL for accessing a file in private bucket
|
|
*/
|
|
export async function generateSignedUrl(key: string, expiresIn: number = SIGNED_URL_EXPIRATION): Promise<string> {
|
|
try {
|
|
const client = getS3Client()
|
|
const settings = getS3Settings()
|
|
|
|
const command = new GetObjectCommand({
|
|
Bucket: settings.bucketName,
|
|
Key: key,
|
|
})
|
|
|
|
const url = await getSignedUrl(client, command, { expiresIn })
|
|
return url
|
|
} catch (error: any) {
|
|
console.error('S3 signed URL generation error:', error)
|
|
throw new Error(`Failed to generate signed URL: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a presigned PUT URL for direct browser/agent uploads to S3.
|
|
*
|
|
* Both the Content-Type and the byte size (Content-Length) are signed
|
|
* into the URL — the uploading client must send headers matching both
|
|
* exactly or S3 rejects with SignatureDoesNotMatch. This caps the
|
|
* upload size at the value you sign in (defending against runaway
|
|
* uploads to a public bucket) and binds the declared MIME to the
|
|
* upload without our server having to handle the bytes.
|
|
*
|
|
* Returned URL only authorizes a single PUT to the specified key.
|
|
* After the upload completes, callers should still validate the
|
|
* actual bytes (e.g. magic-byte sniff via a Range GET) — Content-Type
|
|
* binding only constrains the response header S3 stores, not the
|
|
* payload itself. See finalizeImageUpload() in cmsAssets.
|
|
*/
|
|
export async function createPresignedUploadUrl(
|
|
key: string,
|
|
contentType: string,
|
|
contentLength: number,
|
|
expiresIn: number = 5 * 60 // 5 minutes
|
|
): Promise<string> {
|
|
try {
|
|
const client = getS3Client()
|
|
const settings = getS3Settings()
|
|
|
|
const command = new PutObjectCommand({
|
|
Bucket: settings.bucketName,
|
|
Key: key,
|
|
ContentType: contentType,
|
|
ContentLength: contentLength
|
|
})
|
|
|
|
return await getSignedUrl(client, command, {
|
|
expiresIn,
|
|
// Explicitly tell the signer to include Content-Length in the
|
|
// signed-headers list. Without this the AWS SDK signs only the
|
|
// host + content-type by default for PUT presigns.
|
|
unhoistableHeaders: new Set(['content-length'])
|
|
})
|
|
} catch (error: any) {
|
|
console.error('S3 presigned URL generation error:', error)
|
|
throw new Error(`Failed to generate presigned upload URL: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read a byte range from an S3 object. Used by finalizeImageUpload
|
|
* to sniff magic bytes after a presigned PUT — we never trust the
|
|
* agent's claimed Content-Type without verifying the payload.
|
|
*/
|
|
export async function getObjectByteRange(
|
|
key: string,
|
|
start: number,
|
|
end: number
|
|
): Promise<Buffer> {
|
|
const client = getS3Client()
|
|
const settings = getS3Settings()
|
|
const command = new GetObjectCommand({
|
|
Bucket: settings.bucketName,
|
|
Key: key,
|
|
Range: `bytes=${start}-${end}`
|
|
})
|
|
const result = await client.send(command)
|
|
if (!result.Body) throw new Error('S3 returned empty body')
|
|
// result.Body is a stream in Node — collect to buffer
|
|
const stream = result.Body as NodeJS.ReadableStream
|
|
const chunks: Buffer[] = []
|
|
for await (const chunk of stream) {
|
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk as Uint8Array))
|
|
}
|
|
return Buffer.concat(chunks)
|
|
}
|
|
|
|
/**
|
|
* HEAD an S3 object to read its metadata (Content-Length, Content-Type).
|
|
* Returns null if the object doesn't exist.
|
|
*/
|
|
export async function headObject(key: string): Promise<{ contentLength: number; contentType: string | undefined } | null> {
|
|
const client = getS3Client()
|
|
const settings = getS3Settings()
|
|
try {
|
|
const result = await client.send(new HeadObjectCommand({
|
|
Bucket: settings.bucketName,
|
|
Key: key
|
|
}))
|
|
return {
|
|
contentLength: Number(result.ContentLength ?? 0),
|
|
contentType: result.ContentType
|
|
}
|
|
} catch (e: any) {
|
|
if (e?.name === 'NotFound' || e?.$metadata?.httpStatusCode === 404) return null
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate file type for image uploads
|
|
*/
|
|
export function validateImageType(contentType: string): boolean {
|
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif']
|
|
return allowedTypes.includes(contentType)
|
|
}
|
|
|
|
/**
|
|
* Validate file size
|
|
*/
|
|
export function validateFileSize(fileSize: number, maxSizeMB: number = 5): boolean {
|
|
const maxSize = maxSizeMB * 1024 * 1024
|
|
return fileSize <= maxSize
|
|
}
|