monacousa-portal/src/lib/server/storage.ts

902 lines
23 KiB
TypeScript
Raw Normal View History

import { supabaseAdmin } from './supabase';
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
HeadBucketCommand
} from '@aws-sdk/client-s3';
import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner';
export type StorageBucket = 'documents' | 'avatars' | 'event-images';
/**
* Generate a browser-accessible public URL for Supabase Storage
* This uses PUBLIC_SUPABASE_URL instead of the internal Docker URL
*/
function getBrowserAccessibleUrl(bucket: StorageBucket, path: string): string {
return `${PUBLIC_SUPABASE_URL}/storage/v1/object/public/${bucket}/${path}`;
}
export interface UploadResult {
success: boolean;
path?: string;
publicUrl?: string;
localUrl?: string;
s3Url?: string;
error?: string;
}
export interface S3Config {
endpoint: string;
bucket: string;
accessKey: string;
secretKey: string;
region: string;
useSSL: boolean;
forcePathStyle: boolean;
enabled: boolean;
}
let s3ClientCache: S3Client | null = null;
let s3ConfigCache: S3Config | null = null;
let s3ConfigCacheTime: number = 0;
const S3_CONFIG_CACHE_TTL = 60000; // 1 minute cache
/**
* Get S3 configuration from app_settings table
*/
export async function getS3Config(): Promise<S3Config | null> {
// Check cache
if (s3ConfigCache && Date.now() - s3ConfigCacheTime < S3_CONFIG_CACHE_TTL) {
return s3ConfigCache;
}
const { data: settings } = await supabaseAdmin
.from('app_settings')
.select('setting_key, setting_value')
.eq('category', 'storage');
if (!settings || settings.length === 0) {
return null;
}
const config: Record<string, any> = {};
for (const s of settings) {
let value = s.setting_value;
if (typeof value === 'string') {
// Remove surrounding quotes if present (from JSON stringified values)
value = value.replace(/^"|"$/g, '');
}
config[s.setting_key] = value;
}
// Check if S3 is enabled - handle both boolean true and string 'true'
const isEnabled = config.s3_enabled === true || config.s3_enabled === 'true';
// Check if S3 is enabled and configured
if (!isEnabled || !config.s3_endpoint || !config.s3_access_key || !config.s3_secret_key) {
console.log('S3 config check failed:', {
isEnabled,
hasEndpoint: !!config.s3_endpoint,
hasAccessKey: !!config.s3_access_key,
hasSecretKey: !!config.s3_secret_key
});
return null;
}
s3ConfigCache = {
endpoint: config.s3_endpoint,
bucket: config.s3_bucket || 'monacousa-documents',
accessKey: config.s3_access_key,
secretKey: config.s3_secret_key,
region: config.s3_region || 'us-east-1',
useSSL: config.s3_use_ssl === true || config.s3_use_ssl === 'true',
forcePathStyle: config.s3_force_path_style === true || config.s3_force_path_style === 'true' || config.s3_force_path_style === undefined,
enabled: true
};
s3ConfigCacheTime = Date.now();
return s3ConfigCache;
}
/**
* Get or create S3 client
*/
export async function getS3Client(): Promise<S3Client | null> {
const config = await getS3Config();
if (!config) {
return null;
}
// Return cached client if config hasn't changed
if (s3ClientCache && s3ConfigCache) {
return s3ClientCache;
}
s3ClientCache = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: config.forcePathStyle
});
return s3ClientCache;
}
/**
* Clear S3 client cache (call when settings change)
*/
export function clearS3ClientCache(): void {
s3ClientCache = null;
s3ConfigCache = null;
s3ConfigCacheTime = 0;
}
/**
* Test S3 connection
*/
export async function testS3Connection(): Promise<{ success: boolean; error?: string }> {
const config = await getS3Config();
if (!config) {
return { success: false, error: 'S3 not configured. Please configure and enable S3 storage settings first.' };
}
const client = await getS3Client();
if (!client) {
return { success: false, error: 'Failed to create S3 client' };
}
try {
await client.send(new HeadBucketCommand({ Bucket: config.bucket }));
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('S3 connection test error:', error);
return { success: false, error: `S3 connection failed: ${errorMessage}` };
}
}
/**
* Check if S3 storage is enabled
*/
export async function isS3Enabled(): Promise<boolean> {
const config = await getS3Config();
return config !== null && config.enabled;
}
/**
* Get the S3 key with bucket prefix for organization
*/
function getS3Key(bucket: StorageBucket, path: string): string {
return `${bucket}/${path}`;
}
/**
* Upload a file to S3
*/
async function uploadToS3(
bucket: StorageBucket,
path: string,
file: File | ArrayBuffer | Buffer,
options?: {
contentType?: string;
}
): Promise<UploadResult> {
const config = await getS3Config();
const client = await getS3Client();
if (!config || !client) {
return { success: false, error: 'S3 not configured' };
}
try {
const key = getS3Key(bucket, path);
let body: Buffer;
if (file instanceof ArrayBuffer) {
body = Buffer.from(file);
} else if (Buffer.isBuffer(file)) {
body = file;
} else {
// It's a File object
body = Buffer.from(await file.arrayBuffer());
}
await client.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: body,
ContentType: options?.contentType
})
);
// Construct public URL
const protocol = config.useSSL ? 'https' : 'http';
let publicUrl: string;
if (config.forcePathStyle) {
publicUrl = `${config.endpoint}/${config.bucket}/${key}`;
} else {
publicUrl = `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`;
}
return {
success: true,
path: key,
publicUrl
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('S3 upload error:', error);
return { success: false, error: errorMessage };
}
}
/**
* Get a signed URL from S3
*/
async function getS3PresignedUrl(
bucket: StorageBucket,
path: string,
expiresIn: number = 3600
): Promise<{ url: string | null; error: string | null }> {
const config = await getS3Config();
const client = await getS3Client();
if (!config || !client) {
return { url: null, error: 'S3 not configured' };
}
try {
const key = getS3Key(bucket, path);
const command = new GetObjectCommand({
Bucket: config.bucket,
Key: key
});
const url = await getS3SignedUrl(client, command, { expiresIn });
return { url, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('S3 signed URL error:', error);
return { url: null, error: errorMessage };
}
}
/**
* Delete a file from S3
*/
async function deleteFromS3(
bucket: StorageBucket,
path: string
): Promise<{ success: boolean; error?: string }> {
const config = await getS3Config();
const client = await getS3Client();
if (!config || !client) {
return { success: false, error: 'S3 not configured' };
}
try {
const key = getS3Key(bucket, path);
await client.send(
new DeleteObjectCommand({
Bucket: config.bucket,
Key: key
})
);
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('S3 delete error:', error);
return { success: false, error: errorMessage };
}
}
/**
* Delete multiple files from S3
*/
async function deleteMultipleFromS3(
bucket: StorageBucket,
paths: string[]
): Promise<{ success: boolean; error?: string }> {
const config = await getS3Config();
const client = await getS3Client();
if (!config || !client) {
return { success: false, error: 'S3 not configured' };
}
try {
const objects = paths.map((p) => ({ Key: getS3Key(bucket, p) }));
await client.send(
new DeleteObjectsCommand({
Bucket: config.bucket,
Delete: { Objects: objects }
})
);
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('S3 delete multiple error:', error);
return { success: false, error: errorMessage };
}
}
/**
* List files from S3
*/
async function listFilesFromS3(
bucket: StorageBucket,
folder?: string,
options?: {
limit?: number;
}
): Promise<{ files: any[]; error: string | null }> {
const config = await getS3Config();
const client = await getS3Client();
if (!config || !client) {
return { files: [], error: 'S3 not configured' };
}
try {
const prefix = folder ? `${bucket}/${folder}/` : `${bucket}/`;
const response = await client.send(
new ListObjectsV2Command({
Bucket: config.bucket,
Prefix: prefix,
MaxKeys: options?.limit || 100
})
);
const files = (response.Contents || []).map((obj) => ({
name: obj.Key?.replace(prefix, '') || '',
size: obj.Size,
updated_at: obj.LastModified?.toISOString(),
created_at: obj.LastModified?.toISOString()
}));
return { files, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('S3 list error:', error);
return { files: [], error: errorMessage };
}
}
// ===========================================
// PUBLIC API - Uses S3 or Supabase based on settings
// ===========================================
/**
* Upload a file to storage (S3 or Supabase)
*/
export async function uploadFile(
bucket: StorageBucket,
path: string,
file: File | ArrayBuffer,
options?: {
contentType?: string;
cacheControl?: string;
upsert?: boolean;
}
): Promise<UploadResult> {
// Check if S3 is enabled
if (await isS3Enabled()) {
return uploadToS3(bucket, path, file, options);
}
// Fall back to Supabase Storage
try {
const { data, error } = await supabaseAdmin.storage.from(bucket).upload(path, file, {
contentType: options?.contentType,
cacheControl: options?.cacheControl || '3600',
upsert: options?.upsert || false
});
if (error) {
console.error('Storage upload error:', error);
return { success: false, error: error.message };
}
// Generate browser-accessible public URL (not the internal Docker URL)
const publicUrl = getBrowserAccessibleUrl(bucket, path);
return {
success: true,
path: data.path,
publicUrl
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Storage upload exception:', error);
return { success: false, error: errorMessage };
}
}
/**
* Get the public URL for a file in storage
*/
export async function getPublicUrl(bucket: StorageBucket, path: string): Promise<string> {
// Check if S3 is enabled
if (await isS3Enabled()) {
const config = await getS3Config();
if (config) {
const key = getS3Key(bucket, path);
if (config.forcePathStyle) {
return `${config.endpoint}/${config.bucket}/${key}`;
}
const protocol = config.useSSL ? 'https' : 'http';
return `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`;
}
}
// Fall back to Supabase Storage - use browser-accessible URL
return getBrowserAccessibleUrl(bucket, path);
}
/**
* Get a signed URL for private file access
*/
export async function getSignedUrl(
bucket: StorageBucket,
path: string,
expiresIn: number = 3600
): Promise<{ url: string | null; error: string | null }> {
// Check if S3 is enabled
if (await isS3Enabled()) {
return getS3PresignedUrl(bucket, path, expiresIn);
}
// Fall back to Supabase Storage
const { data, error } = await supabaseAdmin.storage
.from(bucket)
.createSignedUrl(path, expiresIn);
if (error) {
return { url: null, error: error.message };
}
return { url: data.signedUrl, error: null };
}
/**
* Delete a file from storage
*/
export async function deleteFile(
bucket: StorageBucket,
path: string
): Promise<{ success: boolean; error?: string }> {
// Check if S3 is enabled
if (await isS3Enabled()) {
return deleteFromS3(bucket, path);
}
// Fall back to Supabase Storage
const { error } = await supabaseAdmin.storage.from(bucket).remove([path]);
if (error) {
console.error('Storage delete error:', error);
return { success: false, error: error.message };
}
return { success: true };
}
/**
* Delete multiple files from storage
*/
export async function deleteFiles(
bucket: StorageBucket,
paths: string[]
): Promise<{ success: boolean; error?: string }> {
// Check if S3 is enabled
if (await isS3Enabled()) {
return deleteMultipleFromS3(bucket, paths);
}
// Fall back to Supabase Storage
const { error } = await supabaseAdmin.storage.from(bucket).remove(paths);
if (error) {
console.error('Storage delete error:', error);
return { success: false, error: error.message };
}
return { success: true };
}
/**
* List files in a bucket/folder
*/
export async function listFiles(
bucket: StorageBucket,
folder?: string,
options?: {
limit?: number;
offset?: number;
sortBy?: { column: string; order: 'asc' | 'desc' };
}
): Promise<{ files: any[]; error: string | null }> {
// Check if S3 is enabled
if (await isS3Enabled()) {
return listFilesFromS3(bucket, folder, options);
}
// Fall back to Supabase Storage
const { data, error } = await supabaseAdmin.storage.from(bucket).list(folder || '', {
limit: options?.limit || 100,
offset: options?.offset || 0,
sortBy: options?.sortBy || { column: 'created_at', order: 'desc' }
});
if (error) {
return { files: [], error: error.message };
}
return { files: data || [], error: null };
}
/**
* Generate a unique filename with timestamp
*/
export function generateUniqueFilename(originalName: string): string {
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 8);
const safeName = originalName.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50);
const ext = safeName.split('.').pop() || '';
const nameWithoutExt = safeName.replace(`.${ext}`, '');
return `${timestamp}-${randomStr}-${nameWithoutExt}.${ext}`;
}
/**
* Upload an avatar image for a member
* Returns both S3 and local URLs for storage flexibility
*/
export async function uploadAvatar(
memberId: string,
file: File,
userSupabase?: ReturnType<typeof import('@supabase/supabase-js').createClient>
): Promise<UploadResult> {
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP, GIF' };
}
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
return { success: false, error: 'Image size must be less than 5MB' };
}
// Generate path - memberId must match auth.uid() for RLS
const ext = file.name.split('.').pop() || 'jpg';
const path = `${memberId}/avatar.${ext}`;
// Convert to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// Check if S3 is enabled
const s3Enabled = await isS3Enabled();
// Result object
const result: UploadResult = {
success: false,
path
};
// Upload to S3 if enabled
if (s3Enabled) {
const s3Result = await uploadToS3('avatars', path, arrayBuffer, {
contentType: file.type
});
if (!s3Result.success) {
return s3Result;
}
result.s3Url = s3Result.publicUrl;
result.publicUrl = s3Result.publicUrl;
result.success = true;
}
// Always upload to Supabase Storage as well (for fallback)
try {
// First try to delete existing avatar (ignore errors)
await supabaseAdmin.storage.from('avatars').remove([path]);
const { data, error } = await supabaseAdmin.storage.from('avatars').upload(path, arrayBuffer, {
contentType: file.type,
cacheControl: '3600',
upsert: true
});
if (error) {
// If S3 succeeded, this is okay - just log
if (result.success) {
console.warn('Local storage upload failed (S3 succeeded):', error);
} else {
console.error('Avatar upload error:', error);
return { success: false, error: error.message };
}
} else {
// Generate browser-accessible public URL (not the internal Docker URL)
result.localUrl = getBrowserAccessibleUrl('avatars', path);
// If S3 is not enabled, use local URL as the public URL
if (!s3Enabled) {
result.publicUrl = result.localUrl;
result.success = true;
}
}
} catch (error) {
// If S3 succeeded, this is okay
if (!result.success) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Avatar upload exception:', error);
return { success: false, error: errorMessage };
}
}
return result;
}
/**
* Delete a member's avatar from ALL storage backends
* Always attempts to delete from both S3 and Supabase Storage
*/
export async function deleteAvatar(
memberId: string,
avatarPath?: string
): Promise<{ success: boolean; error?: string }> {
// If we have a specific path, use it; otherwise try common extensions
let paths: string[];
if (avatarPath) {
paths = [avatarPath];
} else {
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
paths = extensions.map((ext) => `${memberId}/avatar.${ext}`);
}
const errors: string[] = [];
// Always try to delete from S3 (in case it was uploaded when S3 was enabled)
try {
const s3Config = await getS3Config();
if (s3Config) {
const result = await deleteMultipleFromS3('avatars', paths);
if (!result.success && result.error) {
console.warn('S3 avatar delete warning:', result.error);
}
}
} catch (error) {
console.warn('S3 avatar delete error (non-critical):', error);
}
// Always try to delete from Supabase Storage
try {
await supabaseAdmin.storage.from('avatars').remove(paths);
} catch (error) {
console.warn('Local storage avatar delete error (non-critical):', error);
}
return { success: true };
}
/**
* Get the appropriate avatar URL based on current storage settings
* Useful for getting the right URL when storage setting is toggled
*/
export async function getActiveAvatarUrl(member: {
avatar_url_s3?: string | null;
avatar_url_local?: string | null;
avatar_url?: string | null;
}): Promise<string | null> {
// Check if S3 is enabled
if (await isS3Enabled()) {
return member.avatar_url_s3 || member.avatar_url || null;
}
return member.avatar_url_local || member.avatar_url || null;
}
/**
* Upload a document to storage
* Returns both S3 and local URLs for storage flexibility (same pattern as avatars)
*/
export async function uploadDocument(
file: File,
options?: {
folder?: string;
}
): Promise<UploadResult> {
// Validate file type
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv',
'application/json',
'image/jpeg',
'image/png',
'image/webp',
'image/gif'
];
if (!allowedTypes.includes(file.type)) {
return {
success: false,
error:
'File type not allowed. Supported: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, WebP, GIF'
};
}
// Validate file size (max 50MB)
const maxSize = 50 * 1024 * 1024;
if (file.size > maxSize) {
return { success: false, error: 'File size must be less than 50MB' };
}
// Generate unique storage path
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 8);
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50);
const path = options?.folder ? `${options.folder}/${timestamp}-${randomStr}-${safeName}` : `${timestamp}-${randomStr}-${safeName}`;
// Convert to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// Check if S3 is enabled
const s3Enabled = await isS3Enabled();
// Result object
const result: UploadResult = {
success: false,
path
};
// Upload to S3 if enabled
if (s3Enabled) {
const s3Result = await uploadToS3('documents', path, arrayBuffer, {
contentType: file.type
});
if (!s3Result.success) {
return s3Result;
}
result.s3Url = s3Result.publicUrl;
result.publicUrl = s3Result.publicUrl;
result.success = true;
}
// Always upload to Supabase Storage as well (for fallback)
try {
const { data, error } = await supabaseAdmin.storage.from('documents').upload(path, arrayBuffer, {
contentType: file.type,
cacheControl: '3600',
upsert: false
});
if (error) {
// If S3 succeeded, this is okay - just log
if (result.success) {
console.warn('Local storage upload failed (S3 succeeded):', error);
} else {
console.error('Document upload error:', error);
return { success: false, error: error.message };
}
} else {
// Generate browser-accessible public URL (not the internal Docker URL)
result.localUrl = getBrowserAccessibleUrl('documents', path);
// If S3 is not enabled, use local URL as the public URL
if (!s3Enabled) {
result.publicUrl = result.localUrl;
result.success = true;
}
}
} catch (error) {
// If S3 succeeded, this is okay
if (!result.success) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Document upload exception:', error);
return { success: false, error: errorMessage };
}
}
return result;
}
/**
* Delete a document from ALL storage backends
* Always attempts to delete from both S3 and Supabase Storage
*/
export async function deleteDocument(
storagePath: string
): Promise<{ success: boolean; error?: string }> {
const errors: string[] = [];
// Always try to delete from S3 (in case it was uploaded when S3 was enabled)
try {
const s3Config = await getS3Config();
if (s3Config) {
const result = await deleteFromS3('documents', storagePath);
if (!result.success && result.error) {
console.warn('S3 document delete warning:', result.error);
}
}
} catch (error) {
console.warn('S3 document delete error (non-critical):', error);
}
// Always try to delete from Supabase Storage
try {
await supabaseAdmin.storage.from('documents').remove([storagePath]);
} catch (error) {
console.warn('Local storage document delete error (non-critical):', error);
}
return { success: true };
}
/**
* Get the appropriate document URL based on current storage settings
* Useful for getting the right URL when storage setting is toggled
*/
export async function getActiveDocumentUrl(document: {
file_url_s3?: string | null;
file_url_local?: string | null;
file_path?: string | null;
}): Promise<string | null> {
// Check if S3 is enabled
if (await isS3Enabled()) {
return document.file_url_s3 || document.file_path || null;
}
return document.file_url_local || document.file_path || null;
}
/**
* Upload an event cover image
*/
export async function uploadEventImage(eventId: string, file: File): Promise<UploadResult> {
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP' };
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
return { success: false, error: 'Image size must be less than 10MB' };
}
// Generate path
const ext = file.name.split('.').pop() || 'jpg';
const path = `${eventId}/cover.${ext}`;
// Convert to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// Upload with upsert to replace existing cover
return uploadFile('event-images', path, arrayBuffer, {
contentType: file.type,
cacheControl: '3600',
upsert: true
});
}