feat(back-end): integration of clamAVS
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 51s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-02-12 21:50:42 +01:00
parent 2eea773ee2
commit f5aa0f298e
9 changed files with 393 additions and 21 deletions

View File

@@ -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'

View File

@@ -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());

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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<String, Collection<String>> 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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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");
}
}
}