fix(deploy): fix security
This commit is contained in:
@@ -1,18 +1,24 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.dto.QuoteRequestDto;
|
||||
import com.printcalculator.entity.CustomQuoteRequest;
|
||||
import com.printcalculator.entity.CustomQuoteRequestAttachment;
|
||||
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
|
||||
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.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -22,8 +28,11 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.Year;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -32,10 +41,18 @@ import java.util.regex.Pattern;
|
||||
@RequestMapping("/api/custom-quote-requests")
|
||||
public class CustomQuoteRequestController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class);
|
||||
private final CustomQuoteRequestRepository requestRepo;
|
||||
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
|
||||
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}$");
|
||||
@@ -59,17 +76,19 @@ public class CustomQuoteRequestController {
|
||||
|
||||
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
|
||||
CustomQuoteRequestAttachmentRepository attachmentRepo,
|
||||
com.printcalculator.service.ClamAVService clamAVService) {
|
||||
ClamAVService clamAVService,
|
||||
EmailNotificationService emailNotificationService) {
|
||||
this.requestRepo = requestRepo;
|
||||
this.attachmentRepo = attachmentRepo;
|
||||
this.clamAVService = clamAVService;
|
||||
this.emailNotificationService = emailNotificationService;
|
||||
}
|
||||
|
||||
// 1. Create Custom Quote Request
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Transactional
|
||||
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
|
||||
) throws IOException {
|
||||
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) {
|
||||
@@ -96,6 +115,7 @@ public class CustomQuoteRequestController {
|
||||
request = requestRepo.save(request);
|
||||
|
||||
// 2. Handle Attachments
|
||||
int attachmentsCount = 0;
|
||||
if (files != null && !files.isEmpty()) {
|
||||
if (files.size() > 15) {
|
||||
throw new IOException("Too many files. Max 15 allowed.");
|
||||
@@ -148,9 +168,12 @@ public class CustomQuoteRequestController {
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
attachmentsCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sendAdminContactRequestNotification(request, attachmentsCount);
|
||||
|
||||
return ResponseEntity.ok(request);
|
||||
}
|
||||
|
||||
@@ -203,4 +226,42 @@ public class CustomQuoteRequestController {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -144,9 +146,13 @@ public class AdminOrderController {
|
||||
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
|
||||
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 {
|
||||
Resource resource = storageService.loadAsResource(Paths.get(relativePath));
|
||||
Resource resource = storageService.loadAsResource(safeRelativePath);
|
||||
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
|
||||
try {
|
||||
@@ -276,9 +282,9 @@ public class AdminOrderController {
|
||||
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
|
||||
String displayOrderNumber = getDisplayOrderNumber(order);
|
||||
if (isConfirmation) {
|
||||
String relativePath = "orders/" + order.getId() + "/documents/confirmation-" + displayOrderNumber + ".pdf";
|
||||
Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
|
||||
try {
|
||||
byte[] existingPdf = storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes();
|
||||
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
@@ -298,4 +304,24 @@ public class AdminOrderController {
|
||||
.contentType(MediaType.APPLICATION_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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,12 @@ import java.nio.file.StandardCopyOption;
|
||||
public class FileSystemStorageService implements StorageService {
|
||||
|
||||
private final Path rootLocation;
|
||||
private final Path normalizedRootLocation;
|
||||
private final ClamAVService clamAVService;
|
||||
|
||||
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
|
||||
this.rootLocation = Paths.get(storageLocation);
|
||||
this.normalizedRootLocation = this.rootLocation.toAbsolutePath().normalize();
|
||||
this.clamAVService = clamAVService;
|
||||
}
|
||||
|
||||
@@ -39,10 +41,7 @@ public class FileSystemStorageService implements StorageService {
|
||||
|
||||
@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())) {
|
||||
throw new StorageException("Cannot store file outside current directory.");
|
||||
}
|
||||
Path destinationFile = resolveInsideStorage(destinationRelativePath);
|
||||
|
||||
// 1. Salva prima il file su disco per evitare problemi di stream con file grandi
|
||||
Files.createDirectories(destinationFile.getParent());
|
||||
@@ -63,32 +62,46 @@ public class FileSystemStorageService implements StorageService {
|
||||
|
||||
@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.");
|
||||
}
|
||||
Path destinationFile = resolveInsideStorage(destinationRelativePath);
|
||||
Files.createDirectories(destinationFile.getParent());
|
||||
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(Path path) throws IOException {
|
||||
Path file = rootLocation.resolve(path);
|
||||
Path file = resolveInsideStorage(path);
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource loadAsResource(Path path) throws IOException {
|
||||
try {
|
||||
Path file = rootLocation.resolve(path);
|
||||
Path file = resolveInsideStorage(path);
|
||||
Resource resource = new UrlResource(file.toUri());
|
||||
if (resource.exists() || resource.isReadable()) {
|
||||
return resource;
|
||||
} else {
|
||||
throw new RuntimeException("Could not read file: " + path);
|
||||
throw new StorageException("Could not read file: " + path);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import org.springframework.stereotype.Service;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
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_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 GCodeParser gCodeParser;
|
||||
private final ObjectMapper mapper;
|
||||
@@ -40,7 +40,7 @@ public class SlicerService {
|
||||
ProfileManager profileManager,
|
||||
GCodeParser gCodeParser,
|
||||
ObjectMapper mapper) {
|
||||
this.slicerPath = slicerPath;
|
||||
this.trustedSlicerPath = normalizeExecutablePath(slicerPath);
|
||||
this.profileManager = profileManager;
|
||||
this.gCodeParser = gCodeParser;
|
||||
this.mapper = mapper;
|
||||
@@ -83,17 +83,24 @@ public class SlicerService {
|
||||
basename = basename.substring(0, basename.length() - 4);
|
||||
}
|
||||
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.
|
||||
for (boolean useArrange : new boolean[]{false, true}) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(slicerPath);
|
||||
// Build process arguments explicitly to avoid shell interpretation and command injection.
|
||||
ProcessBuilder pb = new ProcessBuilder();
|
||||
List<String> command = pb.command();
|
||||
command.add(trustedSlicerPath);
|
||||
command.add("--load-settings");
|
||||
command.add(mFile.getAbsolutePath());
|
||||
command.add(machineProfilePath);
|
||||
command.add("--load-settings");
|
||||
command.add(pFile.getAbsolutePath());
|
||||
command.add(processProfilePath);
|
||||
command.add("--load-filaments");
|
||||
command.add(fFile.getAbsolutePath());
|
||||
command.add(filamentProfilePath);
|
||||
command.add("--ensure-on-bed");
|
||||
if (useArrange) {
|
||||
command.add("--arrange");
|
||||
@@ -102,13 +109,12 @@ public class SlicerService {
|
||||
command.add("--slice");
|
||||
command.add("0");
|
||||
command.add("--outputdir");
|
||||
command.add(tempDir.toAbsolutePath().toString());
|
||||
command.add(inputStl.getAbsolutePath());
|
||||
command.add(outputDirPath);
|
||||
command.add(inputStlPath);
|
||||
|
||||
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
|
||||
|
||||
Files.deleteIfExists(slicerLogPath);
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(tempDir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
pb.redirectOutput(slicerLogPath.toFile());
|
||||
@@ -157,17 +163,17 @@ public class SlicerService {
|
||||
}
|
||||
|
||||
public Optional<ModelDimensions> inspectModelDimensions(File inputModel) {
|
||||
Path tempDir = null;
|
||||
Path tempDir = null;
|
||||
try {
|
||||
tempDir = Files.createTempDirectory("slicer_info_");
|
||||
Path infoLogPath = tempDir.resolve("orcaslicer-info.log");
|
||||
String inputModelPath = requireSafeArgument(inputModel.getAbsolutePath(), "input model path");
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(slicerPath);
|
||||
command.add("--info");
|
||||
command.add(inputModel.getAbsolutePath());
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
ProcessBuilder pb = new ProcessBuilder();
|
||||
List<String> infoCommand = pb.command();
|
||||
infoCommand.add(trustedSlicerPath);
|
||||
infoCommand.add("--info");
|
||||
infoCommand.add(inputModelPath);
|
||||
pb.directory(tempDir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
pb.redirectOutput(infoLogPath.toFile());
|
||||
@@ -267,4 +273,38 @@ public class SlicerService {
|
||||
|| normalized.contains("no object is fully inside the print volume")
|
||||
|| 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,17 @@ public class TwintPaymentService {
|
||||
private final String twintPaymentUrl;
|
||||
|
||||
public TwintPaymentService(
|
||||
@Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}")
|
||||
@Value("${payment.twint.url:}")
|
||||
String twintPaymentUrl
|
||||
) {
|
||||
this.twintPaymentUrl = twintPaymentUrl;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (order != null) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
app.mail.enabled=false
|
||||
app.mail.admin.enabled=false
|
||||
app.mail.contact-request.admin.enabled=false
|
||||
|
||||
# Admin back-office local test credentials
|
||||
admin.password=ciaociao
|
||||
admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789
|
||||
admin.password=local-admin-password
|
||||
admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000
|
||||
admin.session.ttl-minutes=480
|
||||
|
||||
@@ -4,7 +4,7 @@ server.port=8000
|
||||
# Database Configuration
|
||||
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/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.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||
spring.jpa.open-in-view=false
|
||||
@@ -26,7 +26,7 @@ clamav.port=${CLAMAV_PORT:3310}
|
||||
clamav.enabled=${CLAMAV_ENABLED:false}
|
||||
|
||||
# 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
|
||||
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.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
|
||||
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}
|
||||
|
||||
# Admin back-office authentication
|
||||
|
||||
@@ -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>© <span th:text="${currentYear}">2026</span> 3D-Fab - notifica automatica.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -32,7 +32,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
})
|
||||
@TestPropertySource(properties = {
|
||||
"admin.password=test-admin-password",
|
||||
"admin.session.secret=0123456789abcdef0123456789abcdef",
|
||||
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"admin.session.ttl-minutes=60"
|
||||
})
|
||||
class AdminAuthSecurityTest {
|
||||
|
||||
Reference in New Issue
Block a user