diff --git a/docker-compose.yml b/docker-compose.yml index 4168b9e..2b8cdd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,8 @@ services: context: support-portal-backend dockerfile: Dockerfile restart: always - ports: - - "8080:8080" + expose: + - "8080" environment: MYSQL_HOST: db MYSQL_USER: support_portal_user diff --git a/support-portal-backend/src/main/java/net/shyshkin/study/fullstack/supportportal/backend/controller/FileController.java b/support-portal-backend/src/main/java/net/shyshkin/study/fullstack/supportportal/backend/controller/FileController.java index 21c4972..aca810a 100644 --- a/support-portal-backend/src/main/java/net/shyshkin/study/fullstack/supportportal/backend/controller/FileController.java +++ b/support-portal-backend/src/main/java/net/shyshkin/study/fullstack/supportportal/backend/controller/FileController.java @@ -11,13 +11,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; -import java.util.HashMap; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.UUID; @RestController @RequestMapping("/api/files") -@CrossOrigin(origins = "*") +@CrossOrigin(origins = { + "https://cmcbackend.rootxwire.com", + "https://maincmc.rootxwire.com", + "https://cmctrauma.com" +}) public class FileController { @Value("${file.upload.directory:uploads}") @@ -26,48 +31,86 @@ public class FileController { @Value("${app.base-url:http://localhost:8080}") private String baseUrl; + private static final List ALLOWED_EXTENSIONS = Arrays.asList( + ".jpg", ".jpeg", ".png", ".gif", ".webp", + ".pdf", ".doc", ".docx" + ); + + private static final List ALLOWED_CONTENT_TYPES = Arrays.asList( + "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + @PostMapping("/upload") public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { - System.out.println("=== FILE UPLOAD DEBUG ==="); - System.out.println("File name: " + file.getOriginalFilename()); - System.out.println("Content type: " + file.getContentType()); - System.out.println("Base URL: " + baseUrl); - try { + // 1. Check empty if (file.isEmpty()) { return ResponseEntity.badRequest().body("File is empty"); } - // Updated validation to accept both images and documents - String contentType = file.getContentType(); - if (!isValidFileType(contentType)) { - return ResponseEntity.badRequest().body("Invalid file type. Only images and documents (PDF, DOC, DOCX) are allowed."); + // 2. Check file size + if (file.getSize() > MAX_FILE_SIZE) { + return ResponseEntity.badRequest().body("File size exceeds 5MB limit"); } - // Create upload directory if it doesn't exist + // 3. Get and sanitize original filename + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isBlank()) { + return ResponseEntity.badRequest().body("Invalid filename"); + } + + // Prevent path traversal - only take the actual filename + String sanitizedFilename = Paths.get(originalFilename).getFileName().toString(); + + // 4. Validate extension (cannot be spoofed by client unlike Content-Type) + String extension = getExtension(sanitizedFilename); + if (extension == null || !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) { + return ResponseEntity.badRequest() + .body("Invalid file type. Allowed: JPG, PNG, GIF, WEBP, PDF, DOC, DOCX"); + } + + // 5. Validate Content-Type (secondary check) + String contentType = file.getContentType(); + if (!ALLOWED_CONTENT_TYPES.contains(contentType)) { + return ResponseEntity.badRequest() + .body("Invalid content type."); + } + + // 6. Validate Content-Type matches extension + if (!isContentTypeMatchingExtension(contentType, extension)) { + return ResponseEntity.badRequest() + .body("File type mismatch detected."); + } + + // 7. Create upload directory if needed Path uploadPath = Paths.get(uploadDirectory); if (!Files.exists(uploadPath)) { Files.createDirectories(uploadPath); } - // Generate unique filename - String originalFilename = file.getOriginalFilename(); - String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".")); - String uniqueFilename = UUID.randomUUID().toString() + fileExtension; + // 8. Generate safe unique filename + String uniqueFilename = UUID.randomUUID().toString() + extension.toLowerCase(); - // Save file - Path filePath = uploadPath.resolve(uniqueFilename); + // 9. Resolve path safely (prevent path traversal) + Path filePath = uploadPath.resolve(uniqueFilename).normalize(); + if (!filePath.startsWith(uploadPath.normalize())) { + return ResponseEntity.badRequest().body("Invalid file path"); + } + + // 10. Save file Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); - // FIXED: Return full URL with base URL String fileUrl = baseUrl + "/uploads/" + uniqueFilename; - Map response = new HashMap<>(); - response.put("url", fileUrl); - response.put("filename", uniqueFilename); - System.out.println("File uploaded successfully. URL: " + fileUrl); - - return ResponseEntity.ok(response); + return ResponseEntity.ok(Map.of( + "url", fileUrl, + "filename", uniqueFilename + )); } catch (IOException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -75,32 +118,25 @@ public class FileController { } } - // Updated validation method - private boolean isValidFileType(String contentType) { - return contentType != null && ( - // Image types - contentType.equals("image/jpeg") || - contentType.equals("image/jpg") || - contentType.equals("image/png") || - contentType.equals("image/gif") || - contentType.equals("image/webp") || - // Document types for resumes - contentType.equals("application/pdf") || - contentType.equals("application/msword") || - contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document") - ); - } - @DeleteMapping("/images/{filename}") public ResponseEntity deleteImage(@PathVariable String filename) { try { - Path filePath = Paths.get(uploadDirectory).resolve(filename); + // Prevent path traversal + String sanitized = Paths.get(filename).getFileName().toString(); + + Path uploadPath = Paths.get(uploadDirectory).normalize(); + Path filePath = uploadPath.resolve(sanitized).normalize(); + + if (!filePath.startsWith(uploadPath)) { + return ResponseEntity.badRequest().body("Invalid filename"); + } + if (!Files.exists(filePath)) { return ResponseEntity.notFound().build(); } Files.delete(filePath); - return ResponseEntity.ok().body(Map.of("message", "File deleted successfully")); + return ResponseEntity.ok(Map.of("message", "File deleted successfully")); } catch (IOException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -111,30 +147,67 @@ public class FileController { @GetMapping("/images/{filename}") public ResponseEntity getImage(@PathVariable String filename) { try { - Path filePath = Paths.get(uploadDirectory).resolve(filename); + // Prevent path traversal + String sanitized = Paths.get(filename).getFileName().toString(); + + Path uploadPath = Paths.get(uploadDirectory).normalize(); + Path filePath = uploadPath.resolve(sanitized).normalize(); + + if (!filePath.startsWith(uploadPath)) { + return ResponseEntity.badRequest().build(); + } + if (!Files.exists(filePath)) { return ResponseEntity.notFound().build(); } - byte[] imageBytes = Files.readAllBytes(filePath); + // Validate it's an allowed file type before serving + String ext = getExtension(sanitized); + if (ext == null || !ALLOWED_EXTENSIONS.contains(ext.toLowerCase())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + byte[] fileBytes = Files.readAllBytes(filePath); String contentType = Files.probeContentType(filePath); - + return ResponseEntity.ok() .header("Content-Type", contentType != null ? contentType : "application/octet-stream") - .body(imageBytes); + .header("Content-Disposition", "inline; filename=\"" + sanitized + "\"") + .body(fileBytes); } catch (IOException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } - private boolean isValidImageType(String contentType) { - return contentType != null && ( - contentType.equals("image/jpeg") || - contentType.equals("image/jpg") || - contentType.equals("image/png") || - contentType.equals("image/gif") || - contentType.equals("image/webp") - ); + // --- Helpers --- + + private String getExtension(String filename) { + int dotIndex = filename.lastIndexOf("."); + if (dotIndex < 0 || dotIndex == filename.length() - 1) return null; + return filename.substring(dotIndex); + } + + private boolean isContentTypeMatchingExtension(String contentType, String extension) { + String ext = extension.toLowerCase(); + switch (ext) { + case ".jpg": + case ".jpeg": + return contentType.equals("image/jpeg") || contentType.equals("image/jpg"); + case ".png": + return contentType.equals("image/png"); + case ".gif": + return contentType.equals("image/gif"); + case ".webp": + return contentType.equals("image/webp"); + case ".pdf": + return contentType.equals("application/pdf"); + case ".doc": + return contentType.equals("application/msword"); + case ".docx": + return contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + default: + return false; + } } } \ No newline at end of file