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*`,
},
];
},