diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index e67de4f..a4711e5 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -26,13 +26,14 @@ public class CustomQuoteRequestController { private final CustomQuoteRequestRepository requestRepo; private final CustomQuoteRequestAttachmentRepository attachmentRepo; - // TODO: Inject Storage Service - private static final String STORAGE_ROOT = "storage_requests"; + private final com.printcalculator.service.StorageService storageService; public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, - CustomQuoteRequestAttachmentRepository attachmentRepo) { + CustomQuoteRequestAttachmentRepository attachmentRepo, + com.printcalculator.service.StorageService storageService) { this.requestRepo = requestRepo; this.attachmentRepo = attachmentRepo; + this.storageService = storageService; } // 1. Create Custom Quote Request @@ -91,10 +92,8 @@ public class CustomQuoteRequestController { attachment.setStoredRelativePath(relativePath); attachmentRepo.save(attachment); - // Save file to disk - Path absolutePath = Paths.get(STORAGE_ROOT, relativePath); - Files.createDirectories(absolutePath.getParent()); - Files.copy(file.getInputStream(), absolutePath); + // Save file to disk via StorageService + storageService.store(file, Paths.get(relativePath)); } } diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 2290eb6..34b71af 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -30,8 +30,6 @@ public class OrderController { 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"; public OrderController(OrderRepository orderRepo, OrderItemRepository orderItemRepo, @@ -207,7 +205,11 @@ public class OrderController { order.setSetupCostChf(session.getSetupCostChf()); order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0? order.setDiscountChf(BigDecimal.ZERO); - // TODO: Calc implementation for shipping + // Calculate Shipping (Basic implementation: Flat rate 9.00 if not pickup) + // Future: Check delivery method from request if available + if (order.getShippingCostChf() == null) { + order.setShippingCostChf(BigDecimal.valueOf(9.00)); + } BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); order.setTotalChf(total); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 018b613..448c150 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -108,7 +108,11 @@ public class QuoteController { try { file.transferTo(tempInput.toFile()); - String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity + // Use profile from machine or fallback + String slicerMachineProfile = machine.getSlicerMachineProfile(); + if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) { + slicerMachineProfile = "bambu_a1"; + } PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides); diff --git a/backend/src/main/java/com/printcalculator/entity/PrinterMachine.java b/backend/src/main/java/com/printcalculator/entity/PrinterMachine.java index 8d378df..fa2b7e3 100644 --- a/backend/src/main/java/com/printcalculator/entity/PrinterMachine.java +++ b/backend/src/main/java/com/printcalculator/entity/PrinterMachine.java @@ -41,6 +41,9 @@ public class PrinterMachine { @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; + @Column(name = "slicer_machine_profile") + private String slicerMachineProfile; + public Long getId() { return id; } @@ -57,6 +60,14 @@ public class PrinterMachine { this.printerDisplayName = printerDisplayName; } + public String getSlicerMachineProfile() { + return slicerMachineProfile; + } + + public void setSlicerMachineProfile(String slicerMachineProfile) { + this.slicerMachineProfile = slicerMachineProfile; + } + public Integer getBuildVolumeXMm() { return buildVolumeXMm; } diff --git a/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..3b5db96 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java @@ -0,0 +1,46 @@ +package com.printcalculator.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +import java.util.HashMap; +import java.util.Map; + +@ControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(StorageException.class) + public ResponseEntity handleStorageException(StorageException exc) { + // Log the full exception for internal debugging + log.error("Storage Exception occurred", exc); + + Map response = new HashMap<>(); + + // Check for specific virus case + if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) { + response.put("error", "Security Violation"); + // Safe message for client + response.put("message", "File rejected by security policy."); + response.put("code", "VIRUS_DETECTED"); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response); + } + + // Generic fallback for other storage errors to avoid leaking internal paths/details + response.put("error", "Storage Operation Failed"); + response.put("message", "Unable to process the file upload."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxSizeException(MaxUploadSizeExceededException exc) { + Map response = new HashMap<>(); + response.put("error", "File too large"); + response.put("message", "The uploaded file exceeds the maximum allowed size."); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/ClamAVService.java b/backend/src/main/java/com/printcalculator/service/ClamAVService.java index 8d5dec5..2b6a6d2 100644 --- a/backend/src/main/java/com/printcalculator/service/ClamAVService.java +++ b/backend/src/main/java/com/printcalculator/service/ClamAVService.java @@ -40,7 +40,7 @@ public class ClamAVService { return true; } else if (result instanceof ScanResult.VirusFound) { Map> viruses = ((ScanResult.VirusFound) result).getFoundViruses(); - logger.warn("Virus found: {}", viruses); + logger.warn("VIRUS DETECTED: {}", viruses); return false; } else { logger.warn("Unknown scan result: {}", result); diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index 573b668..dc33acc 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -89,26 +89,15 @@ public class SlicerService { command.add(inputStl.getAbsolutePath()); - logger.info("Executing Slicer: " + String.join(" ", command)); + logger.info("Slicing file: " + inputStl.getAbsolutePath() + " (Size: " + inputStl.length() + " bytes)"); + if (!inputStl.exists()) { + throw new IOException("Input file not found: " + inputStl.getAbsolutePath()); + } // 4. Run Process - ProcessBuilder pb = new ProcessBuilder(command); - pb.directory(tempDir.toFile()); - // pb.inheritIO(); // Useful for debugging, but maybe capture instead? - - Process process = pb.start(); - boolean finished = process.waitFor(5, TimeUnit.MINUTES); - - if (!finished) { - process.destroy(); - throw new IOException("Slicer timed out"); - } - - if (process.exitValue() != 0) { - // Read stderr - String error = new String(process.getErrorStream().readAllBytes()); - throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error); - } + runSlicerCommand(command, tempDir); + + // 5. Find Output GCode // 5. Find Output GCode // Usually [basename].gcode or plate_1.gcode @@ -131,9 +120,6 @@ public class SlicerService { // 6. Parse Results return gCodeParser.parse(gcodeFile); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted during slicing", e); } finally { // Cleanup temp dir // In production we should delete, for debugging we might want to keep? @@ -143,4 +129,30 @@ public class SlicerService { // Implementation detail: Use a utility to clean up. } } + protected void runSlicerCommand(List command, Path tempDir) throws IOException { + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(tempDir.toFile()); + + try { + Process process = pb.start(); + boolean finished = process.waitFor(5, TimeUnit.MINUTES); + + if (!finished) { + process.destroy(); + throw new IOException("Slicer timed out"); + } + + if (process.exitValue() != 0) { + // Read stderr and stdout + String stderr = new String(process.getErrorStream().readAllBytes()); + String stdout = new String(process.getInputStream().readAllBytes()); // STDOUT + throw new IOException("Slicer failed with exit code " + process.exitValue() + + "\nSTDERR: " + stderr + + "\nSTDOUT: " + stdout); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted during slicing", e); + } + } } diff --git a/backend/src/test/java/com/printcalculator/service/SlicerServiceTest.java b/backend/src/test/java/com/printcalculator/service/SlicerServiceTest.java new file mode 100644 index 0000000..11b2db6 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/SlicerServiceTest.java @@ -0,0 +1,123 @@ +package com.printcalculator.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.printcalculator.model.PrintStats; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; + +class SlicerServiceTest { + + @Mock + private ProfileManager profileManager; + + @Mock + private GCodeParser gCodeParser; + + private ObjectMapper mapper = new ObjectMapper(); + + private SlicerService slicerService; + + @TempDir + Path tempDir; + + // Captured execution details + private List lastCommand; + private Path lastTempDir; + + @BeforeEach + void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + + // Subclass to override runSlicerCommand + slicerService = new SlicerService("orca-slicer", profileManager, gCodeParser, mapper) { + @Override + protected void runSlicerCommand(List command, Path tempDir) throws IOException { + lastCommand = command; + lastTempDir = tempDir; + // Don't run actual process. + // Simulate GCode output creation for the parser to find? + // Or just let it fail at parser step since we only care about JSON generation here? + // For a full test, we should create a dummy GCode file. + + File stl = new File(command.get(command.size() - 1)); + String basename = stl.getName().replace(".stl", ""); + Files.createFile(tempDir.resolve(basename + ".gcode")); + } + }; + + // Mock Profile Responses + ObjectNode emptyNode = mapper.createObjectNode(); + when(profileManager.getMergedProfile(anyString(), eq("machine"))).thenReturn(emptyNode.deepCopy()); + when(profileManager.getMergedProfile(anyString(), eq("filament"))).thenReturn(emptyNode.deepCopy()); + when(profileManager.getMergedProfile(anyString(), eq("process"))).thenReturn(emptyNode.deepCopy()); + + // Mock Parser + when(gCodeParser.parse(any(File.class))).thenReturn(new PrintStats(100, "1m 40s", 10.5, 1000)); + } + + @Test + void testSlice_WithDefaults_ShouldGenerateConfig() throws IOException { + File dummyStl = tempDir.resolve("test.stl").toFile(); + Files.createFile(dummyStl.toPath()); + + slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, null); + + assertNotNull(lastTempDir); + assertTrue(Files.exists(lastTempDir.resolve("process.json"))); + assertTrue(Files.exists(lastTempDir.resolve("machine.json"))); + assertTrue(Files.exists(lastTempDir.resolve("filament.json"))); + } + + @Test + void testSlice_WithLayerHeightOverride_ShouldUpdateProcessJson() throws IOException { + File dummyStl = tempDir.resolve("test.stl").toFile(); + Files.createFile(dummyStl.toPath()); + + Map processOverrides = new HashMap<>(); + processOverrides.put("layer_height", "0.12"); + + slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides); + + File processJsonFile = lastTempDir.resolve("process.json").toFile(); + ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile); + + assertTrue(processJson.has("layer_height")); + assertEquals("0.12", processJson.get("layer_height").asText()); + } + + @Test + void testSlice_WithInfillAndSupportOverrides_ShouldUpdateProcessJson() throws IOException { + File dummyStl = tempDir.resolve("test.stl").toFile(); + Files.createFile(dummyStl.toPath()); + + Map processOverrides = new HashMap<>(); + processOverrides.put("sparse_infill_density", "25%"); + processOverrides.put("enable_support", "1"); + + slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides); + + File processJsonFile = lastTempDir.resolve("process.json").toFile(); + ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile); + + assertEquals("25%", processJson.get("sparse_infill_density").asText()); + assertEquals("1", processJson.get("enable_support").asText()); + } +} diff --git a/db.sql b/db.sql index eadd022..08818e9 100644 --- a/db.sql +++ b/db.sql @@ -12,6 +12,7 @@ create table printer_machine fleet_weight numeric(6, 3) not null default 1.000, is_active boolean not null default true, + slicer_machine_profile varchar(255), created_at timestamptz not null default now() ); diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index a1971f0..2a058e5 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -15,13 +15,16 @@ services: - DB_PASSWORD=${DB_PASSWORD} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles - - CLAMAV_HOST=192.168.1.147 + - CLAMAV_HOST=host.docker.internal - CLAMAV_PORT=3310 + - STORAGE_LOCATION=/app/storage restart: always volumes: - backend_profiles_${ENV}:/app/profiles - - /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes - - /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders + - /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage/quotes + - /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage/orders + extra_hosts: + - "host.docker.internal:host-gateway" frontend: diff --git a/docker-compose.yml b/docker-compose.yml index 1f90f60..927cf05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - PROFILES_DIR=/app/profiles - CLAMAV_HOST=192.168.1.147 - CLAMAV_PORT=3310 + - STORAGE_LOCATION=/app/storage depends_on: - db - clamav diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 928f957..813c7f1 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -2,7 +2,9 @@

{{ 'CALC.TITLE' | translate }}

{{ 'CALC.SUBTITLE' | translate }}

- @if (error()) { + @if (error() === 'VIRUS_DETECTED') { + {{ 'CALC.ERROR_VIRUS' | translate }} + } @else if (error()) { {{ 'CALC.ERROR_GENERIC' | translate }} } diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 4a3d4c0..7f2fea7 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -26,7 +26,7 @@ export class CalculatorPageComponent implements OnInit { loading = signal(false); uploadProgress = signal(0); result = signal(null); - error = signal(false); + error = signal(null); orderSuccess = signal(false); @@ -141,7 +141,7 @@ export class CalculatorPageComponent implements OnInit { this.currentRequest = req; this.loading.set(true); this.uploadProgress.set(0); - this.error.set(false); + this.error.set(null); this.result.set(null); this.orderSuccess.set(false); @@ -175,8 +175,12 @@ export class CalculatorPageComponent implements OnInit { } } }, - error: () => { - this.error.set(true); + error: (err) => { + if (typeof err === 'string') { + this.error.set(err); + } else { + this.error.set('GENERIC'); + } this.loading.set(false); } }); diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 9d8668d..3dd1ad4 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -215,7 +215,8 @@ export class QuoteEstimatorService { }, error: (err) => { console.error('Item upload failed', err); - finalResponses[index] = { success: false, fileName: item.file.name }; + const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED'; + finalResponses[index] = { success: false, fileName: item.file.name, error: errorMsg }; completedRequests++; checkCompletion(); } @@ -262,7 +263,13 @@ export class QuoteEstimatorService { }); if (validCount === 0) { - observer.error('All calculations failed.'); + // Check if any failed due to virus + const virusError = responses.find(r => r.error === 'VIRUS_DETECTED'); + if (virusError) { + observer.error('VIRUS_DETECTED'); + } else { + observer.error('All calculations failed.'); + } return; } diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index e5d3916..6089657 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -40,7 +40,7 @@ "CTA_START": "Start Now", "BUSINESS": "Business", "PRIVATE": "Private", - "MODE_EASY": "Quick", + "MODE_EASY": "Easy Print", "MODE_ADVANCED": "Advanced", "UPLOAD_LABEL": "Drag your 3D file here", "UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index c9a7be4..b160b15 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -17,7 +17,7 @@ "CTA_START": "Inizia Ora", "BUSINESS": "Aziende", "PRIVATE": "Privati", - "MODE_EASY": "Base", + "MODE_EASY": "Stampa Facile", "MODE_ADVANCED": "Avanzata", "UPLOAD_LABEL": "Trascina il tuo file 3D qui", "UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",