fix(deploy): fix security
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 25s

This commit is contained in:
2026-03-03 13:09:55 +01:00
parent 27af5f7ebb
commit 127a321621
12 changed files with 326 additions and 48 deletions

View File

@@ -20,6 +20,7 @@ jobs:
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle - name: Run Tests with Gradle
run: | run: |

View File

@@ -108,7 +108,15 @@ jobs:
- name: Run Gitleaks (secrets scan) - name: Run Gitleaks (secrets scan)
shell: bash shell: bash
run: | run: |
gitleaks detect --source . --no-git --redact --exit-code 1 set +e
gitleaks detect --source . --no-git --redact --exit-code 1 \
--report-format json --report-path /tmp/gitleaks-report.json
rc=$?
if [[ $rc -ne 0 ]]; then
echo "Gitleaks findings:"
cat /tmp/gitleaks-report.json
fi
exit $rc
test-backend: test-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -121,6 +129,7 @@ jobs:
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle - name: Run Tests with Gradle
run: | run: |

View File

@@ -1,18 +1,24 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.QuoteRequestDto;
import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.service.ClamAVService;
import com.printcalculator.service.email.EmailNotificationService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -22,8 +28,11 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.Year;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -32,10 +41,18 @@ import java.util.regex.Pattern;
@RequestMapping("/api/custom-quote-requests") @RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController { public class CustomQuoteRequestController {
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class);
private final CustomQuoteRequestRepository requestRepo; private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo; private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final ClamAVService clamAVService;
private final EmailNotificationService emailNotificationService;
@Value("${app.mail.contact-request.admin.enabled:true}")
private boolean contactRequestAdminMailEnabled;
@Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}")
private String contactRequestAdminMailAddress;
// TODO: Inject Storage Service // TODO: Inject Storage Service
private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
@@ -59,17 +76,19 @@ public class CustomQuoteRequestController {
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo, CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.ClamAVService clamAVService) { ClamAVService clamAVService,
EmailNotificationService emailNotificationService) {
this.requestRepo = requestRepo; this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo; this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
this.emailNotificationService = emailNotificationService;
} }
// 1. Create Custom Quote Request // 1. Create Custom Quote Request
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest( public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
@Valid @RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto, @Valid @RequestPart("request") QuoteRequestDto requestDto,
@RequestPart(value = "files", required = false) List<MultipartFile> files @RequestPart(value = "files", required = false) List<MultipartFile> files
) throws IOException { ) throws IOException {
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) {
@@ -96,6 +115,7 @@ public class CustomQuoteRequestController {
request = requestRepo.save(request); request = requestRepo.save(request);
// 2. Handle Attachments // 2. Handle Attachments
int attachmentsCount = 0;
if (files != null && !files.isEmpty()) { if (files != null && !files.isEmpty()) {
if (files.size() > 15) { if (files.size() > 15) {
throw new IOException("Too many files. Max 15 allowed."); throw new IOException("Too many files. Max 15 allowed.");
@@ -148,9 +168,12 @@ public class CustomQuoteRequestController {
try (InputStream inputStream = file.getInputStream()) { try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING); Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
} }
attachmentsCount++;
} }
} }
sendAdminContactRequestNotification(request, attachmentsCount);
return ResponseEntity.ok(request); return ResponseEntity.ok(request);
} }
@@ -203,4 +226,42 @@ public class CustomQuoteRequestController {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
} }
} }
private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) {
if (!contactRequestAdminMailEnabled) {
return;
}
if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) {
logger.warn("Contact request admin notification enabled but no admin address configured.");
return;
}
Map<String, Object> templateData = new HashMap<>();
templateData.put("requestId", request.getId());
templateData.put("createdAt", request.getCreatedAt());
templateData.put("requestType", safeValue(request.getRequestType()));
templateData.put("customerType", safeValue(request.getCustomerType()));
templateData.put("name", safeValue(request.getName()));
templateData.put("companyName", safeValue(request.getCompanyName()));
templateData.put("contactPerson", safeValue(request.getContactPerson()));
templateData.put("email", safeValue(request.getEmail()));
templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount);
templateData.put("currentYear", Year.now().getValue());
emailNotificationService.sendEmail(
contactRequestAdminMailAddress,
"Nuova richiesta di contatto #" + request.getId(),
"contact-request-admin",
templateData
);
}
private String safeValue(String value) {
if (value == null || value.isBlank()) {
return "-";
}
return value;
}
} }

View File

@@ -30,6 +30,8 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -144,9 +146,13 @@ public class AdminOrderController {
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "File not available"); throw new ResponseStatusException(NOT_FOUND, "File not available");
} }
Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (safeRelativePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try { try {
Resource resource = storageService.loadAsResource(Paths.get(relativePath)); Resource resource = storageService.loadAsResource(safeRelativePath);
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (item.getMimeType() != null && !item.getMimeType().isBlank()) { if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
try { try {
@@ -276,9 +282,9 @@ public class AdminOrderController {
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) { private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
String displayOrderNumber = getDisplayOrderNumber(order); String displayOrderNumber = getDisplayOrderNumber(order);
if (isConfirmation) { if (isConfirmation) {
String relativePath = "orders/" + order.getId() + "/documents/confirmation-" + displayOrderNumber + ".pdf"; Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
try { try {
byte[] existingPdf = storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes(); byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok() return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF) .contentType(MediaType.APPLICATION_PDF)
@@ -298,4 +304,24 @@ public class AdminOrderController {
.contentType(MediaType.APPLICATION_PDF) .contentType(MediaType.APPLICATION_PDF)
.body(pdf); .body(pdf);
} }
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
}
} }

View File

@@ -21,10 +21,12 @@ import java.nio.file.StandardCopyOption;
public class FileSystemStorageService implements StorageService { public class FileSystemStorageService implements StorageService {
private final Path rootLocation; private final Path rootLocation;
private final Path normalizedRootLocation;
private final ClamAVService clamAVService; private final ClamAVService clamAVService;
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) { public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
this.rootLocation = Paths.get(storageLocation); this.rootLocation = Paths.get(storageLocation);
this.normalizedRootLocation = this.rootLocation.toAbsolutePath().normalize();
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
} }
@@ -39,10 +41,7 @@ public class FileSystemStorageService implements StorageService {
@Override @Override
public void store(MultipartFile file, Path destinationRelativePath) throws IOException { public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath(); Path destinationFile = resolveInsideStorage(destinationRelativePath);
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
throw new StorageException("Cannot store file outside current directory.");
}
// 1. Salva prima il file su disco per evitare problemi di stream con file grandi // 1. Salva prima il file su disco per evitare problemi di stream con file grandi
Files.createDirectories(destinationFile.getParent()); Files.createDirectories(destinationFile.getParent());
@@ -63,32 +62,46 @@ public class FileSystemStorageService implements StorageService {
@Override @Override
public void store(Path source, Path destinationRelativePath) throws IOException { public void store(Path source, Path destinationRelativePath) throws IOException {
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath(); Path destinationFile = resolveInsideStorage(destinationRelativePath);
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
throw new StorageException("Cannot store file outside current directory.");
}
Files.createDirectories(destinationFile.getParent()); Files.createDirectories(destinationFile.getParent());
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING); Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
} }
@Override @Override
public void delete(Path path) throws IOException { public void delete(Path path) throws IOException {
Path file = rootLocation.resolve(path); Path file = resolveInsideStorage(path);
Files.deleteIfExists(file); Files.deleteIfExists(file);
} }
@Override @Override
public Resource loadAsResource(Path path) throws IOException { public Resource loadAsResource(Path path) throws IOException {
try { try {
Path file = rootLocation.resolve(path); Path file = resolveInsideStorage(path);
Resource resource = new UrlResource(file.toUri()); Resource resource = new UrlResource(file.toUri());
if (resource.exists() || resource.isReadable()) { if (resource.exists() || resource.isReadable()) {
return resource; return resource;
} else { } else {
throw new RuntimeException("Could not read file: " + path); throw new StorageException("Could not read file: " + path);
} }
} catch (MalformedURLException e) { } catch (MalformedURLException e) {
throw new RuntimeException("Could not read file: " + path, e); throw new StorageException("Could not read file: " + path, e);
} }
} }
private Path resolveInsideStorage(Path relativePath) {
if (relativePath == null) {
throw new StorageException("Path is required.");
}
Path normalizedRelative = relativePath.normalize();
if (normalizedRelative.isAbsolute()) {
throw new StorageException("Cannot access absolute paths.");
}
Path resolved = normalizedRootLocation.resolve(normalizedRelative).normalize();
if (!resolved.startsWith(normalizedRootLocation)) {
throw new StorageException("Cannot access files outside storage root.");
}
return resolved;
}
} }

View File

@@ -10,9 +10,9 @@ import org.springframework.stereotype.Service;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -30,7 +30,7 @@ public class SlicerService {
private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$"); private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$"); private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private final String slicerPath; private final String trustedSlicerPath;
private final ProfileManager profileManager; private final ProfileManager profileManager;
private final GCodeParser gCodeParser; private final GCodeParser gCodeParser;
private final ObjectMapper mapper; private final ObjectMapper mapper;
@@ -40,7 +40,7 @@ public class SlicerService {
ProfileManager profileManager, ProfileManager profileManager,
GCodeParser gCodeParser, GCodeParser gCodeParser,
ObjectMapper mapper) { ObjectMapper mapper) {
this.slicerPath = slicerPath; this.trustedSlicerPath = normalizeExecutablePath(slicerPath);
this.profileManager = profileManager; this.profileManager = profileManager;
this.gCodeParser = gCodeParser; this.gCodeParser = gCodeParser;
this.mapper = mapper; this.mapper = mapper;
@@ -83,17 +83,24 @@ public class SlicerService {
basename = basename.substring(0, basename.length() - 4); basename = basename.substring(0, basename.length() - 4);
} }
Path slicerLogPath = tempDir.resolve("orcaslicer.log"); Path slicerLogPath = tempDir.resolve("orcaslicer.log");
String machineProfilePath = requireSafeArgument(mFile.getAbsolutePath(), "machine profile path");
String processProfilePath = requireSafeArgument(pFile.getAbsolutePath(), "process profile path");
String filamentProfilePath = requireSafeArgument(fFile.getAbsolutePath(), "filament profile path");
String outputDirPath = requireSafeArgument(tempDir.toAbsolutePath().toString(), "output directory path");
String inputStlPath = requireSafeArgument(inputStl.getAbsolutePath(), "input STL path");
// 3. Run slicer. Retry with arrange only for out-of-volume style failures. // 3. Run slicer. Retry with arrange only for out-of-volume style failures.
for (boolean useArrange : new boolean[]{false, true}) { for (boolean useArrange : new boolean[]{false, true}) {
List<String> command = new ArrayList<>(); // Build process arguments explicitly to avoid shell interpretation and command injection.
command.add(slicerPath); ProcessBuilder pb = new ProcessBuilder();
List<String> command = pb.command();
command.add(trustedSlicerPath);
command.add("--load-settings"); command.add("--load-settings");
command.add(mFile.getAbsolutePath()); command.add(machineProfilePath);
command.add("--load-settings"); command.add("--load-settings");
command.add(pFile.getAbsolutePath()); command.add(processProfilePath);
command.add("--load-filaments"); command.add("--load-filaments");
command.add(fFile.getAbsolutePath()); command.add(filamentProfilePath);
command.add("--ensure-on-bed"); command.add("--ensure-on-bed");
if (useArrange) { if (useArrange) {
command.add("--arrange"); command.add("--arrange");
@@ -102,13 +109,12 @@ public class SlicerService {
command.add("--slice"); command.add("--slice");
command.add("0"); command.add("0");
command.add("--outputdir"); command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString()); command.add(outputDirPath);
command.add(inputStl.getAbsolutePath()); command.add(inputStlPath);
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command)); logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
Files.deleteIfExists(slicerLogPath); Files.deleteIfExists(slicerLogPath);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile()); pb.directory(tempDir.toFile());
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
pb.redirectOutput(slicerLogPath.toFile()); pb.redirectOutput(slicerLogPath.toFile());
@@ -157,17 +163,17 @@ public class SlicerService {
} }
public Optional<ModelDimensions> inspectModelDimensions(File inputModel) { public Optional<ModelDimensions> inspectModelDimensions(File inputModel) {
Path tempDir = null; Path tempDir = null;
try { try {
tempDir = Files.createTempDirectory("slicer_info_"); tempDir = Files.createTempDirectory("slicer_info_");
Path infoLogPath = tempDir.resolve("orcaslicer-info.log"); Path infoLogPath = tempDir.resolve("orcaslicer-info.log");
String inputModelPath = requireSafeArgument(inputModel.getAbsolutePath(), "input model path");
List<String> command = new ArrayList<>(); ProcessBuilder pb = new ProcessBuilder();
command.add(slicerPath); List<String> infoCommand = pb.command();
command.add("--info"); infoCommand.add(trustedSlicerPath);
command.add(inputModel.getAbsolutePath()); infoCommand.add("--info");
infoCommand.add(inputModelPath);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile()); pb.directory(tempDir.toFile());
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
pb.redirectOutput(infoLogPath.toFile()); pb.redirectOutput(infoLogPath.toFile());
@@ -267,4 +273,38 @@ public class SlicerService {
|| normalized.contains("no object is fully inside the print volume") || normalized.contains("no object is fully inside the print volume")
|| normalized.contains("calc_exclude_triangles"); || normalized.contains("calc_exclude_triangles");
} }
private String normalizeExecutablePath(String configuredPath) {
if (configuredPath == null || configuredPath.isBlank()) {
throw new IllegalArgumentException("slicer.path is required");
}
if (containsControlChars(configuredPath)) {
throw new IllegalArgumentException("slicer.path contains invalid control characters");
}
try {
return Path.of(configuredPath.trim()).normalize().toString();
} catch (InvalidPathException e) {
throw new IllegalArgumentException("Invalid slicer.path: " + configuredPath, e);
}
}
private String requireSafeArgument(String value, String argName) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Missing required argument: " + argName);
}
if (containsControlChars(value)) {
throw new IOException("Invalid control characters in " + argName);
}
return value;
}
private boolean containsControlChars(String value) {
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
if (ch == '\0' || ch == '\n' || ch == '\r') {
return true;
}
}
return false;
}
} }

View File

@@ -15,13 +15,17 @@ public class TwintPaymentService {
private final String twintPaymentUrl; private final String twintPaymentUrl;
public TwintPaymentService( public TwintPaymentService(
@Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}") @Value("${payment.twint.url:}")
String twintPaymentUrl String twintPaymentUrl
) { ) {
this.twintPaymentUrl = twintPaymentUrl; this.twintPaymentUrl = twintPaymentUrl;
} }
public String getTwintPaymentUrl(com.printcalculator.entity.Order order) { public String getTwintPaymentUrl(com.printcalculator.entity.Order order) {
if (twintPaymentUrl == null || twintPaymentUrl.isBlank()) {
throw new IllegalStateException("TWINT_PAYMENT_URL is not configured");
}
StringBuilder urlBuilder = new StringBuilder(twintPaymentUrl); StringBuilder urlBuilder = new StringBuilder(twintPaymentUrl);
if (order != null) { if (order != null) {

View File

@@ -1,7 +1,8 @@
app.mail.enabled=false app.mail.enabled=false
app.mail.admin.enabled=false app.mail.admin.enabled=false
app.mail.contact-request.admin.enabled=false
# Admin back-office local test credentials # Admin back-office local test credentials
admin.password=ciaociao admin.password=local-admin-password
admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789 admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000
admin.session.ttl-minutes=480 admin.session.ttl-minutes=480

View File

@@ -4,7 +4,7 @@ server.port=8000
# Database Configuration # Database Configuration
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc} spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc}
spring.datasource.username=${DB_USERNAME:printcalc} spring.datasource.username=${DB_USERNAME:printcalc}
spring.datasource.password=${DB_PASSWORD:printcalc_secret} spring.datasource.password=${DB_PASSWORD:}
spring.jpa.hibernate.ddl-auto=update spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
@@ -26,7 +26,7 @@ clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false} clamav.enabled=${CLAMAV_ENABLED:false}
# TWINT Configuration # TWINT Configuration
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.} payment.twint.url=${TWINT_PAYMENT_URL:}
# Mail Configuration # Mail Configuration
spring.mail.host=${MAIL_HOST:mail.infomaniak.com} spring.mail.host=${MAIL_HOST:mail.infomaniak.com}
@@ -41,6 +41,8 @@ app.mail.enabled=${APP_MAIL_ENABLED:true}
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}} app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true}
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
# Admin back-office authentication # Admin back-office authentication

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Nuova richiesta di contatto</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 640px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin-top: 0;
color: #222222;
}
p {
color: #444444;
line-height: 1.5;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th,
td {
text-align: left;
vertical-align: top;
border-bottom: 1px solid #eeeeee;
padding: 10px 6px;
color: #333333;
word-break: break-word;
}
th {
width: 35%;
color: #222222;
background: #fafafa;
}
.footer {
margin-top: 24px;
font-size: 12px;
color: #888888;
border-top: 1px solid #eeeeee;
padding-top: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Nuova richiesta di contatto</h1>
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
<table>
<tr>
<th>ID richiesta</th>
<td th:text="${requestId}">00000000-0000-0000-0000-000000000000</td>
</tr>
<tr>
<th>Data</th>
<td th:text="${createdAt}">2026-03-03T10:00:00Z</td>
</tr>
<tr>
<th>Tipo richiesta</th>
<td th:text="${requestType}">PRINT_SERVICE</td>
</tr>
<tr>
<th>Tipo cliente</th>
<td th:text="${customerType}">PRIVATE</td>
</tr>
<tr>
<th>Nome</th>
<td th:text="${name}">Mario Rossi</td>
</tr>
<tr>
<th>Azienda</th>
<td th:text="${companyName}">3D Fab SA</td>
</tr>
<tr>
<th>Contatto</th>
<td th:text="${contactPerson}">Mario Rossi</td>
</tr>
<tr>
<th>Email</th>
<td th:text="${email}">cliente@example.com</td>
</tr>
<tr>
<th>Telefono</th>
<td th:text="${phone}">+41 00 000 00 00</td>
</tr>
<tr>
<th>Messaggio</th>
<td th:text="${message}">Testo richiesta cliente...</td>
</tr>
<tr>
<th>Allegati</th>
<td th:text="${attachmentsCount}">0</td>
</tr>
</table>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab - notifica automatica.</p>
</div>
</div>
</body>
</html>

View File

@@ -32,7 +32,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
}) })
@TestPropertySource(properties = { @TestPropertySource(properties = {
"admin.password=test-admin-password", "admin.password=test-admin-password",
"admin.session.secret=0123456789abcdef0123456789abcdef", "admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"admin.session.ttl-minutes=60" "admin.session.ttl-minutes=60"
}) })
class AdminAuthSecurityTest { class AdminAuthSecurityTest {

View File

@@ -4,7 +4,7 @@ services:
container_name: print-calculator-db container_name: print-calculator-db
environment: environment:
- POSTGRES_USER=printcalc - POSTGRES_USER=printcalc
- POSTGRES_PASSWORD=printcalc_secret - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=printcalc - POSTGRES_DB=printcalc
ports: ports:
- "5432:5432" - "5432:5432"