From f5aa0f298eedded410d663a7887e3358d8dd7782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 21:50:42 +0100 Subject: [PATCH] feat(back-end): integration of clamAVS --- backend/build.gradle | 1 + .../controller/OrderController.java | 33 ++-- .../controller/QuoteSessionController.java | 38 ++++- .../exception/StorageException.java | 12 ++ .../service/ClamAVService.java | 50 ++++++ .../service/FileSystemStorageService.java | 98 +++++++++++ .../service/StorageService.java | 14 ++ .../controller/OrderIntegrationTest.java | 157 ++++++++++++++++++ docker-compose.yml | 11 ++ 9 files changed, 393 insertions(+), 21 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/exception/StorageException.java create mode 100644 backend/src/main/java/com/printcalculator/service/ClamAVService.java create mode 100644 backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java create mode 100644 backend/src/main/java/com/printcalculator/service/StorageService.java create mode 100644 backend/src/test/java/com/printcalculator/controller/OrderIntegrationTest.java diff --git a/backend/build.gradle b/backend/build.gradle index 70e9613..4a63544 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -25,6 +25,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'xyz.capybara:clamav-client:2.1.2' runtimeOnly 'org.postgresql:postgresql' developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index c546d1a..2290eb6 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -28,20 +28,23 @@ public class OrderController { private final QuoteSessionRepository quoteSessionRepo; private final QuoteLineItemRepository quoteLineItemRepo; private final CustomerRepository customerRepo; + private final com.printcalculator.service.StorageService storageService; // TODO: Inject Storage Service or use a base path property - private static final String STORAGE_ROOT = "storage_orders"; + // private static final String STORAGE_ROOT = "storage_orders"; public OrderController(OrderRepository orderRepo, OrderItemRepository orderItemRepo, QuoteSessionRepository quoteSessionRepo, QuoteLineItemRepository quoteLineItemRepo, - CustomerRepository customerRepo) { + CustomerRepository customerRepo, + com.printcalculator.service.StorageService storageService) { this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; this.quoteSessionRepo = quoteSessionRepo; this.quoteLineItemRepo = quoteLineItemRepo; this.customerRepo = customerRepo; + this.storageService = storageService; } @@ -69,7 +72,9 @@ public class OrderController { .orElseGet(() -> { Customer newC = new Customer(); newC.setEmail(request.getCustomer().getEmail()); + newC.setCustomerType(request.getCustomer().getCustomerType()); newC.setCreatedAt(OffsetDateTime.now()); + newC.setUpdatedAt(OffsetDateTime.now()); return customerRepo.save(newC); }); // Update customer details? @@ -135,6 +140,13 @@ public class OrderController { BigDecimal subtotal = BigDecimal.ZERO; + // Initialize financial fields to defaults to satisfy DB constraints + order.setSubtotalChf(BigDecimal.ZERO); + order.setTotalChf(BigDecimal.ZERO); + order.setDiscountChf(BigDecimal.ZERO); + order.setSetupCostChf(session.getSetupCostChf()); // Or 0 if null, but session has it + order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default + // Save Order first to get ID order = orderRepo.save(order); @@ -162,6 +174,7 @@ public class OrderController { oItem.setStoredFilename(storedFilename); oItem.setStoredRelativePath("PENDING"); // Placeholder oItem.setMimeType("application/octet-stream"); // specific type if known + oItem.setCreatedAt(OffsetDateTime.now()); oItem = orderItemRepo.save(oItem); @@ -174,11 +187,9 @@ public class OrderController { try { Path sourcePath = Paths.get(qItem.getStoredPath()); if (Files.exists(sourcePath)) { - Path targetPath = Paths.get(STORAGE_ROOT, relativePath); - Files.createDirectories(targetPath.getParent()); - Files.copy(sourcePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + storageService.store(sourcePath, Paths.get(relativePath)); - oItem.setFileSizeBytes(Files.size(targetPath)); + oItem.setFileSizeBytes(Files.size(sourcePath)); } } catch (IOException e) { e.printStackTrace(); // Log error but allow order creation? Or fail? @@ -195,6 +206,7 @@ public class OrderController { order.setSubtotalChf(subtotal); order.setSetupCostChf(session.getSetupCostChf()); order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0? + order.setDiscountChf(BigDecimal.ZERO); // TODO: Calc implementation for shipping BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); @@ -239,14 +251,7 @@ public class OrderController { } // Save file to disk - Path absolutePath = Paths.get(STORAGE_ROOT, relativePath); - Files.createDirectories(absolutePath.getParent()); - - if (Files.exists(absolutePath)) { - Files.delete(absolutePath); // Overwrite? - } - - Files.copy(file.getInputStream(), absolutePath); + storageService.store(file, Paths.get(relativePath)); item.setFileSizeBytes(file.getSize()); item.setMimeType(file.getContentType()); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index ce4261d..ffeb59a 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -40,6 +40,7 @@ public class QuoteSessionController { private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; + private final com.printcalculator.service.StorageService storageService; // Defaults private static final String DEFAULT_FILAMENT = "pla_basic"; @@ -50,13 +51,15 @@ public class QuoteSessionController { SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, - com.printcalculator.repository.PricingPolicyRepository pricingRepo) { + com.printcalculator.repository.PricingPolicyRepository pricingRepo, + com.printcalculator.service.StorageService storageService) { this.sessionRepo = sessionRepo; this.lineItemRepo = lineItemRepo; this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; this.pricingRepo = pricingRepo; + this.storageService = storageService; } // 1. Start a new empty session @@ -100,9 +103,7 @@ public class QuoteSessionController { if (file.isEmpty()) throw new IOException("File is empty"); // 1. Define Persistent Storage Path - // Structure: storage_quotes/{sessionId}/{uuid}.{ext} - String storageDir = "storage_quotes/" + session.getId(); - Files.createDirectories(Paths.get(storageDir)); + // Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root) String originalFilename = file.getOriginalFilename(); String ext = originalFilename != null && originalFilename.contains(".") @@ -110,10 +111,13 @@ public class QuoteSessionController { : ".stl"; String storedFilename = UUID.randomUUID() + ext; - Path persistentPath = Paths.get(storageDir, storedFilename); + Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename); // Save file - Files.copy(file.getInputStream(), persistentPath); + storageService.store(file, relativePath); + + // Resolve absolute path for slicing and storage usage + Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath(); try { // Apply Basic/Advanced Logic @@ -206,7 +210,9 @@ public class QuoteSessionController { } catch (Exception e) { // Cleanup if failed - Files.deleteIfExists(persistentPath); + try { + storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename)); + } catch (Exception ignored) {} throw e; } } @@ -330,6 +336,24 @@ public class QuoteSessionController { } Path path = Paths.get(item.getStoredPath()); + // Since storedPath is absolute, we can't directly use loadAsResource with it unless we resolve relative. + // But loadAsResource expects relative path? + // Actually FileSystemStorageService.loadAsResource uses rootLocation.resolve(path). + // If path is absolute, resolve might fail or behave weirdly. + // But wait, we stored absolute path in DB: item.setStoredPath(persistentPath.toString()); + // If we want to use storageService.loadAsResource, we need the relative path. + // Or we just access the file directly if we trust the absolute path. + // But we want to use StorageService abstraction. + + // Option 1: Reconstruct relative path. + // We know structure: quotes/{sessionId}/{filename}... + // But filename is UUID+ext. We don't have storedFilename in QuoteLineItem easily? + // QuoteLineItem doesn't seem to have storedFilename field, only storedPath. + + // If we trust the file is on disk, we can use UrlResource directly here as before, + // relying on the fact that storedPath is the absolute path to the file. + // But we should verify it exists. + if (!Files.exists(path)) { return ResponseEntity.notFound().build(); } diff --git a/backend/src/main/java/com/printcalculator/exception/StorageException.java b/backend/src/main/java/com/printcalculator/exception/StorageException.java new file mode 100644 index 0000000..0a0da37 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/StorageException.java @@ -0,0 +1,12 @@ +package com.printcalculator.exception; + +public class StorageException extends RuntimeException { + + public StorageException(String message) { + super(message); + } + + public StorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/ClamAVService.java b/backend/src/main/java/com/printcalculator/service/ClamAVService.java new file mode 100644 index 0000000..0306730 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/ClamAVService.java @@ -0,0 +1,50 @@ +package com.printcalculator.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import xyz.capybara.clamav.ClamavClient; +import xyz.capybara.clamav.commands.scan.result.ScanResult; + +import java.io.InputStream; +import java.util.Collection; +import java.util.Map; + +@Service +public class ClamAVService { + + private static final Logger logger = LoggerFactory.getLogger(ClamAVService.class); + + private final ClamavClient clamavClient; + + public ClamAVService( + @Value("${clamav.host:localhost}") String host, + @Value("${clamav.port:3310}") int port + ) { + logger.info("Initializing ClamAV client at {}:{}", host, port); + this.clamavClient = new ClamavClient(host, port); + } + + public boolean scan(InputStream inputStream) { + try { + ScanResult result = clamavClient.scan(inputStream); + if (result instanceof ScanResult.OK) { + return true; + } else if (result instanceof ScanResult.VirusFound) { + Map> viruses = ((ScanResult.VirusFound) result).getFoundViruses(); + logger.warn("Virus found: {}", viruses); + return false; + } else { + logger.warn("Unknown scan result: {}", result); + return false; + } + } catch (Exception e) { + logger.error("Error scanning file with ClamAV", e); + // Fail safe? Or fail secure? + // Usually if scanner fails, we should probably reject to be safe, or allow with warning depending on policy. + // For now, let's reject to be safe. + return false; + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java b/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java new file mode 100644 index 0000000..7376035 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java @@ -0,0 +1,98 @@ +package com.printcalculator.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import com.printcalculator.exception.StorageException; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +@Service +public class FileSystemStorageService implements StorageService { + + private final Path rootLocation; + private final ClamAVService clamAVService; + + public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) { + this.rootLocation = Paths.get(storageLocation); + this.clamAVService = clamAVService; + } + + @Override + public void init() { + try { + Files.createDirectories(rootLocation); + } catch (IOException e) { + throw new StorageException("Could not initialize storage", e); + } + } + + @Override + public void store(MultipartFile file, Path destinationRelativePath) throws IOException { + Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath(); + if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) { + // Security check + throw new StorageException("Cannot store file outside current directory."); + } + + // Scan stream + try (InputStream inputStream = file.getInputStream()) { + if (!clamAVService.scan(inputStream)) { + throw new StorageException("File rejected by antivirus scanner."); + } + } + + // Reset stream? MultipartFile.getInputStream() returns a new stream usually, + // but let's verify if we need to open it again. + // Yes, we consumed the stream for scanning. We need to open it again for copying. + + try (InputStream inputStream = file.getInputStream()) { + Files.createDirectories(destinationFile.getParent()); + Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING); + } + } + + @Override + public void store(Path source, Path destinationRelativePath) throws IOException { + Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath(); + if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) { + throw new StorageException("Cannot store file outside current directory."); + } + Files.createDirectories(destinationFile.getParent()); + // We assume source is already safe/scanned if it is internal? + // Or should we scan it too? + // If it comes from QuoteSession (which was scanned on upload), it is safe. + // If we want to be paranoid, we can scan again, but maybe overkill. + // Let's assume it is safe for internal copies. + Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING); + } + + @Override + public void delete(Path path) throws IOException { + Path file = rootLocation.resolve(path); + Files.deleteIfExists(file); + } + + @Override + public Resource loadAsResource(Path path) throws IOException { + try { + Path file = rootLocation.resolve(path); + Resource resource = new UrlResource(file.toUri()); + if (resource.exists() || resource.isReadable()) { + return resource; + } else { + throw new RuntimeException("Could not read file: " + path); + } + } catch (MalformedURLException e) { + throw new RuntimeException("Could not read file: " + path, e); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/StorageService.java b/backend/src/main/java/com/printcalculator/service/StorageService.java new file mode 100644 index 0000000..5fe2321 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/StorageService.java @@ -0,0 +1,14 @@ +package com.printcalculator.service; + +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.Path; +import java.io.IOException; + +public interface StorageService { + void init(); + void store(MultipartFile file, Path destination) throws IOException; + void store(Path source, Path destination) throws IOException; + void delete(Path path) throws IOException; + Resource loadAsResource(Path path) throws IOException; +} diff --git a/backend/src/test/java/com/printcalculator/controller/OrderIntegrationTest.java b/backend/src/test/java/com/printcalculator/controller/OrderIntegrationTest.java new file mode 100644 index 0000000..12f52b1 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/OrderIntegrationTest.java @@ -0,0 +1,157 @@ +package com.printcalculator.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.printcalculator.dto.CreateOrderRequest; +import com.printcalculator.dto.CustomerDto; +import com.printcalculator.dto.AddressDto; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.util.FileSystemUtils; + +import java.io.File; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class OrderIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private QuoteSessionRepository sessionRepository; + + @Autowired + private QuoteLineItemRepository lineItemRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private ObjectMapper objectMapper; + + private UUID sessionId; + private UUID lineItemId; + private final String TEST_FILENAME = "test_model.stl"; + + @BeforeEach + void setup() throws Exception { + // 1. Create Quote Session + QuoteSession session = new QuoteSession(); + session.setStatus("ACTIVE"); + session.setMaterialCode("PLA"); + session.setPricingVersion("v1"); + session.setCreatedAt(OffsetDateTime.now()); + session.setExpiresAt(OffsetDateTime.now().plusDays(7)); + session.setSetupCostChf(BigDecimal.valueOf(5.00)); + session.setSupportsEnabled(false); + session = sessionRepository.save(session); + this.sessionId = session.getId(); + + // 2. Create Dummy File on Disk (storage_quotes) + Path sessionDir = Paths.get("storage_quotes", sessionId.toString()); + Files.createDirectories(sessionDir); + Path filePath = sessionDir.resolve(UUID.randomUUID() + ".stl"); + Files.writeString(filePath, "dummy content"); + + // 3. Create Quote Line Item + QuoteLineItem item = new QuoteLineItem(); + item.setQuoteSession(session); + item.setStatus("READY"); + item.setOriginalFilename(TEST_FILENAME); + item.setStoredPath(filePath.toString()); + item.setQuantity(2); + item.setPrintTimeSeconds(120); + item.setMaterialGrams(BigDecimal.valueOf(10.5)); + item.setUnitPriceChf(BigDecimal.valueOf(10.00)); + item.setCreatedAt(OffsetDateTime.now()); + item.setUpdatedAt(OffsetDateTime.now()); + item = lineItemRepository.save(item); + this.lineItemId = item.getId(); + } + + @AfterEach + void cleanup() throws Exception { + // Cleanup generated files + FileSystemUtils.deleteRecursively(Paths.get("storage_quotes")); + FileSystemUtils.deleteRecursively(Paths.get("storage_orders")); + + // Clean DB + orderRepository.deleteAll(); + lineItemRepository.deleteAll(); + sessionRepository.deleteAll(); + } + + @Test + void testCreateOrderFromQuote_ShouldCopyFilesAndUpdateStatus() throws Exception { + // Prepare Request + CreateOrderRequest request = new CreateOrderRequest(); + + CustomerDto customer = new CustomerDto(); + customer.setEmail("integration@test.com"); + customer.setCustomerType("PRIVATE"); + request.setCustomer(customer); + + AddressDto billing = new AddressDto(); + billing.setFirstName("John"); + billing.setLastName("Doe"); + billing.setAddressLine1("Street 1"); + billing.setCity("City"); + billing.setZip("1000"); + billing.setCountryCode("CH"); + request.setBillingAddress(billing); + + request.setShippingSameAsBilling(true); + + // Execute Request + mockMvc.perform(post("/api/orders/from-quote/" + sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // Verify Session Status + QuoteSession updatedSession = sessionRepository.findById(sessionId).orElseThrow(); + assertEquals("CONVERTED", updatedSession.getStatus(), "Session status should be CONVERTED"); + assertNotNull(updatedSession.getConvertedOrderId(), "Converted Order ID should be set"); + + UUID orderId = updatedSession.getConvertedOrderId(); + + // Verify File Copy + Path orderStorageDir = Paths.get("storage_orders"); + // We need to find the specific file. Structure: storage_orders/orderId/3d-files/orderItemId/filename + // Since we don't know OrderItemId easily without querying DB, let's walk the dir. + + try (var stream = Files.walk(orderStorageDir)) { + boolean fileFound = stream + .filter(Files::isRegularFile) + .anyMatch(path -> { + try { + return Files.readString(path).equals("dummy content"); + } catch (Exception e) { + return false; + } + }); + assertTrue(fileFound, "The file should have been copied to storage_orders with correct content"); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index faf3593..5e636f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,19 @@ services: - MARKUP_PERCENT=20 - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles + - CLAMAV_HOST=clamav + - CLAMAV_PORT=3310 depends_on: - db + - clamav + restart: unless-stopped + + clamav: + platform: linux/amd64 + image: clamav/clamav:latest + container_name: print-calculator-clamav + ports: + - "3310:3310" restart: unless-stopped frontend: