diff --git a/.github/workflows/gradle-build-production.yml b/.github/workflows/gradle-build-production.yml index 64ab7c4302..02e3b4236f 100644 --- a/.github/workflows/gradle-build-production.yml +++ b/.github/workflows/gradle-build-production.yml @@ -87,6 +87,8 @@ jobs: --set-env-vars "^@^MICRONAUT_ENVIRONMENTS=cloud,google,gcp" \ --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ + --set-env-vars "SLACK_PULSE_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ + --set-env-vars "SLACK_PULSE_BOT_TOKEN=${{ secrets.SLACK_PULSE_BOT_TOKEN }}" \ --platform "managed" \ --max-instances 8 \ --allow-unauthenticated diff --git a/.github/workflows/gradle-deploy-develop.yml b/.github/workflows/gradle-deploy-develop.yml index ba2dddfc47..dde66083c0 100644 --- a/.github/workflows/gradle-deploy-develop.yml +++ b/.github/workflows/gradle-deploy-develop.yml @@ -112,6 +112,8 @@ jobs: --set-env-vars "^@^MICRONAUT_ENVIRONMENTS=dev,cloud,google,gcp" \ --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ + --set-env-vars "SLACK_PULSE_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ + --set-env-vars "SLACK_PULSE_BOT_TOKEN=${{ secrets.SLACK_PULSE_BOT_TOKEN }}" \ --platform "managed" \ --max-instances 2 \ --allow-unauthenticated diff --git a/.github/workflows/gradle-deploy-native-develop.yml b/.github/workflows/gradle-deploy-native-develop.yml index cb40d97f93..802d2431bf 100644 --- a/.github/workflows/gradle-deploy-native-develop.yml +++ b/.github/workflows/gradle-deploy-native-develop.yml @@ -111,6 +111,8 @@ jobs: --set-env-vars "^@^MICRONAUT_ENVIRONMENTS=dev,cloud,google,gcp" \ --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ + --set-env-vars "SLACK_PULSE_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ + --set-env-vars "SLACK_PULSE_BOT_TOKEN=${{ secrets.SLACK_PULSE_BOT_TOKEN }}" \ --platform "managed" \ --max-instances 2 \ --allow-unauthenticated diff --git a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java index 6c58600b40..f8e8d7f92a 100644 --- a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java +++ b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java @@ -34,6 +34,9 @@ public static class ApplicationConfig { @NotNull private NotificationsConfig notifications; + @NotNull + private PulseResponseConfig pulseResponse; + @Getter @Setter @ConfigurationProperties("feedback") @@ -89,5 +92,25 @@ public static class SlackConfig { private String botToken; } } + + @Getter + @Setter + @ConfigurationProperties("pulse-response") + public static class PulseResponseConfig { + + @NotNull + private SlackConfig slack; + + @Getter + @Setter + @ConfigurationProperties("slack") + public static class SlackConfig { + @NotBlank + private String signingSecret; + + @NotBlank + private String botToken; + } + } } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java index 2170d8ae2e..772a489683 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java @@ -3,5 +3,5 @@ import java.time.LocalDate; public interface PulseServices { - public void sendPendingEmail(LocalDate now); + public void notifyUsers(LocalDate now); } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java index 8f3ec0788c..e19e4d823b 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java @@ -69,7 +69,7 @@ public PulseServicesImpl( this.automatedEmailRepository = automatedEmailRepository; } - public void sendPendingEmail(LocalDate check) { + public void notifyUsers(LocalDate check) { if (check.getDayOfWeek() == emailDay) { LOG.info("Checking for pending Pulse email"); // Start from the first of the year and move forward to ensure that we diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java index 5a1a96167d..21eb967971 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java @@ -1,10 +1,16 @@ package com.objectcomputing.checkins.services.pulseresponse; import com.objectcomputing.checkins.exceptions.NotFoundException; +import com.objectcomputing.checkins.util.form.FormUrlEncodedDecoder; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; + +import io.micronaut.http.MediaType; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.convert.format.Format; +import io.micronaut.http.HttpStatus; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Header; import io.micronaut.http.annotation.Body; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; @@ -14,6 +20,7 @@ import io.micronaut.scheduling.annotation.ExecuteOn; import io.micronaut.security.annotation.Secured; import io.micronaut.security.rules.SecurityRule; + import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -22,17 +29,27 @@ import java.time.LocalDate; import java.util.Set; import java.util.UUID; +import java.util.Map; +import java.nio.charset.StandardCharsets; @Controller("/services/pulse-responses") @ExecuteOn(TaskExecutors.BLOCKING) -@Secured(SecurityRule.IS_AUTHENTICATED) @Tag(name = "pulse-responses") public class PulseResponseController { private final PulseResponseService pulseResponseServices; + private final MemberProfileServices memberProfileServices; + private final SlackSignatureVerifier slackSignatureVerifier; + private final PulseSlackCommand pulseSlackCommand; - public PulseResponseController(PulseResponseService pulseResponseServices) { + public PulseResponseController(PulseResponseService pulseResponseServices, + MemberProfileServices memberProfileServices, + SlackSignatureVerifier slackSignatureVerifier, + PulseSlackCommand pulseSlackCommand) { this.pulseResponseServices = pulseResponseServices; + this.memberProfileServices = memberProfileServices; + this.slackSignatureVerifier = slackSignatureVerifier; + this.pulseSlackCommand = pulseSlackCommand; } /** @@ -43,6 +60,7 @@ public PulseResponseController(PulseResponseService pulseResponseServices) { * @param dateTo * @return */ + @Secured(SecurityRule.IS_AUTHENTICATED) @Get("/{?teamMemberId,dateFrom,dateTo}") public Set findPulseResponses(@Nullable @Format("yyyy-MM-dd") LocalDate dateFrom, @Nullable @Format("yyyy-MM-dd") LocalDate dateTo, @@ -56,6 +74,7 @@ public Set findPulseResponses(@Nullable @Format("yyyy-MM-dd") Loc * @param pulseResponse, {@link PulseResponseCreateDTO} * @return {@link HttpResponse} */ + @Secured(SecurityRule.IS_AUTHENTICATED) @Post public HttpResponse createPulseResponse(@Body @Valid PulseResponseCreateDTO pulseResponse, HttpRequest request) { @@ -70,6 +89,7 @@ public HttpResponse createPulseResponse(@Body @Valid PulseRespons * @param pulseResponse, {@link PulseResponse} * @return {@link HttpResponse} */ + @Secured(SecurityRule.IS_AUTHENTICATED) @Put public HttpResponse update(@Body @Valid @NotNull PulseResponse pulseResponse, HttpRequest request) { @@ -82,6 +102,7 @@ public HttpResponse update(@Body @Valid @NotNull PulseResponse pu * @param id * @return */ + @Secured(SecurityRule.IS_AUTHENTICATED) @Get("/{id}") public PulseResponse readRole(@NotNull UUID id) { PulseResponse result = pulseResponseServices.read(id); @@ -90,4 +111,72 @@ public PulseResponse readRole(@NotNull UUID id) { } return result; } -} \ No newline at end of file + + @Secured(SecurityRule.IS_ANONYMOUS) + @Post(uri = "/command", consumes = MediaType.APPLICATION_FORM_URLENCODED) + public HttpResponse commandPulseResponse( + @Header("X-Slack-Signature") String signature, + @Header("X-Slack-Request-Timestamp") String timestamp, + @Body String requestBody) { + // Validate the request + if (slackSignatureVerifier.verifyRequest(signature, + timestamp, requestBody)) { + // Convert the request body to a map of values. + FormUrlEncodedDecoder formUrlEncodedDecoder = new FormUrlEncodedDecoder(); + Map body = + formUrlEncodedDecoder.decode(requestBody, + StandardCharsets.UTF_8); + + // Respond to the slack command. + String triggerId = (String)body.get("trigger_id"); + if (pulseSlackCommand.send(triggerId)) { + return HttpResponse.ok(); + } else { + return HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return HttpResponse.unauthorized(); + } + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Post("/external") + public HttpResponse externalPulseResponse( + @Header("X-Slack-Signature") String signature, + @Header("X-Slack-Request-Timestamp") String timestamp, + @Body String requestBody, + HttpRequest request) { + // Validate the request + if (slackSignatureVerifier.verifyRequest(signature, + timestamp, requestBody)) { + PulseResponseCreateDTO pulseResponseDTO = + SlackPulseResponseConverter.get(memberProfileServices, + requestBody); + + // Create the pulse response + PulseResponse pulseResponse = pulseResponseServices.unsecureSave( + new PulseResponse( + pulseResponseDTO.getInternalScore(), + pulseResponseDTO.getExternalScore(), + pulseResponseDTO.getSubmissionDate(), + pulseResponseDTO.getTeamMemberId(), + pulseResponseDTO.getInternalFeelings(), + pulseResponseDTO.getExternalFeelings() + ) + ); + + if (pulseResponse == null) { + return HttpResponse.status(HttpStatus.CONFLICT, + "Already submitted today"); + } else { + return HttpResponse.created(pulseResponse) + .headers(headers -> headers.location( + URI.create(String.format("%s/%s", + request.getPath(), + pulseResponse.getId())))); + } + } else { + return HttpResponse.unauthorized(); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java index a2c2ac42dd..2c237a7b95 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotNull; import java.time.LocalDate; +import java.util.Optional; import java.util.List; import java.util.UUID; @@ -14,4 +15,5 @@ public interface PulseResponseRepository extends CrudRepository findByTeamMemberId(@NotNull UUID teamMemberId); List findBySubmissionDateBetween(@NotNull LocalDate dateFrom, @NotNull LocalDate dateTo); -} \ No newline at end of file + Optional getByTeamMemberIdAndSubmissionDate(@NotNull UUID teamMemberId, @NotNull LocalDate submissionDate); +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java index 8b5ec0a01c..69ac07e50b 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java @@ -9,8 +9,9 @@ public interface PulseResponseService { PulseResponse read(UUID id); PulseResponse save(PulseResponse pulseResponse); + PulseResponse unsecureSave(PulseResponse pulseResponse); PulseResponse update(PulseResponse pulseResponse); Set findByFields(UUID teamMemberId, LocalDate dateFrom, LocalDate dateTo); -} \ No newline at end of file +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java index f6fbcfcfd1..c525c9845e 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java @@ -48,25 +48,61 @@ public PulseResponseServicesImpl( @Override public PulseResponse save(PulseResponse pulseResponse) { - UUID currentUserId = currentUserServices.getCurrentUser().getId(); + if (pulseResponse != null) { + verifyPulseData(pulseResponse); + final UUID memberId = pulseResponse.getTeamMemberId(); + UUID currentUserId = currentUserServices.getCurrentUser().getId(); + if (memberId != null && + !currentUserId.equals(memberId) && + !isSubordinateTo(memberId, currentUserId)) { + throw new BadArgException( + String.format("User %s does not have permission to create pulse response for user %s", + currentUserId, memberId)); + } + return saveCommon(pulseResponse); + } else { + return null; + } + } + + @Override + public PulseResponse unsecureSave(PulseResponse pulseResponse) { PulseResponse pulseResponseRet = null; if (pulseResponse != null) { + // External users could submit a pulse resonse multiple times. We + // need to check to see if this user has already submitted one + // today. + boolean submitted = false; final UUID memberId = pulseResponse.getTeamMemberId(); - LocalDate pulseSubDate = pulseResponse.getSubmissionDate(); - if (pulseResponse.getId() != null) { - throw new BadArgException(String.format("Found unexpected id for pulseresponse %s", pulseResponse.getId())); - } else if (memberId != null && - memberRepo.findById(memberId).isEmpty()) { - throw new BadArgException(String.format("Member %s doesn't exists", memberId)); - } else if (pulseSubDate.isBefore(LocalDate.EPOCH) || pulseSubDate.isAfter(LocalDate.MAX)) { - throw new BadArgException(String.format("Invalid date for pulseresponse submission date %s", memberId)); - } else if (memberId != null && - !currentUserId.equals(memberId) && - !isSubordinateTo(memberId, currentUserId)) { - throw new BadArgException(String.format("User %s does not have permission to create pulse response for user %s", currentUserId, memberId)); + if (memberId != null) { + Optional existing = + pulseResponseRepo.getByTeamMemberIdAndSubmissionDate( + memberId, pulseResponse.getSubmissionDate()); + submitted = existing.isPresent(); + } + if (!submitted) { + verifyPulseData(pulseResponse); + return saveCommon(pulseResponse); } - pulseResponseRet = pulseResponseRepo.save(pulseResponse); } + return null; + } + + private void verifyPulseData(PulseResponse pulseResponse) { + final UUID memberId = pulseResponse.getTeamMemberId(); + LocalDate pulseSubDate = pulseResponse.getSubmissionDate(); + if (pulseResponse.getId() != null) { + throw new BadArgException(String.format("Found unexpected id for pulseresponse %s", pulseResponse.getId())); + } else if (memberId != null && + memberRepo.findById(memberId).isEmpty()) { + throw new BadArgException(String.format("Member %s doesn't exists", memberId)); + } else if (pulseSubDate.isBefore(LocalDate.EPOCH) || pulseSubDate.isAfter(LocalDate.MAX)) { + throw new BadArgException(String.format("Invalid date for pulseresponse submission date %s", memberId)); + } + } + + private PulseResponse saveCommon(PulseResponse pulseResponse) { + PulseResponse pulseResponseRet = pulseResponseRepo.save(pulseResponse); // Send low pulse survey score if appropriate sendPulseLowScoreEmail(pulseResponseRet); diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java new file mode 100644 index 0000000000..439de89da1 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java @@ -0,0 +1,77 @@ +package com.objectcomputing.checkins.services.pulseresponse; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.request.views.ViewsOpenRequest; +import com.slack.api.methods.response.views.ViewsOpenResponse; + +import io.micronaut.context.annotation.Value; +import io.micronaut.core.io.Readable; +import io.micronaut.core.io.IOUtils; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.util.List; + +@Singleton +public class PulseSlackCommand { + private static final Logger LOG = LoggerFactory.getLogger(PulseSlackCommand.class); + + @Inject + private CheckInsConfiguration configuration; + + @Value("classpath:slack/pulse_slack_blocks.json") + private Readable pulseSlackBlocks; + + public boolean send(String triggerId) { + String slackBlocks = getSlackBlocks(); + + // See if we have a token. + String token = configuration.getApplication() + .getPulseResponse() + .getSlack().getBotToken(); + if (token != null && !slackBlocks.isEmpty()) { + MethodsClient client = Slack.getInstance().methods(token); + + try { + ViewsOpenRequest request = ViewsOpenRequest.builder() + .triggerId(triggerId) + .viewAsString(slackBlocks) + .build(); + + // Send it to Slack + ViewsOpenResponse response = client.viewsOpen(request); + + if (!response.isOk()) { + LOG.error("Unable to open the Pulse view"); + } + + return response.isOk(); + } catch(Exception ex) { + LOG.error(ex.toString()); + return false; + } + } else { + LOG.error("Missing token or missing slack blocks"); + return false; + } + } + + private String getSlackBlocks() { + try { + return IOUtils.readText( + new BufferedReader(pulseSlackBlocks.asReader())); + } catch(Exception ex) { + LOG.error(ex.toString()); + return ""; + } + } +} + diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java new file mode 100644 index 0000000000..42dd436799 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java @@ -0,0 +1,91 @@ +package com.objectcomputing.checkins.services.pulseresponse; + +import com.objectcomputing.checkins.exceptions.BadArgException; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.util.Map; +import java.util.UUID; +import java.time.LocalDate; + +public class SlackPulseResponseConverter { + public static PulseResponseCreateDTO get( + MemberProfileServices memberProfileServices, String body) { + final String key = "payload="; + final int start = body.indexOf(key); + if (start >= 0) { + try { + // Get the map of values from the string body + final ObjectMapper mapper = new ObjectMapper(); + final Map map = + mapper.readValue(body.substring(start + key.length()), + new TypeReference<>() {}); + final Map view = + (Map)map.get("view"); + final Map state = + (Map)view.get("state"); + final Map values = + (Map)state.get("values"); + + // Create the pulse DTO and fill in the values. + PulseResponseCreateDTO response = new PulseResponseCreateDTO(); + response.setTeamMemberId(lookupUser(memberProfileServices, map)); + response.setSubmissionDate(LocalDate.now()); + + response.setInternalScore(Integer.parseInt( + getMappedValue(values, "internalScore", true))); + response.setInternalFeelings( + getMappedValue(values, "internalFeelings", false)); + + String score = getMappedValue(values, "externalScore", false); + if (!score.isEmpty()) { + response.setExternalScore(Integer.parseInt(score)); + } + response.setExternalFeelings( + getMappedValue(values, "externalFeelings", false)); + + return response; + } catch(JsonProcessingException ex) { + throw new BadArgException(ex.getMessage()); + } catch(NumberFormatException ex) { + throw new BadArgException("Pulse scores must be integers"); + } + } else { + throw new BadArgException("Invalid pulse response body"); + } + } + + private static String getMappedValue(Map map, + String key, boolean required) { + final String valueKey = "value"; + if (map.containsKey(key)) { + final Map other = (Map)map.get(key); + if (other.containsKey(valueKey)) { + return (String)other.get(valueKey); + } + } + + if (required) { + throw new BadArgException( + String.format("Expected %s.%s was not found", key, valueKey)); + } else { + return ""; + } + } + + private static UUID lookupUser(MemberProfileServices memberProfileServices, + Map map) { + // Get the user's profile map. + Map user = (Map)map.get("user"); + Map profile = (Map)user.get("profile"); + + // Lookup the user based on the email address. + String email = (String)profile.get("email"); + MemberProfile member = memberProfileServices.findByWorkEmail(email); + return member.getId(); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java new file mode 100644 index 0000000000..c9be9ed274 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java @@ -0,0 +1,68 @@ +package com.objectcomputing.checkins.services.pulseresponse; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.time.Instant; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +@Singleton +public class SlackSignatureVerifier { + @Inject + private CheckInsConfiguration configuration; + + public SlackSignatureVerifier() {} + + public boolean verifyRequest(String slackSignature, String timestamp, String requestBody) { + try { + // Prevent replay attacks by checking the timestamp + if (!isRecentRequest(timestamp)) { + return false; + } + + // Create the base string + String baseString = "v0:" + timestamp + ":" + requestBody; + + // Generate HMAC-SHA256 signature + String secret = configuration.getApplication() + .getPulseResponse() + .getSlack().getSigningSecret(); + String computedSignature = "v0=" + hmacSha256(secret, baseString); + + // Compare the computed signature with Slack's signature + return computedSignature.equals(slackSignature); + } catch (Exception e) { + return false; + } + } + + private boolean isRecentRequest(String timestamp) { + long currentTime = Instant.now().getEpochSecond(); + long requestTime = Long.parseLong(timestamp); + return Math.abs(currentTime - requestTime) < 60; + } + + private String hmacSha256(String secret, String message) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + + // Convert hash to hex + StringBuilder hexString = new StringBuilder(); + byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java index 49cb86f041..baba523e35 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java @@ -41,7 +41,7 @@ public boolean sendScheduledEmails() { req.setStatus("sent"); feedbackRequestRepository.update(req); } - pulseServices.sendPendingEmail(today); + pulseServices.notifyUsers(today); reviewPeriodServices.sendNotifications(today); return true; } diff --git a/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java b/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java new file mode 100644 index 0000000000..f103b3a6e4 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java @@ -0,0 +1,22 @@ +package com.objectcomputing.checkins.util.form; + +import java.nio.charset.Charset; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; + +public class FormUrlEncodedDecoder { + public Map decode(String formUrlEncodedString, Charset charset) { + Map queryParams = new HashMap<>(); + String[] pairs = formUrlEncodedString.split("&"); + for (String pair : pairs) { + int idx = pair.indexOf("="); + if (idx > 0) { + String key = URLDecoder.decode(pair.substring(0, idx), charset); + String value = URLDecoder.decode(pair.substring(idx + 1), charset); + queryParams.put(key, value); + } + } + return queryParams; + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 99148dcede..72d6115189 100755 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -28,7 +28,7 @@ micronaut: enabled: true mapping: "/**" paths: - - "classpath:public" + - "classpath:public" executors: io: @@ -101,6 +101,10 @@ check-ins: slack: webhook-url: ${ SLACK_WEBHOOK_URL } bot-token: ${ SLACK_BOT_TOKEN } + pulse-response: + slack: + signing-secret: ${ SLACK_PULSE_SIGNING_SECRET } + bot-token: ${ SLACK_PULSE_BOT_TOKEN } web-address: ${ WEB_ADDRESS } --- flyway: diff --git a/server/src/main/resources/slack/README.md b/server/src/main/resources/slack/README.md new file mode 100644 index 0000000000..8313956e7b --- /dev/null +++ b/server/src/main/resources/slack/README.md @@ -0,0 +1,10 @@ +# Slack Blocks + +Place Slack Blocks jSON here. + +### Micronaut Usage + +```java +@Value("classpath:slack/filename.json") +private Readable slackBlocks; +``` diff --git a/server/src/main/resources/slack/pulse_slack_blocks.json b/server/src/main/resources/slack/pulse_slack_blocks.json new file mode 100644 index 0000000000..bdfd32a132 --- /dev/null +++ b/server/src/main/resources/slack/pulse_slack_blocks.json @@ -0,0 +1,158 @@ +{ + "type": "modal", + "title": { + "type": "plain_text", + "text": "Check-Ins Pulse", + "emoji": true + }, + "submit": { + "type": "plain_text", + "text": "Submit", + "emoji": true + }, + "close": { + "type": "plain_text", + "text": "Cancel", + "emoji": true + }, + "blocks": [ + { + "type": "section", + "text": { + "type": "plain_text", + "text": "How are you feeling about work today?" + }, + "accessory": { + "type": "radio_buttons", + "action_id": "internalScore", + "options": [ + { + "value": "1", + "text": { + "type": "plain_text", + "text": "😦", + "emoji": true + } + }, + { + "value": "2", + "text": { + "type": "plain_text", + "text": "🙁", + "emoji": true + } + }, + { + "value": "3", + "text": { + "type": "plain_text", + "text": "😐", + "emoji": true + } + }, + { + "value": "4", + "text": { + "type": "plain_text", + "text": "🙂", + "emoji": true + } + }, + { + "value": "5", + "text": { + "type": "plain_text", + "text": "😀", + "emoji": true + } + } + ] + } + }, + { + "type": "input", + "element": { + "type": "plain_text_input", + "action_id": "internalFeelings", + "placeholder": { + "type": "plain_text", + "text": "Comment" + } + }, + "label": { + "type": "plain_text", + "text": "Comment", + "emoji": true + } + }, + { + "type": "section", + "text": { + "type": "plain_text", + "text": "How are you feeling about life outside of work?" + }, + "accessory": { + "type": "radio_buttons", + "action_id": "externalScore", + "options": [ + { + "value": "1", + "text": { + "type": "plain_text", + "text": "😦", + "emoji": true + } + }, + { + "value": "2", + "text": { + "type": "plain_text", + "text": "🙁", + "emoji": true + } + }, + { + "value": "3", + "text": { + "type": "plain_text", + "text": "😐", + "emoji": true + } + }, + { + "value": "4", + "text": { + "type": "plain_text", + "text": "🙂", + "emoji": true + } + }, + { + "value": "5", + "text": { + "type": "plain_text", + "text": "😀", + "emoji": true + } + } + ] + } + }, + { + "type": "input", + "element": { + "type": "plain_text_input", + "action_id": "externalFeelings", + "placeholder": { + "type": "plain_text", + "text": "Comment" + } + }, + "label": { + "type": "plain_text", + "text": "Comment", + "emoji": true + } + } + ] +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java b/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java index f3de9f012f..540257568d 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java @@ -97,7 +97,7 @@ void testBiWeeklySendEmail() { final Setting setting = new Setting(pulseSettingName, pulseBiWeekly); settingsServices.save(setting); - pulseServices.sendPendingEmail(biWeeklyDate); + pulseServices.notifyUsers(biWeeklyDate); assertEquals(1, emailSender.events.size()); EmailHelper.validateEmail("SEND_EMAIL", "null", "null", @@ -112,7 +112,7 @@ void testWeeklySendEmail() { final Setting setting = new Setting(pulseSettingName, pulseWeekly); settingsServices.save(setting); - pulseServices.sendPendingEmail(weeklyDate); + pulseServices.notifyUsers(weeklyDate); assertEquals(1, emailSender.events.size()); EmailHelper.validateEmail("SEND_EMAIL", "null", "null", @@ -127,7 +127,7 @@ void testMonthlySendEmail() { final Setting setting = new Setting(pulseSettingName, pulseMonthly); settingsServices.save(setting); - pulseServices.sendPendingEmail(monthlyDate); + pulseServices.notifyUsers(monthlyDate); assertEquals(1, emailSender.events.size()); EmailHelper.validateEmail("SEND_EMAIL", "null", "null", @@ -142,7 +142,7 @@ void testDuplicateSendEmail() { final Setting setting = new Setting(pulseSettingName, pulseMonthly); settingsServices.save(setting); - pulseServices.sendPendingEmail(monthlyDate); + pulseServices.notifyUsers(monthlyDate); // This should be zero because email was already sent on this date. assertEquals(0, emailSender.events.size()); } @@ -152,13 +152,13 @@ void testNoSendEmail() { final Setting setting = new Setting(pulseSettingName, pulseBiWeekly); settingsServices.save(setting); - pulseServices.sendPendingEmail(weeklyDate); + pulseServices.notifyUsers(weeklyDate); // This should be zero because, when set to bi-weekly, email is sent on // the first, third, and fifth Monday of the month. assertEquals(0, emailSender.events.size()); final LocalDate nonMonday = weeklyDate.plus(1, ChronoUnit.DAYS); - pulseServices.sendPendingEmail(nonMonday); + pulseServices.notifyUsers(nonMonday); // This should be zero because the date is not a Monday. assertEquals(0, emailSender.events.size()); } diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml index c6fca9bd81..c5aca006c0 100644 --- a/server/src/test/resources/application-test.yml +++ b/server/src/test/resources/application-test.yml @@ -45,9 +45,13 @@ check-ins: slack: webhook-url: https://bogus.objectcomputing.com/slack bot-token: BOGUS_TOKEN + pulse-response: + slack: + signing-secret: BOGUS_SIGNING_SECRET + bot-token: BOGUS_TOKEN --- aes: key: BOGUS_TEST_KEY --- github-credentials: - github_token: "test github token" \ No newline at end of file + github_token: "test github token" diff --git a/web-ui/src/pages/PulseReportPage.jsx b/web-ui/src/pages/PulseReportPage.jsx index 73355b17f0..83d4ef0b76 100644 --- a/web-ui/src/pages/PulseReportPage.jsx +++ b/web-ui/src/pages/PulseReportPage.jsx @@ -328,7 +328,13 @@ const PulseReportPage = () => { }); if (res.error) return; - const pulses = res.payload.data; + // Get the pulses and filter out invalid data. + const pulses = res.payload.data.filter((pulse) => { + return pulse.internalScore > 0 && pulse.internalScore <= 5 && + (pulse.externalScore == null || + (pulse.externalScore > 0 && pulse.externalScore <= 5)); + }); + // Sort the pulses on their submission date. pulses.sort((p1, p2) => { const [year1, month1, day1] = p1.submissionDate;