doxa-maps-cdn/server/utils/storage.ts
2026-04-23 10:56:15 +01:00

190 lines
5.8 KiB
TypeScript

import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } 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}`)
}
}
/**
* 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
}