diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..81661838 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,69 @@ +name: Build and Deploy + +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + BACKEND_IMAGE: ghcr.io/${{ github.repository_owner }}/petshop-backend + FRONTEND_IMAGE: ghcr.io/${{ github.repository_owner }}/petshop-web + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push backend image + uses: docker/build-push-action@v5 + with: + context: ./backend + push: true + tags: ${{ env.BACKEND_IMAGE }}:latest + + - name: Build and push frontend image + uses: docker/build-push-action@v5 + with: + context: ./web + push: true + tags: ${{ env.FRONTEND_IMAGE }}:latest + build-args: | + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} + + - name: Log in to Azure + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Deploy backend + run: | + az containerapp update \ + --name ${{ secrets.AZURE_BACKEND_APP_NAME }} \ + --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \ + --image ${{ env.BACKEND_IMAGE }}:latest \ + --registry-server ${{ env.REGISTRY }} \ + --registry-username ${{ github.actor }} \ + --registry-password ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy frontend + run: | + az containerapp update \ + --name ${{ secrets.AZURE_FRONTEND_APP_NAME }} \ + --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \ + --image ${{ env.FRONTEND_IMAGE }}:latest \ + --registry-server ${{ env.REGISTRY }} \ + --registry-username ${{ github.actor }} \ + --registry-password ${{ secrets.GITHUB_TOKEN }} diff --git a/backend/Dockerfile b/backend/Dockerfile index c3479c8e..ed3552be 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,6 +9,5 @@ RUN mvn -q -DskipTests package FROM eclipse-temurin:25-jre WORKDIR /app COPY --from=build /app/target/*.jar app.jar -COPY uploads ./uploads EXPOSE 8080 ENTRYPOINT ["java","-jar","app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml index 67b9a807..a87811c2 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -102,6 +102,12 @@ 3.1.0 + + com.azure + azure-storage-blob + 12.29.0 + + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java b/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java index 5c3b4e08..59442d4f 100644 --- a/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java +++ b/backend/src/main/java/com/petshop/backend/service/AvatarStorageService.java @@ -1,7 +1,9 @@ package com.petshop.backend.service; import com.petshop.backend.entity.User; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.PathResource; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; @@ -22,10 +24,14 @@ import java.util.UUID; public class AvatarStorageService { private static final String STORED_PREFIX = "/uploads/avatars/"; + private static final String BLOB_CONTAINER = "avatars"; @Value("${app.upload.base-dir:uploads}") private String uploadBaseDir; + @Autowired + private AzureBlobService blobService; + private Path avatarDirectory; @PostConstruct @@ -34,18 +40,22 @@ public class AvatarStorageService { } public String storeAvatar(MultipartFile file) throws IOException { - Files.createDirectories(avatarDirectory); - - String originalFilename = file.getOriginalFilename(); - String extension = resolveExtension(originalFilename); + String extension = resolveExtension(file.getOriginalFilename()); String filename = UUID.randomUUID() + extension; - Path filePath = avatarDirectory.resolve(filename).normalize(); - - Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + if (blobService.isEnabled()) { + blobService.upload(BLOB_CONTAINER, filename, file.getBytes()); + } else { + Files.createDirectories(avatarDirectory); + Files.copy(file.getInputStream(), avatarDirectory.resolve(filename).normalize(), StandardCopyOption.REPLACE_EXISTING); + } return STORED_PREFIX + filename; } public Resource loadAvatarResource(User user) { + String filename = extractFilename(user.getAvatarUrl()); + if (blobService.isEnabled()) { + return new ByteArrayResource(blobService.download(BLOB_CONTAINER, filename)); + } Path filePath = resolveStoredAvatarPath(user.getAvatarUrl()); if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { throw new IllegalArgumentException("Avatar file was not found"); @@ -54,15 +64,21 @@ public class AvatarStorageService { } public void deleteAvatar(User user) throws IOException { - if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) { - return; - } - try { - Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl())); - } catch (IllegalArgumentException ignored) { + if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) return; + if (blobService.isEnabled()) { + blobService.delete(BLOB_CONTAINER, extractFilename(user.getAvatarUrl())); + } else { + try { + Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl())); + } catch (IllegalArgumentException ignored) {} } } + private String extractFilename(String avatarUrl) { + if (avatarUrl == null || !avatarUrl.startsWith(STORED_PREFIX)) throw new IllegalArgumentException("Avatar file was not found"); + return avatarUrl.substring(STORED_PREFIX.length()); + } + public String toOwnerAvatarUrl(User user) { return hasAvatar(user) ? "/api/v1/users/" + user.getId() + "/avatar/file" : null; } diff --git a/backend/src/main/java/com/petshop/backend/service/AzureBlobService.java b/backend/src/main/java/com/petshop/backend/service/AzureBlobService.java new file mode 100644 index 00000000..cbdfc62d --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/AzureBlobService.java @@ -0,0 +1,44 @@ +package com.petshop.backend.service; + +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.core.util.BinaryData; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class AzureBlobService { + + private final BlobServiceClient client; + private final String containerPrefix; + + public AzureBlobService( + @Value("${azure.storage.connection-string:}") String connectionString, + @Value("${azure.storage.container-prefix:petshop}") String containerPrefix) { + this.containerPrefix = containerPrefix; + this.client = (connectionString != null && !connectionString.isBlank()) + ? new BlobServiceClientBuilder().connectionString(connectionString).buildClient() + : null; + } + + public boolean isEnabled() { + return client != null; + } + + public void upload(String container, String blobName, byte[] bytes) { + getContainerClient(container).getBlobClient(blobName).upload(BinaryData.fromBytes(bytes), true); + } + + public byte[] download(String container, String blobName) { + return getContainerClient(container).getBlobClient(blobName).downloadContent().toBytes(); + } + + public void delete(String container, String blobName) { + getContainerClient(container).getBlobClient(blobName).deleteIfExists(); + } + + private BlobContainerClient getContainerClient(String container) { + return client.createBlobContainerIfNotExists(containerPrefix + "-" + container); + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java b/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java index 1068e094..48565753 100644 --- a/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java +++ b/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java @@ -1,6 +1,8 @@ package com.petshop.backend.service; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.PathResource; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; @@ -21,31 +23,58 @@ public class CatalogImageStorageService { private static final String PET_PREFIX = "/uploads/pets/"; private static final String PRODUCT_PREFIX = "/uploads/products/"; + private static final String BLOB_PETS = "pets"; + private static final String BLOB_PRODUCTS = "products"; @Value("${app.upload.base-dir:uploads}") private String uploadBaseDir; + @Autowired + private AzureBlobService blobService; + public String storePetImage(MultipartFile file) throws IOException { - return storeImage(file, Paths.get(uploadBaseDir, "pets").toAbsolutePath().normalize(), PET_PREFIX); + String extension = resolveExtension(file.getOriginalFilename()); + String filename = UUID.randomUUID() + extension; + if (blobService.isEnabled()) { + blobService.upload(BLOB_PETS, filename, file.getBytes()); + } else { + Path directory = Paths.get(uploadBaseDir, "pets").toAbsolutePath().normalize(); + Files.createDirectories(directory); + Files.copy(file.getInputStream(), directory.resolve(filename).normalize(), StandardCopyOption.REPLACE_EXISTING); + } + return PET_PREFIX + filename; } public String storeProductImage(MultipartFile file) throws IOException { - return storeImage(file, Paths.get(uploadBaseDir, "products").toAbsolutePath().normalize(), PRODUCT_PREFIX); + String extension = resolveExtension(file.getOriginalFilename()); + String filename = UUID.randomUUID() + extension; + if (blobService.isEnabled()) { + blobService.upload(BLOB_PRODUCTS, filename, file.getBytes()); + } else { + Path directory = Paths.get(uploadBaseDir, "products").toAbsolutePath().normalize(); + Files.createDirectories(directory); + Files.copy(file.getInputStream(), directory.resolve(filename).normalize(), StandardCopyOption.REPLACE_EXISTING); + } + return PRODUCT_PREFIX + filename; } public Resource loadPetImage(String storedPath) { - Resource resource = new PathResource(resolveStoredPath(storedPath, Paths.get(uploadBaseDir, "pets").toAbsolutePath().normalize(), PET_PREFIX)); - if (!resource.exists()) { - throw new IllegalArgumentException("Image file was not found"); + String filename = extractFilename(storedPath, PET_PREFIX); + if (blobService.isEnabled()) { + return new ByteArrayResource(blobService.download(BLOB_PETS, filename)); } + Resource resource = new PathResource(resolveStoredPath(storedPath, Paths.get(uploadBaseDir, "pets").toAbsolutePath().normalize(), PET_PREFIX)); + if (!resource.exists()) throw new IllegalArgumentException("Image file was not found"); return resource; } public Resource loadProductImage(String storedPath) { - Resource resource = new PathResource(resolveStoredPath(storedPath, Paths.get(uploadBaseDir, "products").toAbsolutePath().normalize(), PRODUCT_PREFIX)); - if (!resource.exists()) { - throw new IllegalArgumentException("Image file was not found"); + String filename = extractFilename(storedPath, PRODUCT_PREFIX); + if (blobService.isEnabled()) { + return new ByteArrayResource(blobService.download(BLOB_PRODUCTS, filename)); } + Resource resource = new PathResource(resolveStoredPath(storedPath, Paths.get(uploadBaseDir, "products").toAbsolutePath().normalize(), PRODUCT_PREFIX)); + if (!resource.exists()) throw new IllegalArgumentException("Image file was not found"); return resource; } @@ -53,28 +82,35 @@ public class CatalogImageStorageService { return MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); } + public MediaType resolveMediaTypeByName(String filename) { + return MediaTypeFactory.getMediaType(filename).orElse(MediaType.APPLICATION_OCTET_STREAM); + } + public void deletePetImage(String storedPath) throws IOException { - deleteImage(storedPath, Paths.get(uploadBaseDir, "pets").toAbsolutePath().normalize(), PET_PREFIX); + if (storedPath == null || storedPath.isBlank()) return; + String filename = extractFilename(storedPath, PET_PREFIX); + if (blobService.isEnabled()) { + blobService.delete(BLOB_PETS, filename); + } else { + Files.deleteIfExists(resolveStoredPath(storedPath, Paths.get(uploadBaseDir, "pets").toAbsolutePath().normalize(), PET_PREFIX)); + } } public void deleteProductImage(String storedPath) throws IOException { - deleteImage(storedPath, Paths.get(uploadBaseDir, "products").toAbsolutePath().normalize(), PRODUCT_PREFIX); - } - - private String storeImage(MultipartFile file, Path directory, String prefix) throws IOException { - Files.createDirectories(directory); - String extension = resolveExtension(file.getOriginalFilename()); - String filename = UUID.randomUUID() + extension; - Path filePath = directory.resolve(filename).normalize(); - Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); - return prefix + filename; - } - - private void deleteImage(String storedPath, Path directory, String prefix) throws IOException { - if (storedPath == null || storedPath.isBlank()) { - return; + if (storedPath == null || storedPath.isBlank()) return; + String filename = extractFilename(storedPath, PRODUCT_PREFIX); + if (blobService.isEnabled()) { + blobService.delete(BLOB_PRODUCTS, filename); + } else { + Files.deleteIfExists(resolveStoredPath(storedPath, Paths.get(uploadBaseDir, "products").toAbsolutePath().normalize(), PRODUCT_PREFIX)); } - Files.deleteIfExists(resolveStoredPath(storedPath, directory, prefix)); + } + + private String extractFilename(String storedPath, String prefix) { + if (storedPath == null || !storedPath.startsWith(prefix)) throw new IllegalArgumentException("Image file was not found"); + String filename = storedPath.substring(prefix.length()); + if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) throw new IllegalArgumentException("Image file was not found"); + return filename; } private Path resolveStoredPath(String storedPath, Path directory, String prefix) { diff --git a/backend/src/main/java/com/petshop/backend/service/ChatAttachmentStorageService.java b/backend/src/main/java/com/petshop/backend/service/ChatAttachmentStorageService.java index 720c8079..55932b92 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatAttachmentStorageService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatAttachmentStorageService.java @@ -1,5 +1,8 @@ package com.petshop.backend.service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.PathResource; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; @@ -18,26 +21,37 @@ import java.util.UUID; public class ChatAttachmentStorageService { private static final String STORED_PREFIX = "/uploads/chat/"; - private final Path chatDirectory = Paths.get("uploads", "chat").toAbsolutePath().normalize(); + private static final String BLOB_CONTAINER = "chat"; + + @Value("${app.upload.base-dir:uploads}") + private String uploadBaseDir; + + @Autowired + private AzureBlobService blobService; public String storeAttachment(MultipartFile file) throws IOException { - Files.createDirectories(chatDirectory); - String originalFilename = file.getOriginalFilename(); - String extension = ""; - if (originalFilename != null && originalFilename.contains(".")) { - extension = originalFilename.substring(originalFilename.lastIndexOf(".")); - } - + String extension = (originalFilename != null && originalFilename.contains(".")) + ? originalFilename.substring(originalFilename.lastIndexOf(".")) : ""; String filename = UUID.randomUUID() + extension; - Path filePath = chatDirectory.resolve(filename).normalize(); - Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + if (blobService.isEnabled()) { + blobService.upload(BLOB_CONTAINER, filename, file.getBytes()); + } else { + Path chatDirectory = Paths.get(uploadBaseDir, "chat").toAbsolutePath().normalize(); + Files.createDirectories(chatDirectory); + Files.copy(file.getInputStream(), chatDirectory.resolve(filename).normalize(), StandardCopyOption.REPLACE_EXISTING); + } return STORED_PREFIX + filename; } public Resource loadAttachmentResource(String attachmentUrl) { - Path filePath = resolveStoredPath(attachmentUrl); + String filename = extractFilename(attachmentUrl); + if (blobService.isEnabled()) { + return new ByteArrayResource(blobService.download(BLOB_CONTAINER, filename)); + } + Path chatDirectory = Paths.get(uploadBaseDir, "chat").toAbsolutePath().normalize(); + Path filePath = chatDirectory.resolve(filename).normalize(); if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { throw new IllegalArgumentException("Attachment file was not found"); } @@ -45,27 +59,18 @@ public class ChatAttachmentStorageService { } public MediaType resolveMediaType(String attachmentUrl) { - try { - return MediaTypeFactory.getMediaType(loadAttachmentResource(attachmentUrl)).orElse(MediaType.APPLICATION_OCTET_STREAM); - } catch (IllegalArgumentException ex) { - return MediaType.APPLICATION_OCTET_STREAM; - } + String filename = extractFilename(attachmentUrl); + return MediaTypeFactory.getMediaType(filename).orElse(MediaType.APPLICATION_OCTET_STREAM); } - private Path resolveStoredPath(String attachmentUrl) { + private String extractFilename(String attachmentUrl) { if (attachmentUrl == null || attachmentUrl.isBlank() || !attachmentUrl.startsWith(STORED_PREFIX)) { throw new IllegalArgumentException("Invalid attachment URL"); } - String filename = attachmentUrl.substring(STORED_PREFIX.length()); if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) { throw new IllegalArgumentException("Invalid attachment filename"); } - - Path resolved = chatDirectory.resolve(filename).normalize(); - if (!resolved.startsWith(chatDirectory)) { - throw new IllegalArgumentException("Invalid attachment path"); - } - return resolved; + return filename; } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index e006d6c1..ea174dff 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -33,7 +33,7 @@ spring: open-in-view: false flyway: - enabled: false + enabled: ${FLYWAY_ENABLED:false} server: port: ${SERVER_PORT:8080} @@ -52,6 +52,11 @@ app: base-dir: ${UPLOAD_BASE_DIR:uploads} frontend-url: ${FRONTEND_URL:http://localhost:3000} +azure: + storage: + connection-string: ${AZURE_STORAGE_CONNECTION_STRING:} + container-prefix: ${AZURE_STORAGE_CONTAINER_PREFIX:petshop} + resend: api-key: ${RESEND_API_KEY:} from: ${RESEND_FROM:PetShop } diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 00000000..e850494d --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/next.config.mjs b/web/next.config.mjs index b725bf69..77d89061 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -1,11 +1,12 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', reactCompiler: true, async rewrites() { return [ { source: "/api/:path*", - destination: "http://localhost:8080/api/:path*", + destination: `${process.env.BACKEND_URL || "http://localhost:8080"}/api/:path*`, }, ]; },