diff --git a/src/main/kotlin/com/crafto/crafto_backend/config/FileUploadConfiguration.kt b/src/main/kotlin/com/crafto/crafto_backend/config/FileUploadConfiguration.kt index dc71dfe..256951a 100644 --- a/src/main/kotlin/com/crafto/crafto_backend/config/FileUploadConfiguration.kt +++ b/src/main/kotlin/com/crafto/crafto_backend/config/FileUploadConfiguration.kt @@ -1,5 +1,7 @@ package com.crafto.crafto_backend.config +import com.crafto.crafto_backend.constant.AppConstants.FileUpload.MAX_FILE_SIZE_MB +import com.crafto.crafto_backend.constant.AppConstants.FileUpload.MAX_REQUEST_SIZE_MB import jakarta.servlet.MultipartConfigElement import org.springframework.boot.web.servlet.MultipartConfigFactory import org.springframework.context.annotation.Bean @@ -13,8 +15,8 @@ class FileUploadConfiguration { @Bean fun multipartConfigElement(): MultipartConfigElement { val factory = MultipartConfigFactory() - factory.setMaxFileSize(DataSize.ofMegabytes(4)) - factory.setMaxRequestSize(DataSize.ofMegabytes(16)) + factory.setMaxFileSize(DataSize.ofMegabytes(MAX_FILE_SIZE_MB.toLong())) + factory.setMaxRequestSize(DataSize.ofMegabytes(MAX_REQUEST_SIZE_MB.toLong())) return factory.createMultipartConfig() } } \ No newline at end of file diff --git a/src/main/kotlin/com/crafto/crafto_backend/config/FirebaseConfig.kt b/src/main/kotlin/com/crafto/crafto_backend/config/FirebaseConfig.kt index 68e8ce0..326c3aa 100644 --- a/src/main/kotlin/com/crafto/crafto_backend/config/FirebaseConfig.kt +++ b/src/main/kotlin/com/crafto/crafto_backend/config/FirebaseConfig.kt @@ -5,21 +5,25 @@ import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.cloud.StorageClient import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.io.ClassPathResource -import java.io.IOException -import java.io.InputStream +import javax.annotation.PostConstruct @Configuration +@EnableConfigurationProperties(FirebaseStorageProperties::class) class FirebaseConfig { @Value("\${firebase.bucket-name}") private lateinit var bucketName: String - @Bean - fun firebaseStorage(): StorageClient { + @Value("\${firebase.config.path}") + private lateinit var firebaseConfigPath: String + + @PostConstruct + fun initializeFirebase() { try { - val serviceAccount: InputStream = ClassPathResource("firebase-service-account.json").inputStream + val serviceAccount = ClassPathResource(firebaseConfigPath).inputStream val options = FirebaseOptions.builder() .setCredentials(GoogleCredentials.fromStream(serviceAccount)) @@ -28,11 +32,16 @@ class FirebaseConfig { if (FirebaseApp.getApps().isEmpty()) { FirebaseApp.initializeApp(options) + println("Firebase initialized successfully with bucket: $bucketName") } - - return StorageClient.getInstance() - } catch (e: IOException) { - throw RuntimeException("Failed to initialize Firebase Storage", e) + } catch (e: Exception) { + println("Error initializing Firebase: ${e.message}") + throw RuntimeException("Failed to initialize Firebase", e) } } + + @Bean + fun storageClient(): StorageClient { + return StorageClient.getInstance() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/crafto/crafto_backend/config/FirebaseStorageProperties.kt b/src/main/kotlin/com/crafto/crafto_backend/config/FirebaseStorageProperties.kt new file mode 100644 index 0000000..a2af623 --- /dev/null +++ b/src/main/kotlin/com/crafto/crafto_backend/config/FirebaseStorageProperties.kt @@ -0,0 +1,13 @@ +package com.crafto.crafto_backend.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "firebase.storage") +data class FirebaseStorageProperties( + val bucket: String, + val downloadUrl: String +) { + fun generateUrl(filePath: String): String { + return String.format(downloadUrl, bucket, filePath) + } +} diff --git a/src/main/kotlin/com/crafto/crafto_backend/constant/AppConstants.kt b/src/main/kotlin/com/crafto/crafto_backend/constant/AppConstants.kt index 804ea7e..51321e0 100644 --- a/src/main/kotlin/com/crafto/crafto_backend/constant/AppConstants.kt +++ b/src/main/kotlin/com/crafto/crafto_backend/constant/AppConstants.kt @@ -4,11 +4,16 @@ object AppConstants { // File upload limits object FileUpload { - const val MAX_FILE_SIZE_MB = 4 + const val MAX_FILE_SIZE_MB = 5 const val MAX_REQUEST_SIZE_MB = 16 const val MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 const val MAX_REQUEST_SIZE_BYTES = MAX_REQUEST_SIZE_MB * 1024 * 1024 - val ALLOWED_IMAGE_TYPES = listOf("image/jpeg", "image/png", "image/jpg") + val allowedMimeTypes = mapOf( + "image/jpeg" to "jpg", + "image/jpg" to "jpg", + "image/png" to "png", + "image/webp" to "webp", + ) val ALLOWED_IMAGE_EXTENSIONS = listOf("jpg", "jpeg", "png") } diff --git a/src/main/kotlin/com/crafto/crafto_backend/service/CraftsmanService.kt b/src/main/kotlin/com/crafto/crafto_backend/service/CraftsmanService.kt index 568244e..820d727 100644 --- a/src/main/kotlin/com/crafto/crafto_backend/service/CraftsmanService.kt +++ b/src/main/kotlin/com/crafto/crafto_backend/service/CraftsmanService.kt @@ -21,6 +21,7 @@ import com.crafto.crafto_backend.service.exception.UserIssueNotFound import com.crafto.crafto_backend.service.exception.UserNotFoundException import com.crafto.crafto_backend.utils.getFileExtension import com.crafto.crafto_backend.utils.validateImageFile +import com.crafto.crafto_backend.utils.validateImageFiles import org.apache.coyote.BadRequestException import org.bson.types.ObjectId import org.springframework.stereotype.Service @@ -108,15 +109,15 @@ class CraftsmanService( try { // Upload new files - newIdFrontUrl = firebaseStorageService.uploadFile( + newIdFrontUrl = firebaseStorageService.uploadImage( file = idCardFront, - folder = AppConstants.StoragePaths.craftsmanIdCards(craftsmanId), + folderName = AppConstants.StoragePaths.craftsmanIdCards(craftsmanId), fileName = "id-front-${System.currentTimeMillis()}.${getFileExtension(idCardFront)}" ) - newIdBackUrl = firebaseStorageService.uploadFile( + newIdBackUrl = firebaseStorageService.uploadImage( file = idCardBack, - folder = AppConstants.StoragePaths.craftsmanIdCards(craftsmanId), + folderName = AppConstants.StoragePaths.craftsmanIdCards(craftsmanId), fileName = "id-back-${System.currentTimeMillis()}.${getFileExtension(idCardBack)}" ) @@ -165,9 +166,9 @@ class CraftsmanService( // Upload new work images val workUrls = workImages.mapIndexed { index, file -> - firebaseStorageService.uploadFile( + firebaseStorageService.uploadImage( file = file, - folder = AppConstants.StoragePaths.craftsmanWorkPortfolio(craftsmanId), + folderName = AppConstants.StoragePaths.craftsmanWorkPortfolio(craftsmanId), fileName = "work-${System.currentTimeMillis()}-$index.${getFileExtension(file)}" ) } @@ -205,9 +206,9 @@ class CraftsmanService( } // Upload new profile picture - val profilePictureUrl = firebaseStorageService.uploadFile( + val profilePictureUrl = firebaseStorageService.uploadImage( file = profilePicture, - folder = AppConstants.StoragePaths.craftsmanProfilePicture(craftsmanId), + folderName = AppConstants.StoragePaths.craftsmanProfilePicture(craftsmanId), fileName = "profile-${System.currentTimeMillis()}.${getFileExtension(profilePicture)}" ) @@ -278,30 +279,6 @@ class CraftsmanService( return craftsman } - private fun validateImageFiles( - files: List, - fileType: String = "Image", - maxSizeBytes: Int = AppConstants.FileUpload.MAX_FILE_SIZE_BYTES - ) { - files.forEach { file -> - if (file.isEmpty) { - throw BadRequestException("Empty file uploaded") ////// - } - - if (!AppConstants.FileUpload.ALLOWED_IMAGE_TYPES.contains(file.contentType)) { - throw BadRequestException( - "$fileType must be ${AppConstants.FileUpload.ALLOWED_IMAGE_EXTENSIONS} format" - ) - } - - if (file.size > maxSizeBytes) { - throw BadRequestException( - "$fileType ${file.originalFilename} exceeds ${AppConstants.FileUpload.MAX_FILE_SIZE_MB} MB limit" - ) - } - } - } - private fun validateWorkImages(files: List) { validateImageFiles(files, fileType = "Work image") } diff --git a/src/main/kotlin/com/crafto/crafto_backend/service/CustomerService.kt b/src/main/kotlin/com/crafto/crafto_backend/service/CustomerService.kt index 1df5b18..e2517c7 100644 --- a/src/main/kotlin/com/crafto/crafto_backend/service/CustomerService.kt +++ b/src/main/kotlin/com/crafto/crafto_backend/service/CustomerService.kt @@ -31,7 +31,6 @@ class CustomerService( private val customerRepository: CustomerRepository, private val customerIssueRepository: CustomerIssueRepository, private val categoryRepository: CategoryRepository, - private val imageStorageService: ImageStorageService, private val offerRepository: CraftsmanOfferRepository, private val firebaseStorageService: FirebaseStorageService ) { @@ -104,7 +103,7 @@ class CustomerService( fun uploadProductImages(customer: Customer, files: List): List { val imageUrls = mutableListOf() files.forEach { file -> - val imageUrl = imageStorageService.uploadImage( + val imageUrl = firebaseStorageService.uploadImage( file = file, fileName = "${customer.id}-${file.originalFilename}", folderName = IMAGE_FOLDER_NAME @@ -180,9 +179,9 @@ class CustomerService( } // Upload new photo - val profilePictureUrl = firebaseStorageService.uploadFile( + val profilePictureUrl = firebaseStorageService.uploadImage( file = profilePicture, - folder = AppConstants.StoragePaths.customerProfilePicture(customerId), + folderName = AppConstants.StoragePaths.customerProfilePicture(customerId), fileName = "profile-${System.currentTimeMillis()}.${getFileExtension(profilePicture)}" ) diff --git a/src/main/kotlin/com/crafto/crafto_backend/service/FirebaseStorageService.kt b/src/main/kotlin/com/crafto/crafto_backend/service/FirebaseStorageService.kt index b26d229..220f4c8 100644 --- a/src/main/kotlin/com/crafto/crafto_backend/service/FirebaseStorageService.kt +++ b/src/main/kotlin/com/crafto/crafto_backend/service/FirebaseStorageService.kt @@ -1,68 +1,87 @@ package com.crafto.crafto_backend.service +import com.crafto.crafto_backend.config.FirebaseStorageProperties +import com.crafto.crafto_backend.constant.AppConstants.FileUpload.allowedMimeTypes +import com.crafto.crafto_backend.service.exception.InvalidImageFormatException import com.google.cloud.storage.Acl import com.google.cloud.storage.BlobId import com.google.cloud.storage.BlobInfo -import com.google.cloud.storage.Bucket import com.google.firebase.cloud.StorageClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.net.URI import java.net.URLDecoder import java.util.UUID -import kotlin.collections.forEach -import kotlin.text.contains -import kotlin.text.substringAfter @Service -@EnableConfigurationProperties(ImageStorageProperties::class) class FirebaseStorageService( private val storageClient: StorageClient, - private val props: ImageStorageProperties + private val storageProperties: FirebaseStorageProperties ) { - private val deleteScope = CoroutineScope(Dispatchers.IO) fun uploadFile( file: MultipartFile, - folder: String, + folderName: String, fileName: String? = null ): String { try { - // Generate unique filename if not provided - val actualFileName = fileName ?: "${UUID.randomUUID()}_${file.originalFilename}" - val fullPath = "$folder/$actualFileName" + val actualFileName = fileName ?: generateUniqueFileName(file) + val fullPath = "$folderName/$actualFileName" - // Create blob (file) in Firebase Storage - val blobId = BlobId.of(props.bucket, fullPath) + val blobId = BlobId.of(storageProperties.bucket, fullPath) val blobInfo = BlobInfo.newBuilder(blobId) .setContentType(file.contentType ?: "application/octet-stream") .build() - // Upload the file - val blob = storageClient.bucket().storage.create(blobInfo, file.bytes) - - // Make the file publicly readable + val firebaseBucket = storageClient.bucket() + val blob = firebaseBucket.storage.create(blobInfo, file.bytes) blob.createAcl(Acl.of(Acl.User.ofAllUsers(), Acl.Role.READER)) - // Now this URL will work - return "https://storage.googleapis.com/${props.bucket}/$fullPath" + return storageProperties.generateUrl(fullPath) } catch (e: Exception) { println("Error uploading file: ${e.message}") - throw kotlin.RuntimeException("Failed to upload file to Firebase Storage", e) + throw RuntimeException("Failed to upload file to Firebase Storage", e) } } + fun uploadImage( + file: MultipartFile, + folderName: String, + fileName: String? =null, + ): String { + if (file.isEmpty) { + throw IllegalArgumentException("File is empty") + } + + val mimeType = + file.contentType ?: throw IllegalArgumentException("Content type cannot be null") + + val extension = allowedMimeTypes[mimeType] + ?: throw InvalidImageFormatException() + + //Generate filename with correct extension from MIME type + val actualFileName = if (fileName != null) { + val nameWithoutExt = fileName.substringBeforeLast(".", fileName) + "$nameWithoutExt.$extension" + } else { + val timestamp = System.currentTimeMillis() + val uuid = UUID.randomUUID().toString().take(8) + "${timestamp}_${uuid}.$extension" + } + return uploadFile(file, folderName, actualFileName) + } + fun deleteFile(filePath: String): Boolean { return try { - val blobId = BlobId.of(props.bucket, filePath) - val deleted = storageClient.bucket().storage.delete(blobId) - println("File deleted successfully: $filePath - Result: $deleted") + val blobId = BlobId.of(storageProperties.bucket, filePath) + val firebaseBucket = storageClient.bucket() + val deleted = firebaseBucket.storage.delete(blobId) + println("Delete file: $filePath - Result: $deleted") deleted } catch (e: Exception) { println("Error deleting file: ${e.message}") @@ -107,53 +126,63 @@ class FirebaseStorageService( } fun deleteMultipleFilesByUrlsAsync(fileUrls: List) { + if (fileUrls.isEmpty()) return + deleteScope.launch { + var deletedCount = 0 fileUrls.forEach { url -> try { - deleteFileByUrl(url) + if (deleteFileByUrl(url)) { + deletedCount++ + } } catch (e: Exception) { println("Failed to delete: $url") } } + println("Async deletion completed: $deletedCount/${fileUrls.size}") + } + } + + private fun generateUniqueFileName(file: MultipartFile): String { + val originalName = file.originalFilename ?: "file" + val extension = originalName.substringAfterLast('.', "") + val timestamp = System.currentTimeMillis() + val uuid = UUID.randomUUID().toString().take(8) + + return if (extension.isNotEmpty()) { + "${timestamp}_${uuid}.$extension" + } else { + "${timestamp}_${uuid}" } } private fun extractFilePathFromUrl(fileUrl: String): String? { return try { - // Firebase Storage URLs have different formats: - // 1. Signed URL: https://storage.googleapis.com/bucket-name/path/to/file?GoogleAccessId=... - // 2. Public URL: https://storage.googleapis.com/bucket-name/path/to/file - // 3. Firebase URL: https://firebasestorage.googleapis.com/v0/b/bucket-name/o/path%2Fto%2Ffile?... - when { - // Handle standard Google Storage URLs + // Format: https://storage.googleapis.com/bucket-name/path/to/file fileUrl.contains("storage.googleapis.com") -> { val uri = URI(fileUrl) val path = uri.path ?: return null - // Ensure bucket name is in the path - if (!path.contains(props.bucket)) { + if (!path.contains(storageProperties.bucket)) { println("URL doesn't match expected bucket: $fileUrl") return null } - // Remove bucket name from path - path.substringAfter("/${props.bucket}/") + path.substringAfter("/${storageProperties.bucket}/") } - // Handle Firebase Storage URLs + // Format: https://firebasestorage.googleapis.com/v0/b/bucket/o/path%2Fto%2Ffile fileUrl.contains("firebasestorage.googleapis.com") -> { val uri = URI(fileUrl) val path = uri.path ?: return null - // Extract the encoded file path after /o/ if (!path.contains("/o/")) { println("Invalid Firebase Storage URL format: $fileUrl") return null } - // Extract the encoded file path after /o/ + val encodedPath = path.substringAfter("/o/") - // Decode the URL encoding URLDecoder.decode(encodedPath, "UTF-8") } diff --git a/src/main/kotlin/com/crafto/crafto_backend/service/ImageStorageService.kt b/src/main/kotlin/com/crafto/crafto_backend/service/ImageStorageService.kt deleted file mode 100644 index 0399216..0000000 --- a/src/main/kotlin/com/crafto/crafto_backend/service/ImageStorageService.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.crafto.crafto_backend.service - -import com.crafto.crafto_backend.service.exception.InvalidImageFormatException -import com.crafto.crafto_backend.service.exception.UploadImageException -import com.google.cloud.storage.BlobId -import com.google.cloud.storage.BlobInfo -import com.google.cloud.storage.Bucket -import com.google.cloud.storage.Storage -import com.google.firebase.cloud.StorageClient -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.stereotype.Service -import org.springframework.web.multipart.MultipartFile -import java.time.LocalDateTime -import java.util.UUID - -@ConfigurationProperties(prefix = "storage.firebase") -data class ImageStorageProperties( - val bucket: String, - val downloadUrl: String -) - -@Service -@EnableConfigurationProperties(ImageStorageProperties::class) -class ImageStorageService( - private val storageClient: StorageClient, - private val props: ImageStorageProperties -) { - fun uploadImage( - file: MultipartFile, - fileName: String, - folderName: String, - ): String { - val mimeType = - file.contentType ?: throw IllegalArgumentException("Content type cannot be null") - - if (!allowedMimeTypes.containsKey(mimeType)) { - throw InvalidImageFormatException() - } - try { - val extension = allowedMimeTypes[mimeType]!! - val uniqueFileName = - "${fileName}_${LocalDateTime.now()}_${UUID.randomUUID()}.$extension" - - val storagePath = "$folderName/$uniqueFileName" - - val blobInfo = BlobInfo.newBuilder(BlobId.of(props.bucket, storagePath)) - .setContentType(mimeType) - .build() - - val storage = storageClient.bucket() - val blob = storage.create( - storagePath, - file.bytes, - Bucket.BlobTargetOption.predefinedAcl(Storage.PredefinedAcl.PUBLIC_READ) - ) - - return String.format(props.downloadUrl, props.bucket, storagePath) - - } catch (ex: Exception) { - throw UploadImageException() - } - } - - companion object { - val allowedMimeTypes = mapOf( - "image/jpeg" to "jpg", - "image/jpg" to "jpg", - "image/png" to "png", - "image/webp" to "webp", - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/crafto/crafto_backend/utils/Helper.kt b/src/main/kotlin/com/crafto/crafto_backend/utils/Helper.kt index 14bac6b..8645df0 100644 --- a/src/main/kotlin/com/crafto/crafto_backend/utils/Helper.kt +++ b/src/main/kotlin/com/crafto/crafto_backend/utils/Helper.kt @@ -25,20 +25,45 @@ fun getFileExtension(file: MultipartFile): String { } } -fun validateImageFile(file: MultipartFile, fileType: String) { +fun validateImageFile(file: MultipartFile, fileType: String, + maxSizeBytes: Int = AppConstants.FileUpload.MAX_FILE_SIZE_BYTES) { if (file.isEmpty) { throw BadRequestException("Empty file uploaded") } - if (!AppConstants.FileUpload.ALLOWED_IMAGE_TYPES.contains(file.contentType)) { + if (!AppConstants.FileUpload.allowedMimeTypes.contains(file.contentType)) { throw BadRequestException( "$fileType must be ${AppConstants.FileUpload.ALLOWED_IMAGE_EXTENSIONS.joinToString()} format" ) } - if (file.size > AppConstants.FileUpload.MAX_FILE_SIZE_BYTES) { + if (file.size > maxSizeBytes) { throw BadRequestException( - "$fileType exceeds ${AppConstants.FileUpload.MAX_FILE_SIZE_MB} MB limit" + "$fileType exceeds ${maxSizeBytes / 1024 / 1024} MB limit" ) } +} + +fun validateImageFiles( + files: List, + fileType: String = "Image", + maxSizeBytes: Int = AppConstants.FileUpload.MAX_FILE_SIZE_BYTES +) { + files.forEach { file -> + if (file.isEmpty) { + throw BadRequestException("Empty file uploaded") + } + + if (!AppConstants.FileUpload.allowedMimeTypes.contains(file.contentType)) { + throw BadRequestException( + "$fileType must be ${AppConstants.FileUpload.ALLOWED_IMAGE_EXTENSIONS} format" + ) + } + + if (file.size > maxSizeBytes) { + throw BadRequestException( + "$fileType ${file.originalFilename} exceeds ${AppConstants.FileUpload.MAX_FILE_SIZE_MB} MB limit" + ) + } + } } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cb11266..ebd5768 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,14 +5,12 @@ spring.data.mongodb.database=crafto_db # Firebase Configuration firebase.bucket-name=${BUCKET_NAME} -firebase.storage.bucket=${BUCKET_NAME} firebase.config.path=firebase-service-account.json -# Storage Configuration -storage.firebase.bucket=${BUCKET_NAME} - -storage.firebase.download-url=https://storage.googleapis.com/%s/%s?alt=media +# Firebase Storage Configuration +firebase.storage.bucket=${BUCKET_NAME} +firebase.storage.download-url=https://storage.googleapis.com/%s/%s # File upload size limits -spring.servlet.multipart.max-file-size=5MB -spring.servlet.multipart.max-request-size=5MB \ No newline at end of file +# spring.servlet.multipart.max-file-size=5MB +# spring.servlet.multipart.max-request-size=16MB \ No newline at end of file