diff --git a/backend/pom.xml b/backend/pom.xml index 9c87602..8946ba2 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -131,6 +131,26 @@ org.springframework.session spring-session-jdbc + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + org.springframework.security + spring-security-test + test + diff --git a/backend/readme.md b/backend/readme.md index 6b0a320..df8192f 100644 --- a/backend/readme.md +++ b/backend/readme.md @@ -10,6 +10,12 @@ mvn clean package cd target java -jar backend.jar ``` +## Context of the Backend +The context of the backend is `/api`. This is set at `server.servlet.context-path` in +the application.yaml. Since we serve the backend at `/api`, every controller context +path is appended after `/api`. eg: `https://host/api/notification`. +The admin portal ui to the backend is served at `/` as a seperate web app. +Similarly, backend is another webapp served at `/api` in the same tomcat. ## How the Portal Webapp is Served in the Tomcat Server - pom.xml in `portal/` will build the vue app and will create a war, by including everything inside `dist` @@ -19,4 +25,6 @@ java -jar backend.jar - if none of them exists, backend will start without the webapp - For more info: `lk.gov.govtech.covid19.config.WebappConfiguration` - Content in the war will appear at localhost:8000/ + + \ No newline at end of file diff --git a/backend/src/main/java/lk/gov/govtech/covid19/controller/ApplicationController.java b/backend/src/main/java/lk/gov/govtech/covid19/controller/ApplicationController.java index b9c3dd5..4596c94 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/controller/ApplicationController.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/controller/ApplicationController.java @@ -1,16 +1,17 @@ package lk.gov.govtech.covid19.controller; -import lk.gov.govtech.covid19.dto.AlertNotificationResponse; -import lk.gov.govtech.covid19.dto.CaseNotificationResponse; -import lk.gov.govtech.covid19.dto.StatusResponse; -import lk.gov.govtech.covid19.dto.UpdateStatusRequest; +import lk.gov.govtech.covid19.dto.*; import lk.gov.govtech.covid19.service.ApplicationService; import lk.gov.govtech.covid19.util.Constants; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; +import javax.validation.Valid; + /** * Controller class for all task force application related apis */ @@ -81,16 +82,9 @@ public ResponseEntity getSatus() { //Update Covid-19 Status @PutMapping(path = "/dashboard/status", consumes = "application/json", produces = "application/json") - public ResponseEntity updateStatus(@RequestBody UpdateStatusRequest request){ - - if(request==null){ - log.error("Empty request found"); - return ResponseEntity.noContent().build(); - }else { - log.info("Dashboard status updated"); - applicationService.updateStatus(request); - return ResponseEntity.accepted().build(); - } - + public ResponseEntity updateStatus(@RequestBody @Valid UpdateStatusRequest request){ + log.info("Dashboard status updated"); + applicationService.updateStatus(request); + return ResponseEntity.accepted().build(); } } diff --git a/backend/src/main/java/lk/gov/govtech/covid19/controller/ImageController.java b/backend/src/main/java/lk/gov/govtech/covid19/controller/ImageController.java index f3b4dc8..e921d1e 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/controller/ImageController.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/controller/ImageController.java @@ -2,8 +2,11 @@ import lk.gov.govtech.covid19.dto.StoredImage; import lk.gov.govtech.covid19.dto.StoredImageResponse; +import lk.gov.govtech.covid19.exceptions.ImageHandlingException; import lk.gov.govtech.covid19.service.ImageService; import lk.gov.govtech.covid19.util.Constants; +import lk.gov.govtech.covid19.validation.AcceptableImage; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -11,27 +14,26 @@ import org.springframework.web.multipart.MultipartFile; @RestController +@Slf4j @RequestMapping(Constants.IMAGE_API_CONTEXT) public class ImageController { @Autowired ImageService imageService; + private MultipartFile image; - @PostMapping(path = "/add") - public ResponseEntity uploadImage(@RequestParam("image") MultipartFile image){ - StoredImageResponse response = null; - if (image.isEmpty()){ - System.out.println("empty file"); - }else { - response = imageService.addImage(image); - } + @PostMapping + public ResponseEntity uploadImage(@RequestParam("image") @AcceptableImage MultipartFile image) + throws ImageHandlingException { + StoredImageResponse response = imageService.addImage(image); + log.info("Image {} of size:{} added", image.getOriginalFilename(), image.getSize()); System.gc(); - return ResponseEntity.ok().body(response); + return ResponseEntity.accepted().body(response); } @GetMapping(value = "/image/{imageId}", produces = MediaType.IMAGE_JPEG_VALUE) public @ResponseBody byte[] getFiles(@PathVariable("imageId") int imageId){ - StoredImage si = imageService.getImage(imageId); - return si.getImage(); + StoredImage storedImage = imageService.getImage(imageId); + return storedImage.getImage(); } } diff --git a/backend/src/main/java/lk/gov/govtech/covid19/controller/NotificationController.java b/backend/src/main/java/lk/gov/govtech/covid19/controller/NotificationController.java index 87837af..fbc7031 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/controller/NotificationController.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/controller/NotificationController.java @@ -9,6 +9,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import javax.validation.Valid; + @Slf4j @RestController @RequestMapping(value = Constants.NOTIFICATION_API_CONTEXT) @@ -18,7 +20,7 @@ public class NotificationController { NotificationService notificationService; @PostMapping(path = "/alert/add", consumes = "application/json", produces = "application/json") - public ResponseEntity addNewAlert(@RequestBody AlertNotificationRequest request){ + public ResponseEntity addNewAlert(@RequestBody @Valid AlertNotificationRequest request){ log.info("New alert added with title {}", request.getTitle().getEnglish()); notificationService.addAlertNotificaiton(request); @@ -26,7 +28,7 @@ public ResponseEntity addNewAlert(@RequestBody AlertNotificationRequest request) } @PutMapping(path = "/alert/{alertId}", consumes = "application/json") - public ResponseEntity addNewAlert(@PathVariable("alertId") String alertId, @RequestBody AlertNotificationRequest request){ + public ResponseEntity addNewAlert(@PathVariable("alertId") String alertId, @RequestBody @Valid AlertNotificationRequest request){ boolean success = notificationService.updateAlertNotification(alertId, request); if (success) { log.info("Update alert with id:{} title:{}", alertId, request.getTitle().getEnglish()); diff --git a/backend/src/main/java/lk/gov/govtech/covid19/dto/AlertNotificationRequest.java b/backend/src/main/java/lk/gov/govtech/covid19/dto/AlertNotificationRequest.java index 10fd9a6..996edbf 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/dto/AlertNotificationRequest.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/dto/AlertNotificationRequest.java @@ -2,23 +2,35 @@ import lombok.Data; +import javax.validation.Valid; +import javax.validation.constraints.*; + @Data public class AlertNotificationRequest { + @NotBlank @Size(max=45) private String source; + @NotNull @Valid private Title title; + @NotNull @Valid private Message message; @Data public static class Title { + @NotBlank @Size(max=100) private String english; + @Size(max=100) private String sinhala; + @Size(max=100) private String tamil; } @Data public static class Message { + @NotBlank @Size(min=8, max=2500) private String english; + @Size(max=2500) private String sinhala; + @Size(max=2500) private String tamil; } } diff --git a/backend/src/main/java/lk/gov/govtech/covid19/dto/StoredImageResponse.java b/backend/src/main/java/lk/gov/govtech/covid19/dto/StoredImageResponse.java index ed9fa02..2959857 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/dto/StoredImageResponse.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/dto/StoredImageResponse.java @@ -1,11 +1,10 @@ package lk.gov.govtech.covid19.dto; -import lombok.AllArgsConstructor; import lombok.Data; @Data -@AllArgsConstructor public class StoredImageResponse { private int id; + private String url; private String name; } diff --git a/backend/src/main/java/lk/gov/govtech/covid19/dto/UpdateStatusRequest.java b/backend/src/main/java/lk/gov/govtech/covid19/dto/UpdateStatusRequest.java index 1e7f78b..a0f2478 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/dto/UpdateStatusRequest.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/dto/UpdateStatusRequest.java @@ -2,10 +2,21 @@ import lombok.Data; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; + @Data public class UpdateStatusRequest { - private int lk_total_case; - private int lk_recovered_case; - private int lk_total_deaths; - private int lk_total_suspect; + @NotNull @Min(0) @Max(22000000) + private Integer lk_total_case; + + @NotNull @Min(0) @Max(22000000) + private Integer lk_recovered_case; + + @NotNull @Min(0) @Max(22000000) + private Integer lk_total_deaths; + + @NotNull @Min(0) @Max(22000000) + private Integer lk_total_suspect; } diff --git a/backend/src/main/java/lk/gov/govtech/covid19/exceptions/CustomExceptionHandler.java b/backend/src/main/java/lk/gov/govtech/covid19/exceptions/CustomExceptionHandler.java new file mode 100644 index 0000000..3b7cdce --- /dev/null +++ b/backend/src/main/java/lk/gov/govtech/covid19/exceptions/CustomExceptionHandler.java @@ -0,0 +1,50 @@ +package lk.gov.govtech.covid19.exceptions; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; + +import javax.servlet.http.HttpServletRequest; + +@Slf4j +@ControllerAdvice +public class CustomExceptionHandler { + + @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Malformed JSON request") + @ExceptionHandler(value = {HttpMessageNotReadableException.class}) + public void handleMalformedRequestRelatedExceptions(HttpServletRequest request, Exception e) { + log.warn("Malformed JSON request"); + } + + @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Invalid request. Value missing or invalid.") + @ExceptionHandler(value = {MethodArgumentNotValidException.class, HttpMessageConversionException.class, + DataIntegrityViolationException.class, IllegalArgumentException.class}) + public void handleValidationException(HttpServletRequest request, Exception e) { + log.warn("Invalid request. Value missing or invalid."); + } + + @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR, reason = "Error while processing image") + @ExceptionHandler(value = {ImageHandlingException.class}) + public void handleValidationException(HttpServletRequest request, ImageHandlingException e) { + log.warn("Error while processing image"); + } + + @ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "Resource not found") + @ExceptionHandler(value = {IndexOutOfBoundsException.class}) + public void handleExceptionsLeadingToNotFound(HttpServletRequest request, IndexOutOfBoundsException e) { + log.warn("Resource not found"); + } + + @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR, reason = "Unknown error state of server") + @ExceptionHandler(value = { Exception.class }) + public void defaultErrorHandler(HttpServletRequest request, Exception e) throws Exception { + log.error("Exception mapping failure. Exception: {}, message: {}", + e.getClass().getName(), e.getMessage()); + } +} diff --git a/backend/src/main/java/lk/gov/govtech/covid19/exceptions/ImageHandlingException.java b/backend/src/main/java/lk/gov/govtech/covid19/exceptions/ImageHandlingException.java new file mode 100644 index 0000000..cd52b99 --- /dev/null +++ b/backend/src/main/java/lk/gov/govtech/covid19/exceptions/ImageHandlingException.java @@ -0,0 +1,11 @@ +package lk.gov.govtech.covid19.exceptions; + +public class ImageHandlingException extends Exception { + public ImageHandlingException(String message) { + super(message); + } + + public ImageHandlingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/lk/gov/govtech/covid19/model/mapper/StoredImageMapper.java b/backend/src/main/java/lk/gov/govtech/covid19/model/mapper/StoredImageMapper.java index 6132baf..b64223a 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/model/mapper/StoredImageMapper.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/model/mapper/StoredImageMapper.java @@ -12,7 +12,6 @@ public class StoredImageMapper implements RowMapper { @Override public StoredImage mapRow(ResultSet resultSet, int i) throws SQLException { StoredImage si = new StoredImage(); - System.out.println(resultSet.toString()); si.setName(resultSet.getString("name")); Blob blob = resultSet.getBlob("image"); si.setImage(blob.getBytes(1, (int) blob.length())); diff --git a/backend/src/main/java/lk/gov/govtech/covid19/repository/CovidRepository.java b/backend/src/main/java/lk/gov/govtech/covid19/repository/CovidRepository.java index 93956b7..3ecb465 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/repository/CovidRepository.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/repository/CovidRepository.java @@ -6,12 +6,14 @@ import lk.gov.govtech.covid19.model.StatusEntity; import lk.gov.govtech.covid19.model.mapper.*; import lombok.extern.slf4j.Slf4j; +import org.apache.tomcat.util.http.fileupload.IOUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.sql.PreparedStatement; @@ -145,8 +147,9 @@ public void updateStatus(UpdateStatusRequest request) { request.getLk_total_case() , request.getLk_recovered_case(), request.getLk_total_deaths(), request.getLk_total_suspect(), 1); } - public int addImage(InputStream is, String name, long size) throws IOException { + public int addImage(byte[] bArray, String name, long size) { KeyHolder holder = new GeneratedKeyHolder(); + InputStream is = new ByteArrayInputStream(bArray); jdbcTemplate.update(connection -> { PreparedStatement ps = connection.prepareStatement("insert into images(name, image) " + "values(?,?)", Statement.RETURN_GENERATED_KEYS); @@ -155,6 +158,7 @@ public int addImage(InputStream is, String name, long size) throws IOException { return ps; },holder ); + IOUtils.closeQuietly(is); return holder.getKey().intValue(); } diff --git a/backend/src/main/java/lk/gov/govtech/covid19/security/SecurityConfiguration.java b/backend/src/main/java/lk/gov/govtech/covid19/security/SecurityConfiguration.java index b7134ec..64d6d27 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/security/SecurityConfiguration.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/security/SecurityConfiguration.java @@ -31,6 +31,8 @@ public class SecurityConfiguration { /* * Endpoints with auth (either http basic auth or login based can be used) * - /notification + * - /application PUT + * - /images POST * * */ @@ -102,6 +104,10 @@ protected void configure(final HttpSecurity http) throws Exception { .hasAuthority(AUTHORITY_NOTIFICATION) .antMatchers(HttpMethod.PUT, APPLICATION_API_CONTEXT + "/dashboard/status") .hasAuthority(AUTHORITY_NOTIFICATION) + .antMatchers(HttpMethod.POST, IMAGE_API_CONTEXT) + .hasAuthority(AUTHORITY_NOTIFICATION) + .antMatchers(DHIS_API_CONTEXT + "/**") + .hasAuthority(AUTHORITY_NOTIFICATION) .and() .addFilter(getPasswordFilter()) .requestCache() // avoid saving anonymous requests in sessions diff --git a/backend/src/main/java/lk/gov/govtech/covid19/service/ImageCompressionService.java b/backend/src/main/java/lk/gov/govtech/covid19/service/ImageCompressionService.java index c52c10c..24ccaed 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/service/ImageCompressionService.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/service/ImageCompressionService.java @@ -1,5 +1,8 @@ package lk.gov.govtech.covid19.service; +import lk.gov.govtech.covid19.exceptions.ImageHandlingException; +import lombok.extern.slf4j.Slf4j; +import org.apache.tomcat.util.http.fileupload.IOUtils; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -10,32 +13,48 @@ import javax.imageio.stream.ImageOutputStream; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.Iterator; +@Slf4j @Service public class ImageCompressionService { - public byte[] compressImage(MultipartFile file) throws IOException { - BufferedImage image = ImageIO.read(file.getInputStream()); - File compressedFile = new File("compressed_"+file.getOriginalFilename()); -// OutputStream os = new FileOutputStream(compressedFile); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - float imageQ = 0.5f; - Iterator imageWriters = ImageIO.getImageWritersByFormatName("jpg"); - if (!imageWriters.hasNext()){ - throw new IllegalStateException("Writers not found"); + public byte[] compressImage(MultipartFile file) throws ImageHandlingException { + InputStream is = null; + BufferedImage bufferedImage; + ByteArrayOutputStream baos = null; + ImageOutputStream imageOutputStream = null; + ImageWriter imageWriter = null; + + try { + is = file.getInputStream(); + baos = new ByteArrayOutputStream(); + bufferedImage = ImageIO.read(is); + + float imageQ = 0.5f; + Iterator imageWriters = ImageIO.getImageWritersByFormatName("jpg"); + if (!imageWriters.hasNext()) { + throw new IllegalStateException("Writers not found"); + } + imageWriter = imageWriters.next(); + imageOutputStream = ImageIO.createImageOutputStream(baos); + imageWriter.setOutput(imageOutputStream); + ImageWriteParam imageWriteParam = imageWriter.getDefaultWriteParam(); + imageWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + imageWriteParam.setCompressionQuality(imageQ); + imageWriter.write(null, new IIOImage(bufferedImage, null, null), imageWriteParam); + return baos.toByteArray(); + } catch (IOException e) { + log.warn("Error while loading or compressing image"); + throw new ImageHandlingException("Error while loading or compressing image", e); + } finally { + IOUtils.closeQuietly(is); + IOUtils.closeQuietly(baos); + IOUtils.closeQuietly(imageOutputStream); + if (imageWriter != null) { + imageWriter.dispose(); + } } - ImageWriter iw = (ImageWriter)imageWriters.next(); - ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream(baos); - iw.setOutput(imageOutputStream); - ImageWriteParam imageWriteParam = iw.getDefaultWriteParam(); - imageWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); - imageWriteParam.setCompressionQuality(imageQ); - iw.write(null, new IIOImage(image, null, null), imageWriteParam); - baos.close(); - imageOutputStream.close(); - iw.dispose(); - return baos.toByteArray(); } } diff --git a/backend/src/main/java/lk/gov/govtech/covid19/service/ImageService.java b/backend/src/main/java/lk/gov/govtech/covid19/service/ImageService.java index 0d9d758..6eb7591 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/service/ImageService.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/service/ImageService.java @@ -2,7 +2,9 @@ import lk.gov.govtech.covid19.dto.StoredImage; import lk.gov.govtech.covid19.dto.StoredImageResponse; +import lk.gov.govtech.covid19.exceptions.ImageHandlingException; import lk.gov.govtech.covid19.repository.CovidRepository; +import lk.gov.govtech.covid19.util.Constants; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -21,21 +23,15 @@ public class ImageService { @Autowired ImageCompressionService imageCompressionService; - public StoredImageResponse addImage(MultipartFile file){ - StoredImageResponse storedImageResponse = null; - - try { - byte[] bArray = imageCompressionService.compressImage(file); - InputStream is = new ByteArrayInputStream(bArray); - int id = repository.addImage(is,file.getOriginalFilename(),file.getSize()); - storedImageResponse = new StoredImageResponse(id,file.getOriginalFilename()); - return storedImageResponse; - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } + public StoredImageResponse addImage(MultipartFile file) throws ImageHandlingException { + byte[] bArray = imageCompressionService.compressImage(file); + int id = repository.addImage(bArray,file.getOriginalFilename(),file.getSize()); + StoredImageResponse storedImageResponse = new StoredImageResponse(); + storedImageResponse.setId(id); + storedImageResponse.setUrl(Constants.BACKEND_CONTEXT + + Constants.IMAGE_API_CONTEXT + "/image/" + id); + storedImageResponse.setName(file.getOriginalFilename()); return storedImageResponse; } diff --git a/backend/src/main/java/lk/gov/govtech/covid19/util/Constants.java b/backend/src/main/java/lk/gov/govtech/covid19/util/Constants.java index 2f824a4..298b9e4 100644 --- a/backend/src/main/java/lk/gov/govtech/covid19/util/Constants.java +++ b/backend/src/main/java/lk/gov/govtech/covid19/util/Constants.java @@ -2,6 +2,10 @@ public class Constants { + //although defined here, actually set at `server.servlet.context-path` in + //the application.yaml file. Refer to backend/readme for more info + public static final String BACKEND_CONTEXT = "/api"; + //context for all calls related to task force ap public static final String TF_API_CONTEXT = "/taskforce"; //context for all calls related to dhis service integration diff --git a/backend/src/main/java/lk/gov/govtech/covid19/validation/AcceptableImage.java b/backend/src/main/java/lk/gov/govtech/covid19/validation/AcceptableImage.java new file mode 100644 index 0000000..f52e57a --- /dev/null +++ b/backend/src/main/java/lk/gov/govtech/covid19/validation/AcceptableImage.java @@ -0,0 +1,19 @@ +package lk.gov.govtech.covid19.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({TYPE, PARAMETER}) +@Retention(RUNTIME) +@Constraint(validatedBy = ImageValidator.class) +public @interface AcceptableImage { + String message() default "Insupportable type of image"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/backend/src/main/java/lk/gov/govtech/covid19/validation/ImageValidator.java b/backend/src/main/java/lk/gov/govtech/covid19/validation/ImageValidator.java new file mode 100644 index 0000000..855e635 --- /dev/null +++ b/backend/src/main/java/lk/gov/govtech/covid19/validation/ImageValidator.java @@ -0,0 +1,53 @@ +package lk.gov.govtech.covid19.validation; + +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class ImageValidator implements ConstraintValidator { + + @Override + public boolean isValid(MultipartFile multipartFile, ConstraintValidatorContext constraintValidatorContext) { + try { + if(multipartFile.isEmpty()) { + return false; + } else if(multipartFile.getSize() > 200000) { + return false; + } else if (isInvalidFileName(multipartFile.getOriginalFilename())) { + return false; + } else if (isInvalidFileExtension(multipartFile.getOriginalFilename())) { + return false; + } else { + return true; + } + } catch (Exception e) { + return false; + } + } + + private boolean isInvalidFileName(String filename) { + if (filename == null) { + return true; + } else if(filename.length()>100) { + return true; + } else if(hasInvalidChars(filename)) { + return true; + } else if (StringUtils.countOccurrencesOf(filename, ".") != 1) { + return true; + } else { + return false; + } + } + + private boolean hasInvalidChars(String filename) { + boolean isMatching = filename.matches("^[a-zA-Z0-9 ._-]+$"); + return !isMatching; + } + + private boolean isInvalidFileExtension(String originalFilename) { + String extension = originalFilename.split("\\.")[1]; //split by dot + return !extension.equals("jpg"); + } +} diff --git a/backend/src/test/java/lk/gov/govtech/covid19/controller/ApplicationControllerTest.java b/backend/src/test/java/lk/gov/govtech/covid19/controller/ApplicationControllerTest.java new file mode 100644 index 0000000..78ebc24 --- /dev/null +++ b/backend/src/test/java/lk/gov/govtech/covid19/controller/ApplicationControllerTest.java @@ -0,0 +1,113 @@ +package lk.gov.govtech.covid19.controller; + +import lk.gov.govtech.covid19.dto.UpdateStatusRequest; +import lk.gov.govtech.covid19.service.ApplicationService; +import org.json.JSONObject; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import static lk.gov.govtech.covid19.util.Constants.APPLICATION_API_CONTEXT; +import static lk.gov.govtech.covid19.util.Constants.AUTHORITY_NOTIFICATION; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class ApplicationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ApplicationService applicationService; + + @Test + @WithMockUser(authorities = { AUTHORITY_NOTIFICATION }) + public void put_dashboardStatus_success() throws Exception { + JSONObject json = new JSONObject(); + json.put("lk_total_case", 85); + json.put("lk_recovered_case", 85); + json.put("lk_total_deaths", 85); + json.put("lk_total_suspect", 85); + + doNothing().when(applicationService).updateStatus(new UpdateStatusRequest()); + + this.mockMvc.perform(put(APPLICATION_API_CONTEXT + "/dashboard/status") + .contentType(MediaType.APPLICATION_JSON) + .content(json.toString())) + .andExpect(status().isAccepted()); + } + + @Test + @WithMockUser + public void put_dashboardStatus_unauthorized() throws Exception { + JSONObject json = new JSONObject(); + json.put("lk_total_case", 85); + json.put("lk_recovered_case", 85); + json.put("lk_total_deaths", 85); + json.put("lk_total_suspect", 85); + + doNothing().when(applicationService).updateStatus(new UpdateStatusRequest()); + + this.mockMvc.perform(put(APPLICATION_API_CONTEXT + "/dashboard/status") + .contentType(MediaType.APPLICATION_JSON) + .content(json.toString())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = { AUTHORITY_NOTIFICATION }) + public void put_dashboardStatus_noContent() throws Exception { + doNothing().when(applicationService).updateStatus(new UpdateStatusRequest()); + + this.mockMvc.perform(put(APPLICATION_API_CONTEXT + "/dashboard/status") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + this.mockMvc.perform(put(APPLICATION_API_CONTEXT + "/dashboard/status") + .contentType(MediaType.APPLICATION_JSON) + .content("")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = { AUTHORITY_NOTIFICATION }) + public void put_dashboardStatus_missingField() throws Exception { + JSONObject json = new JSONObject(); + //missing field lk_total_case + json.put("lk_recovered_case", 85); + json.put("lk_total_deaths", 85); + json.put("lk_total_suspect", 85); + + doNothing().when(applicationService).updateStatus(new UpdateStatusRequest()); + + this.mockMvc.perform(put(APPLICATION_API_CONTEXT + "/dashboard/status") + .contentType(MediaType.APPLICATION_JSON) + .content(json.toString())) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = { AUTHORITY_NOTIFICATION }) + public void put_dashboardStatus_invalidField() throws Exception { + JSONObject json = new JSONObject(); + json.put("lk_total_case", 1000000000); + json.put("lk_recovered_case", 85); + json.put("lk_total_deaths", 85); + json.put("lk_total_suspect", 85); + + doNothing().when(applicationService).updateStatus(new UpdateStatusRequest()); + + this.mockMvc.perform(put(APPLICATION_API_CONTEXT + "/dashboard/status") + .contentType(MediaType.APPLICATION_JSON) + .content(json.toString())) + .andExpect(status().isBadRequest()); + } + +} diff --git a/backend/src/test/java/lk/gov/govtech/covid19/controller/NotificationControllerTest.java b/backend/src/test/java/lk/gov/govtech/covid19/controller/NotificationControllerTest.java new file mode 100644 index 0000000..61aa830 --- /dev/null +++ b/backend/src/test/java/lk/gov/govtech/covid19/controller/NotificationControllerTest.java @@ -0,0 +1,107 @@ +package lk.gov.govtech.covid19.controller; + +import lk.gov.govtech.covid19.dto.AlertNotificationRequest; +import lk.gov.govtech.covid19.service.NotificationService; +import org.json.JSONObject; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import static lk.gov.govtech.covid19.util.Constants.*; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class NotificationControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + NotificationService notificationService; + + @Test + @WithMockUser(authorities = { AUTHORITY_NOTIFICATION }) + public void post_notificationAlert_success() throws Exception { + JSONObject json = new JSONObject(); + JSONObject title = new JSONObject(); + JSONObject message = new JSONObject(); + + title.put("english", "english title"); + message.put("english", "english message"); + + json.put("source", "the source"); + json.put("title", title); + json.put("message", message); + + doNothing().when(notificationService).addAlertNotificaiton(new AlertNotificationRequest()); + + this.mockMvc.perform(post(NOTIFICATION_API_CONTEXT + "/alert/add") + .contentType(MediaType.APPLICATION_JSON) + .content(json.toString())) + .andExpect(status().isAccepted()); + } + + @Test + @WithMockUser + public void post_notificationAlert_unauthorized() throws Exception { + JSONObject json = new JSONObject(); + JSONObject title = new JSONObject(); + JSONObject message = new JSONObject(); + + title.put("english", "english title"); + message.put("english", "english message"); + + json.put("source", "the source"); + json.put("title", title); + json.put("message", message); + + doNothing().when(notificationService).addAlertNotificaiton(new AlertNotificationRequest()); + + this.mockMvc.perform(post(NOTIFICATION_API_CONTEXT + "/alert/add") + .contentType(MediaType.APPLICATION_JSON) + .content(json.toString())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = { AUTHORITY_NOTIFICATION }) + public void post_notificationAlert_noContent() throws Exception { + doNothing().when(notificationService).addAlertNotificaiton(new AlertNotificationRequest()); + + this.mockMvc.perform(post(NOTIFICATION_API_CONTEXT + "/alert/add") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = { AUTHORITY_NOTIFICATION }) + public void post_notificationAlert_missingField() throws Exception { + JSONObject json = new JSONObject(); + JSONObject title = new JSONObject(); + JSONObject message = new JSONObject(); + + doNothing().when(notificationService).addAlertNotificaiton(new AlertNotificationRequest()); + + message.put("english", "english message"); + json.put("source", "the source"); + //missing field - title + json.put("message", message); + this.mockMvc.perform(post(NOTIFICATION_API_CONTEXT + "/alert/add") + .contentType(MediaType.APPLICATION_JSON) + .content(json.toString())) + .andExpect(status().isBadRequest()); + + json.put("title", title); //title is an empty json + this.mockMvc.perform(post(NOTIFICATION_API_CONTEXT + "/alert/add") + .contentType(MediaType.APPLICATION_JSON) + .content(json.toString())) + .andExpect(status().isBadRequest()); + } +} diff --git a/portal/public/index.html b/portal/public/index.html index af3e6c5..928948b 100644 --- a/portal/public/index.html +++ b/portal/public/index.html @@ -22,7 +22,6 @@ MyHealth Sri Lanka Management Portal-login - diff --git a/portal/src/api/index.js b/portal/src/api/index.js index 76bd873..2983ea1 100644 --- a/portal/src/api/index.js +++ b/portal/src/api/index.js @@ -44,4 +44,15 @@ export default { } }) }, + postMultipartFDWithToken(url, data) { + return axios({ + method: 'post', + url: '/api' + url, + data: data, + headers: { + 'content-type': 'multipart/form-data', + 'x-auth-token': store.getters['user/getToken'] + } + }) + }, } \ No newline at end of file diff --git a/portal/src/components/views/news/News.vue b/portal/src/components/views/news/News.vue index d4d39f3..69ccecc 100644 --- a/portal/src/components/views/news/News.vue +++ b/portal/src/components/views/news/News.vue @@ -28,11 +28,11 @@ - +
-

Title is required

@@ -41,166 +41,20 @@
+
- -
- - - - - -
-
-

{{ charcount.englishChar }} characters

-
-
-

English Alerts is required

-

English Alerts must have at most 2500 characters

-
+ +
-

+
@@ -217,157 +71,14 @@
- - - - - - -
-

{{ charcount.sinhalaChar }} characters

-
+ +
-

+
@@ -383,164 +94,19 @@
- - - - - -
-

{{ charcount.tamilChar }} characters

-
-
-

Tamil Alerts must have at most 2500 characters

-
+ +
-
+ -
-
diff --git a/portal/src/components/views/news/NewsEditor.vue b/portal/src/components/views/news/NewsEditor.vue new file mode 100644 index 0000000..6f2a4fc --- /dev/null +++ b/portal/src/components/views/news/NewsEditor.vue @@ -0,0 +1,134 @@ + + + \ No newline at end of file diff --git a/portal/src/components/views/news/news.js b/portal/src/components/views/news/news.js index 0dd38ec..6520911 100644 --- a/portal/src/components/views/news/news.js +++ b/portal/src/components/views/news/news.js @@ -1,130 +1,22 @@ import Vue from 'vue' +import NewsEditor from './NewsEditor' import { required, maxLength } from 'vuelidate/lib/validators'; -import { Editor, EditorContent ,EditorMenuBar } from 'tiptap'; -import { - Blockquote, - HardBreak, - Heading, - HorizontalRule, - OrderedList, - BulletList, - ListItem, - Bold, - Italic, - Link, - Strike, - Underline, - History, -} from 'tiptap-extensions' import api from '../../../api' export default { name: 'News', components: { - EditorContent, - EditorMenuBar, + NewsEditor }, data() { return { - submitStatus: false, - english: new Editor({ - extensions: [ - new Blockquote(), - new BulletList(), - new HardBreak(), - new Heading({levels: [1, 2, 3]}), - new HorizontalRule(), - new ListItem(), - new OrderedList(), - new Link(), - new Bold(), - new Italic(), - new Strike(), - new Underline(), - new History(), - ], - onUpdate: ({getHTML}) => { - this.message.english = getHTML(); - this.englishChar(); - }, - }), - - sinhala: new Editor({ - extensions: [ - new Blockquote(), - new BulletList(), - new HardBreak(), - new Heading({levels: [1, 2, 3]}), - new HorizontalRule(), - new ListItem(), - new OrderedList(), - new Link(), - new Bold(), - new Italic(), - new Strike(), - new Underline(), - new History(), - ], - onUpdate: ({getHTML}) => { - this.message.sinhala = getHTML(); - this.sinhalaChar(); - }, - }), - - tamil: new Editor({ - extensions: [ - new Blockquote(), - new BulletList(), - new HardBreak(), - new Heading({levels: [1, 2, 3]}), - new HorizontalRule(), - new ListItem(), - new OrderedList(), - new Link(), - new Bold(), - new Italic(), - new Strike(), - new Underline(), - new History(), - ], - onUpdate: ({getHTML}) => { - this.message.tamil = getHTML(); - this.tamilChar(); - }, - }), - - buttons:{ - "bold":"Bold", - "italic":"Italic", - "strike":"Strike", - "underline":"Underline", - "paragraph":"Paragraph", - "H1":"H1", - "H2":"H2", - "H3":"H3", - "order_list":"Order List", - "bullet_list":"Bullet List", - "redo":"Redo", - "undo":"UnDo", - }, - "source":'', - - title:{ - "english":"", - "sinhala":"", - "tamil":"", - }, - - message:{ - "english":"", - "sinhala":"", - "tamil":"", - }, - charcount:{ - "englishChar":0, - "sinhalaChar":0, - "tamilChar":0, + submitBtnDisable: false, + source: "", + title: { + english: "", + sinhala: "", + tamil: "", }, } }, @@ -145,38 +37,35 @@ export default { maxLength: maxLength(100) }, }, - message:{ - english:{ - required, - maxLength: maxLength(2500) - }, - sinhala:{ - maxLength: maxLength(2500) - }, - tamil:{ - maxLength: maxLength(2500) - }, - }, }, methods:{ saveAlerts(){ this.$v.$touch(); - if (this.$v.$invalid){ + if (this.$v.$invalid + || this.$refs.englishEditor.isInvalid() + || this.$refs.sinhalaEditor.isInvalid() + || this.$refs.tamilEditor.isInvalid()) { return - }else{ - this.submitStatus = true; + } else { + this.submitBtnDisable = true; + + let sinhalaMessageLength = this.$refs.sinhalaEditor.message.length; + let sinhalaMessage = sinhalaMessageLength>7? this.$refs.sinhalaEditor.message : ""; //avoid empty paragraph + let tamilMessageLength = this.$refs.tamilEditor.message.length; + let tamilMessage = tamilMessageLength>7? this.$refs.tamilEditor.message : ""; //avoid empty paragraph + api.postJsonWithToken('/notification/alert/add',{ - "source":this.source, - title:{ - "english":this.title.english, - "sinhala":this.title.sinhala, - "tamil":this.title.tamil, + source: this.source, + title: { + english: this.title.english, + sinhala: this.title.sinhala, + tamil: this.title.tamil, }, message:{ - "english":this.message.english, - "sinhala":this.message.sinhala, - "tamil":this.message.tamil, + english: this.$refs.englishEditor.message, + sinhala: sinhalaMessage, + tamil: tamilMessage, } } ).then(response=>{ @@ -185,86 +74,16 @@ export default { title: 'New Alert Was Created', icon: 'success' }); - this.source =''; - this.title.english=''; - this.title.sinhala=''; - this.title.tamil=''; - this.message.english=''; - this.message.sinhala=''; - this.message.tamil=''; - this.charcount.sinhalaChar =0; - this.charcount.englishChar =0; - this.charcount.tamilChar =0; - this.submitStatus = false; - this.$v.$reset(); - this.english.destroy(); - this.sinhala.destroy(); - this.tamil.destroy(); - this.english =new Editor({ - extensions: [ - new Blockquote(), - new BulletList(), - new HardBreak(), - new Heading({levels: [1, 2, 3]}), - new HorizontalRule(), - new ListItem(), - new OrderedList(), - new Link(), - new Bold(), - new Italic(), - new Strike(), - new Underline(), - new History(), - ], - onUpdate: ({getHTML}) => { - this.message.english = getHTML(); - this.englishChar(); - }, - }); - this.sinhala =new Editor({ - extensions: [ - new Blockquote(), - new BulletList(), - new HardBreak(), - new Heading({levels: [1, 2, 3]}), - new HorizontalRule(), - new ListItem(), - new OrderedList(), - new Link(), - new Bold(), - new Italic(), - new Strike(), - new Underline(), - new History(), - ], - onUpdate: ({getHTML}) => { - this.message.english = getHTML(); - this.englishChar(); - }, - }); - this.tamil =new Editor({ - extensions: [ - new Blockquote(), - new BulletList(), - new HardBreak(), - new Heading({levels: [1, 2, 3]}), - new HorizontalRule(), - new ListItem(), - new OrderedList(), - new Link(), - new Bold(), - new Italic(), - new Strike(), - new Underline(), - new History(), - ], - onUpdate: ({getHTML}) => { - this.message.english = getHTML(); - this.englishChar(); - }, - }); - + this.source =''; + this.title.english=''; + this.title.sinhala=''; + this.title.tamil=''; + this.$v.$reset(); + this.$refs.englishEditor.clearContent(); + this.$refs.sinhalaEditor.clearContent(); + this.$refs.tamilEditor.clearContent(); } + this.submitBtnDisable = false; }).catch(error =>{ Vue.swal({ title: 'Something Went Wrong!', @@ -273,22 +92,9 @@ export default { if (error.response) { console.log(error.response.status); } - this.submitStatus =false; - }) + this.submitBtnDisable =false; + }); } - - }, - sinhalaChar() - { - this.charcount.sinhalaChar = (this.message.sinhala.length)-7; }, - englishChar() - { - this.charcount.englishChar = (this.message.english.length)-7; - }, - tamilChar() - { - this.charcount.tamilChar = (this.message.tamil.length)-7; - } } } diff --git a/portal/src/components/views/news/newsEditor.js b/portal/src/components/views/news/newsEditor.js new file mode 100644 index 0000000..378c263 --- /dev/null +++ b/portal/src/components/views/news/newsEditor.js @@ -0,0 +1,151 @@ +import Vue from 'vue'; +import { maxLength } from 'vuelidate/lib/validators'; +import { Editor, EditorContent, EditorMenuBar } from 'tiptap'; +import { + Blockquote, + HardBreak, + Heading, + HorizontalRule, + OrderedList, + BulletList, + ListItem, + Bold, + Italic, + Link, + Strike, + Underline, + History, + Image, +} from 'tiptap-extensions' +import api from '../../../api'; + +export default { + name: 'NewsEditor', + components: { + EditorContent, + EditorMenuBar, + }, + props: ['isEnglish','requiredErrorMessage', 'maxLengthErrorMessage'], + computed: { + isRequired: function () { + return this.isEnglish==""; + } + }, + data() { + return { + imageBtnDisable: false, + message: "", + newsEditor: new Editor({ + extensions: [ + new Blockquote(), + new BulletList(), + new HardBreak(), + new Heading({ levels: [1, 2, 3] }), + new HorizontalRule(), + new ListItem(), + new OrderedList(), + new Link(), + new Bold(), + new Italic(), + new Strike(), + new Underline(), + new History(), + new Image(), + ], + onUpdate: ({ getHTML }) => { + this.message = getHTML(); + this.$v.message.$touch(); + }, + }), + buttons: { + bold: "Bold", + italic: "Italic", + strike: "Strike", + underline: "Underline", + paragraph: "Paragraph", + H1: "H1", + H2: "H2", + H3: "H3", + order_list: "Numbered List", + bullet_list: "Bullet List", + redo: "Redo", + undo: "UnDo", + image: "Image", + }, + } + }, + validations: { + message: { + required: (function () { + if(this.isRequired) { + return this.message.length > 7; // atleast one letter -->

a

+ } else { + return true; // no validations here + } + }), + maxLength: maxLength(2500) + }, + }, + methods: { + clearContent() { + this.newsEditor.clearContent(true); + this.$v.$reset(); + }, + isInvalid() { + if (this.isRequired) { + this.$v.$touch(); //sets $dirty in vualidate even if the field is not touched by the user + } + return this.$v.$invalid; + }, + showImagePrompt(commands) { + this.imageBtnDisable = true; + let inputElement = document.createElement("input"); + inputElement.setAttribute("type", "file"); + inputElement.addEventListener("change", (e) => { + if (e.target.files && e.target.files[0]) { + let anImage = e.target.files[0]; // file from input + + let formData = new FormData(); + formData.append("image", anImage, anImage.name); + api.postMultipartFDWithToken( + '/images', + formData + ).then(response => { + if (response.status == 202 && response.data.url.length > 0) { + const src = response.data.url; + commands.image({ src }); + this.imageBtnDisable = false; + } + }).catch(error => { + if (error.response.status == 400) { + Vue.swal.mixin({ + position: 'top-end', + showConfirmButton: false, + showCancelButton:true, + }).fire({ + title: "Invalid image type, size or name", + text: "Image must be of type jpg. Image size must be smaller than 200KB. Image name can only contain letters, numbers, dashes, underscores and spaces", + icon: "error", + }); + } else { + Vue.swal.mixin({ + toast: true, + position: 'top-end', + showConfirmButton: false, + timer: 2000, + }).fire({ + title: "Error uploading image", + icon: "error", + }); + } + this.submitBtnDisable = false; + }); + } + }) + inputElement.click(); + }, + beforeDestroy() { + this.newsEditor.destroy(); + }, + } +}; \ No newline at end of file diff --git a/postman/COVID-19.postman_collection.json b/postman/COVID-19.postman_collection.json index d4c88b4..3cdbf13 100644 --- a/postman/COVID-19.postman_collection.json +++ b/postman/COVID-19.postman_collection.json @@ -331,15 +331,15 @@ "value": "attachment.jpg" }, "url": { - "raw": "http://localhost:8000/images/add", + "raw": "http://localhost:8000/api/images", "protocol": "http", "host": [ "localhost" ], "port": "8000", "path": [ - "images", - "add" + "api", + "images" ] } }, @@ -351,22 +351,22 @@ "method": "GET", "header": [], "url": { - "raw": "http://localhost:8000/images/image/4", + "raw": "http://localhost:8000/api/images/image/4", "protocol": "http", "host": [ "localhost" ], "port": "8000", "path": [ + "api", "images", "image", - 4 + "4" ] }, - "description": "GET http://localhost:8000/images/image/" + "description": "GET http://localhost:8000/api/images/image/" }, "response": [] } - ], - "protocolProfileBehavior": {} + ] } \ No newline at end of file