diff --git a/pom.xml b/pom.xml index 171ab162..11ad9f37 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common-API common-api - 3.6.1 + 3.6.0 war Common-API diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index a6451109..0184b32f 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -192,4 +192,15 @@ cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ video-call-url=@env.VIDEO_CALL_URL@ jibri.output.path=@env.JIBRI_OUTPUT_PATH@ video.recording.path=@env.VIDEO_RECORDING_PATH@ + +platform.feedback.ratelimit.enabled=@env.PLATFORM_FEEDBACK_RATELIMIT_ENABLED@ +platform.feedback.ratelimit.pepper=@env.PLATFORM_FEEDBACK_RATELIMIT_PEPPER@ +platform.feedback.ratelimit.trust-forwarded-for=@env.PLATFORM_FEEDBACK_RATELIMIT_TRUST_FORWARDED_FOR@ +platform.feedback.ratelimit.forwarded-for-header=@env.PLATFORM_FEEDBACK_RATELIMIT_FORWARDED_FOR_HEADER@ +platform.feedback.ratelimit.minute-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_MINUTE_LIMIT@ +platform.feedback.ratelimit.day-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_DAY_LIMIT@ +platform.feedback.ratelimit.user-day-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_USER_DAY_LIMIT@ +platform.feedback.ratelimit.fail-window-minutes=@env.PLATFORM_FEEDBACK_RATELIMIT_FAIL_WINDOW_MINUTES@ +platform.feedback.ratelimit.backoff-minutes=@env.PLATFORM_FEEDBACK_RATELIMIT_BACKOFF_MINUTES@ generateBeneficiaryIDs-api-url=@env.GEN_BENEFICIARY_IDS_API_URL@ + diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 1035af57..a81ea62e 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -195,4 +195,15 @@ firebase.credential-file=${FIREBASE_CREDENTIAL} video-call-url=${VIDEO_CALL_URL} jibri.output.path={JIBRI_OUTPUT_PATH} video.recording.path={VIDEO_RECORDING_PATH} + +# Platform Feedback module +platform.feedback.ratelimit.enabled=${PLATFORM_FEEDBACK_RATELIMIT_ENABLED} +platform.feedback.ratelimit.pepper=${PLATFORM_FEEDBACK_RATELIMIT_PEPPER} +platform.feedback.ratelimit.trust-forwarded-for=${PLATFORM_FEEDBACK_RATELIMIT_TRUST_FORWARDED_FOR} +platform.feedback.ratelimit.forwarded-for-header=${PLATFORM_FEEDBACK_RATELIMIT_FORWARDED_FOR_HEADER} +platform.feedback.ratelimit.minute-limit=${PLATFORM_FEEDBACK_RATELIMIT_MINUTE_LIMIT} +platform.feedback.ratelimit.day-limit=${PLATFORM_FEEDBACK_RATELIMIT_DAY_LIMIT} +platform.feedback.ratelimit.user-day-limit=${PLATFORM_FEEDBACK_RATELIMIT_USER_DAY_LIMIT} +platform.feedback.ratelimit.fail-window-minutes=${PLATFORM_FEEDBACK_RATELIMIT_FAIL_WINDOW_MINUTES} +platform.feedback.ratelimit.backoff-minutes=${PLATFORM_FEEDBACK_RATELIMIT_BACKOFF_MINUTES} generateBeneficiaryIDs-api-url={GEN_BENEFICIARY_IDS_API_URL} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index bea76523..aca73ddb 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -208,5 +208,21 @@ captcha.enable-captcha=true cors.allowed-origins=http://localhost:* +# ---Platform Feedback module --- +# Rate limiter OFF locally (no Redis required) +platform.feedback.ratelimit.enabled=true +platform.feedback.ratelimit.pepper=dev-pepper-123 # dummy + +# trust forwarded-for locally is harmless (localhost only) +platform.feedback.ratelimit.trust-forwarded-for=false +platform.feedback.ratelimit.forwarded-for-header=X-Forwarded-For + +# Optional overrides (not needed if disabled) +platform.feedback.ratelimit.minute-limit=10 +platform.feedback.ratelimit.day-limit=100 +platform.feedback.ratelimit.user-day-limit=50 +platform.feedback.ratelimit.fail-window-minutes=5 +platform.feedback.ratelimit.backoff-minutes=15 + ### generate Beneficiary IDs URL generateBeneficiaryIDs-api-url=/generateBeneficiaryController/generateBeneficiaryIDs diff --git a/src/main/java/com/iemr/common/config/RedisConfig.java b/src/main/java/com/iemr/common/config/RedisConfig.java index e812b3f9..796ad557 100644 --- a/src/main/java/com/iemr/common/config/RedisConfig.java +++ b/src/main/java/com/iemr/common/config/RedisConfig.java @@ -33,6 +33,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.iemr.common.data.users.User; +import org.springframework.data.redis.core.StringRedisTemplate; @Configuration public class RedisConfig { @@ -57,4 +58,10 @@ public RedisTemplate redisTemplate(RedisConnectionFactory factor return template; } + // new bean for rate limiting & counters + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { + return new StringRedisTemplate(factory); + } + } diff --git a/src/main/java/com/iemr/common/controller/platform_feedback/PlatformFeedbackController.java b/src/main/java/com/iemr/common/controller/platform_feedback/PlatformFeedbackController.java new file mode 100644 index 00000000..fd9aad90 --- /dev/null +++ b/src/main/java/com/iemr/common/controller/platform_feedback/PlatformFeedbackController.java @@ -0,0 +1,74 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.controller; + +import com.iemr.common.dto.*; +import com.iemr.common.service.PlatformFeedbackService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.media.Content; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Platform Feedback", description = "Feedback ingestion and category listing for platform-wide feedback") +@RestController +@RequestMapping("/platform-feedback") +@Validated +public class PlatformFeedbackController { + + private final PlatformFeedbackService service; + + public PlatformFeedbackController(PlatformFeedbackService service) { + this.service = service; + } + + @Operation(summary = "Submit feedback (public endpoint)", + description = "Accepts feedback (anonymous or identified). Accepts categoryId or categorySlug; slug is preferred.") + @ApiResponse(responseCode = "201", description = "Feedback accepted") + @ApiResponse(responseCode = "400", description = "Validation or business error", content = @Content) + @PostMapping + public ResponseEntity submit( + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Feedback payload") + @Valid @RequestBody FeedbackRequest req) { + FeedbackResponse resp = service.submitFeedback(req); + return ResponseEntity.status(HttpStatus.CREATED).body(resp); + } + + @Operation(summary = "List active categories", + description = "Returns active categories. Optionally filter by serviceLine (frontend convenience).") + @ApiResponse(responseCode = "200", description = "List of categories") + @GetMapping("/categories") + public ResponseEntity> list( + @Parameter(description = "Optional serviceLine to prefer scopes (1097|104|AAM|MMU|TM|ECD)") + @RequestParam(required = false) String serviceLine) { + if (serviceLine == null) serviceLine = "GLOBAL"; + List list = service.listCategories(serviceLine); + return ResponseEntity.ok(list); + } +} diff --git a/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java b/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java new file mode 100644 index 00000000..b499ef3a --- /dev/null +++ b/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java @@ -0,0 +1,171 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "m_platform_feedback") +public class Feedback { + + @Id + @Column(name = "FeedbackID", length = 36, updatable = false, nullable = false) + private String feedbackId; + + @Column(name = "CreatedAt", nullable = false, insertable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "UpdatedAt", nullable = false, insertable = false, updatable = false) + private LocalDateTime updatedAt; + + @Min(1) + @Max(5) + @Column(name = "Rating", nullable = false) + private int rating; + + @Size(max = 2000) + @Column(name = "Comment", columnDefinition = "TEXT", nullable = true) + private String comment; + + @Column(name = "ServiceLine", nullable = false, length = 10) + private String serviceLine; + + @Column(name = "IsAnonymous", nullable = false) + private boolean isAnonymous = true; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "CategoryID", referencedColumnName = "CategoryID", nullable = false) + private FeedbackCategory category; + + @Column(name = "UserID", nullable = true) + private Integer userId; + + public Feedback() { + this.feedbackId = UUID.randomUUID().toString(); + } + + public Feedback(int rating, String comment, String serviceLine, boolean isAnonymous, FeedbackCategory category, Integer userId) { + this(); // ensures feedbackId + this.setRating(rating); + this.setComment(comment); + this.setServiceLine(serviceLine); + this.isAnonymous = isAnonymous; + this.category = category; + this.userId = userId; + } + + public String getFeedbackId() { + return feedbackId; + } + + // Don't usually set feedbackId externally, but keep setter for testing/migration if needed + public void setFeedbackId(String feedbackId) { + this.feedbackId = feedbackId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public int getRating() { + return rating; + } + + public void setRating(int rating) { + if (rating < 1 || rating > 5) { + throw new IllegalArgumentException("Rating must be between 1 and 5."); + } + this.rating = rating; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + if (comment != null && comment.length() > 2000) { + throw new IllegalArgumentException("Comment cannot exceed 2000 characters"); + } + this.comment = (comment == null || comment.trim().isEmpty()) ? null : comment.trim(); + } + + public String getServiceLine() { + return serviceLine; + } + + public void setServiceLine(String serviceLine) { + if (serviceLine == null || serviceLine.trim().isEmpty()) { + throw new IllegalArgumentException("ServiceLine must not be null or empty."); + } + this.serviceLine = serviceLine; + } + + public boolean isAnonymous() { + return isAnonymous; + } + + public void setAnonymous(boolean anonymous) { + isAnonymous = anonymous; + if (anonymous) { + this.userId = null; + } + } + + public FeedbackCategory getCategory() { + return category; + } + + public void setCategory(FeedbackCategory category) { + if (category == null) { + throw new IllegalArgumentException("Category must not be null."); + } + this.category = category; + } + + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + if (userId != null) { + this.isAnonymous = false; + } + } +} diff --git a/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java b/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java new file mode 100644 index 00000000..ee9ffffe --- /dev/null +++ b/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java @@ -0,0 +1,153 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * FeedbackCategory maps to the m_feedback_category table. + */ +@Entity +@Table(name = "m_feedback_category", uniqueConstraints = { + @UniqueConstraint(name = "uq_category_slug", columnNames = "Slug") +}) +public class FeedbackCategory { + + @Id + @Column(name = "CategoryID", length = 36, updatable = false, nullable = false) + private String categoryId; + + @NotBlank + @Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$", message = "Slug must be lowercase alphanumeric with optional single dashes") + @Size(max = 64) + @Column(name = "Slug", nullable = false, length = 64) + private String slug; + + @NotBlank + @Size(max = 128) + @Column(name = "Label", nullable = false, length = 128) + private String label; + + @NotBlank + @Size(max = 20) + @Column(name = "Scope", nullable = false, length = 20) + private String scope; + + @Column(name = "Active", nullable = false) + private boolean active = true; + + + @Column(name = "CreatedAt", nullable = false, insertable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "UpdatedAt", nullable = false, insertable = false, updatable = false) + private LocalDateTime updatedAt; + + // ===== Constructors ===== + public FeedbackCategory() { + // nothing — categoryId will be generated at persist if not provided + } + + public FeedbackCategory(String slug, String label, String scope, boolean active) { + this.slug = slug; + this.label = label; + this.scope = scope; + this.active = active; + } + + // ===== JPA lifecycle hooks ===== + /** + * Ensure CategoryID exists for new rows created via JPA. If DB seeding provides IDs, + * those will be used instead (we only set if null). + */ + @PrePersist + protected void ensureId() { + if (this.categoryId == null || this.categoryId.trim().isEmpty()) { + this.categoryId = UUID.randomUUID().toString(); + } + } + + // ===== Getters & Setters ===== + public String getCategoryId() { + return categoryId; + } + + // categoryId is generated at persist; setter kept for migration/tests + public void setCategoryId(String categoryId) { + this.categoryId = categoryId; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + // CreatedAt normally set by DB; setter available for tests/migrations + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + // UpdatedAt normally managed by DB + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/iemr/common/dto/platform_feedback/CategoryResponse.java b/src/main/java/com/iemr/common/dto/platform_feedback/CategoryResponse.java new file mode 100644 index 00000000..df1c7184 --- /dev/null +++ b/src/main/java/com/iemr/common/dto/platform_feedback/CategoryResponse.java @@ -0,0 +1,24 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.dto; + +public record CategoryResponse(String categoryId, String slug, String label, String scope, boolean active) {} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackRequest.java b/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackRequest.java new file mode 100644 index 00000000..7002ece9 --- /dev/null +++ b/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackRequest.java @@ -0,0 +1,38 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +public record FeedbackRequest( + @Min(1) @Max(5) int rating, + String categoryId, + String categorySlug, + @Size(max = 2000) String comment, + boolean isAnonymous, + @Pattern(regexp = "^(1097|104|AAM|MMU|TM|ECD)$") String serviceLine, + Integer userId +) {} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackResponse.java b/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackResponse.java new file mode 100644 index 00000000..6b348f6e --- /dev/null +++ b/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackResponse.java @@ -0,0 +1,26 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.dto; +import java.time.LocalDateTime; + + +public record FeedbackResponse(String id, LocalDateTime createdAt) {} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/exception/BadRequestException.java b/src/main/java/com/iemr/common/exception/BadRequestException.java new file mode 100644 index 00000000..65d146a6 --- /dev/null +++ b/src/main/java/com/iemr/common/exception/BadRequestException.java @@ -0,0 +1,31 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { super(message); } + public BadRequestException(String message, Throwable cause) { super(message, cause); } +} diff --git a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java new file mode 100644 index 00000000..d6dddf55 --- /dev/null +++ b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java @@ -0,0 +1,216 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + + +@Component +@ConditionalOnProperty(prefix = "platform.feedback.ratelimit", name = "enabled", havingValue = "true", matchIfMissing = false) +@Order(Ordered.HIGHEST_PRECEDENCE + 10) // run early (adjust order as needed) +public class PlatformFeedbackRateLimitFilter extends OncePerRequestFilter { + + private final StringRedisTemplate redis; + private final String pepper; + private final boolean trustForwardedFor; + private final String forwardedForHeader; + + // Limits & TTLs (tweak if needed) + private static final int MINUTE_LIMIT = 10; + private static final int DAY_LIMIT = 100; + private static final int USER_DAY_LIMIT = 50; // for identified users + private static final Duration MINUTE_WINDOW = Duration.ofMinutes(1); + private static final Duration DAY_WINDOW = Duration.ofHours(48); // keep key TTL ~48h + private static final Duration FAIL_COUNT_WINDOW = Duration.ofMinutes(5); + private static final int FAILS_TO_BACKOFF = 3; + private static final Duration BACKOFF_WINDOW = Duration.ofMinutes(15); + + public PlatformFeedbackRateLimitFilter(StringRedisTemplate redis, + org.springframework.core.env.Environment env) { + this.redis = redis; + this.pepper = env.getProperty("platform.feedback.pepper", ""); + this.trustForwardedFor = Boolean.parseBoolean(env.getProperty("platform.feedback.trust-forwarded-for", "true")); + this.forwardedForHeader = env.getProperty("platform.feedback.forwarded-for-header", "X-Forwarded-For"); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + // Only filter specific endpoints (POST to platform-feedback). Keep it narrow. + String path = request.getRequestURI(); + String method = request.getMethod(); + // adjust path as needed (supports /common-api/platform-feedback and subpaths) + return !("POST".equalsIgnoreCase(method) && path != null && path.matches("^/platform-feedback(?:/.*)?$")); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // compute day key + String clientIp = extractClientIp(request); + if (clientIp == null || clientIp.isBlank()) { + // If we can't identify an IP, be conservative and allow but log (or optionally block) + filterChain.doFilter(request, response); + return; + } + + String today = LocalDate.now(ZoneId.of("Asia/Kolkata")).toString().replaceAll("-", ""); // yyyyMMdd + String ipHash = sha256Base64(clientIp + pepper + today); // base64 shorter storage; not reversible without pepper + String minKey = "rl:fb:min:" + ipHash + ":" + (System.currentTimeMillis() / 60000L); // minute-slotted + String dayKey = "rl:fb:day:" + today + ":" + ipHash; + String failKey = "rl:fb:fail:" + ipHash; + String backoffKey = "rl:fb:backoff:" + ipHash; + + // If under backoff -> respond 429 with Retry-After = TTL + Long backoffTtl = getTtlSeconds(backoffKey); + if (backoffTtl != null && backoffTtl > 0) { + sendTooMany(response, backoffTtl); + return; + } + + // Minute window check (INCR + TTL if first) + long minuteCount = incrementWithExpire(minKey, 1, MINUTE_WINDOW.getSeconds()); + if (minuteCount > MINUTE_LIMIT) { + handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, dayKey); + return; + } + + // Day window check + long dayCount = incrementWithExpire(dayKey, 1, DAY_WINDOW.getSeconds()); + if (dayCount > DAY_LIMIT) { + handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, dayKey); + return; + } + + // Optional: per-user daily cap if we can extract an authenticated user id from header/jwt + Integer userId = extractUserIdFromRequest(request); // implement extraction as per your JWT scheme + if (userId != null) { + String userDayKey = "rl:fb:user:" + today + ":" + userId; + long ucount = incrementWithExpire(userDayKey, 1, DAY_WINDOW.getSeconds()); + if (ucount > USER_DAY_LIMIT) { + handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, userDayKey); + return; + } + } + + // All checks passed — proceed to controller + filterChain.doFilter(request, response); + } + + // increments key by delta; sets TTL when key is new (INCR returns 1) + private long incrementWithExpire(String key, long delta, long ttlSeconds) { + Long value = redis.opsForValue().increment(key, delta); + if (value != null && value == 1L) { + redis.expire(key, ttlSeconds, TimeUnit.SECONDS); + } + return value == null ? 0L : value; + } + + private void handleFailureAndMaybeBackoff(String failKey, String backoffKey, HttpServletResponse response, String trigKey, String dayKey) throws IOException { + // increment fail counter and possibly set backoff + Long fails = redis.opsForValue().increment(failKey, 1); + if (fails != null && fails == 1L) { + redis.expire(failKey, FAIL_COUNT_WINDOW.getSeconds(), TimeUnit.SECONDS); + } + if (fails != null && fails >= FAILS_TO_BACKOFF) { + // set backoff flag + redis.opsForValue().set(backoffKey, "1", BACKOFF_WINDOW.getSeconds(), TimeUnit.SECONDS); + sendTooMany(response, BACKOFF_WINDOW.getSeconds()); + return; + } + + // otherwise respond with Retry-After for the triggering key TTL (minute/day) + Long retryAfter = getTtlSeconds(trigKey); + if (retryAfter == null || retryAfter <= 0) retryAfter = 60L; + sendTooMany(response, retryAfter); + } + + private void sendTooMany(HttpServletResponse response, long retryAfterSeconds) throws IOException { + response.setStatus(429); + response.setHeader("Retry-After", String.valueOf(retryAfterSeconds)); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + String body = String.format("{\"code\":\"RATE_LIMITED\",\"message\":\"Too many requests\",\"retryAfter\":%d}", retryAfterSeconds); + response.getWriter().write(body); + } + + private Long getTtlSeconds(String key) { + Long ttl = redis.getExpire(key, TimeUnit.SECONDS); + return ttl == null || ttl < 0 ? null : ttl; + } + + private String extractClientIp(HttpServletRequest request) { + if (trustForwardedFor) { + String header = request.getHeader(forwardedForHeader); + if (StringUtils.hasText(header)) { + // X-Forwarded-For may contain comma-separated list; take the first (client) entry + String[] parts = header.split(","); + if (parts.length > 0) { + String ip = parts[0].trim(); + if (StringUtils.hasText(ip)) return ip; + } + } + } + return request.getRemoteAddr(); + } + + private Integer extractUserIdFromRequest(HttpServletRequest request) { + // implement based on how you propagate JWT or user info. + // Example: if your gateway injects header X-User-Id for authenticated requests: + String s = request.getHeader("X-User-Id"); + if (StringUtils.hasText(s)) { + try { return Integer.valueOf(s); } catch (NumberFormatException ignored) {} + } + // If JWT parsing required, do it here, but keep this filter light — prefer upstream auth filter to populate a header. + return null; + } + + private static String sha256Base64(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + // base64 url-safe or normal base64 — either is fine; base64 is shorter than hex + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); + } catch (Exception ex) { + throw new RuntimeException("sha256 failure", ex); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackCategoryRepository.java b/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackCategoryRepository.java new file mode 100644 index 00000000..083ca5f7 --- /dev/null +++ b/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackCategoryRepository.java @@ -0,0 +1,34 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.repository.platform_feedback; + +import com.iemr.common.model.FeedbackCategory; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PlatformFeedbackCategoryRepository extends JpaRepository { + Optional findBySlugIgnoreCase(String slug); + List findByActiveTrueOrderByLabelAsc(); +} diff --git a/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackRepository.java b/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackRepository.java new file mode 100644 index 00000000..fd00bdbc --- /dev/null +++ b/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackRepository.java @@ -0,0 +1,30 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.repository.platform_feedback; + +import com.iemr.common.model.Feedback; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PlatformFeedbackRepository extends JpaRepository { +} diff --git a/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java b/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java index 2e6ac1d4..a79d3683 100644 --- a/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java @@ -42,6 +42,7 @@ import com.iemr.common.utils.mapper.InputMapper; import com.iemr.common.utils.mapper.OutputMapper; import com.iemr.common.utils.response.OutputResponse; +import org.springframework.beans.factory.annotation.Value; @Service public class IdentityBeneficiaryServiceImpl implements IdentityBeneficiaryService { @@ -51,6 +52,7 @@ public class IdentityBeneficiaryServiceImpl implements IdentityBeneficiaryServic Logger logger = LoggerFactory.getLogger(this.getClass().getName()); private static HttpUtils httpUtils = new HttpUtils(); private InputMapper inputMapper = new InputMapper(); + @Value("${identity-api-url}") private String identityBaseURL; @Value("${identity-1097-api-url}") diff --git a/src/main/java/com/iemr/common/service/platform_feedback/PlatformFeedbackService.java b/src/main/java/com/iemr/common/service/platform_feedback/PlatformFeedbackService.java new file mode 100644 index 00000000..5149e3ed --- /dev/null +++ b/src/main/java/com/iemr/common/service/platform_feedback/PlatformFeedbackService.java @@ -0,0 +1,113 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.service; + +import com.iemr.common.dto.*; +import com.iemr.common.model.Feedback; +import com.iemr.common.model.FeedbackCategory; +import com.iemr.common.repository.platform_feedback.PlatformFeedbackCategoryRepository; +import com.iemr.common.repository.platform_feedback.PlatformFeedbackRepository; +import com.iemr.common.exception.BadRequestException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class PlatformFeedbackService { + + private final PlatformFeedbackRepository feedbackRepo; + private final PlatformFeedbackCategoryRepository categoryRepo; + + public PlatformFeedbackService(PlatformFeedbackRepository feedbackRepo, + PlatformFeedbackCategoryRepository categoryRepo) { + this.feedbackRepo = feedbackRepo; + this.categoryRepo = categoryRepo; + } + + @Transactional + public FeedbackResponse submitFeedback(FeedbackRequest req) { + // basic validations + if (req.rating() < 1 || req.rating() > 5) { + throw new BadRequestException("rating must be between 1 and 5"); + } + if (!req.isAnonymous() && req.userId() == null) { + throw new BadRequestException("userId required when isAnonymous=false"); + } + + FeedbackCategory category = resolveCategory(req.categoryId(), req.categorySlug(), req.serviceLine()); + + Feedback fb = new Feedback(); + fb.setFeedbackId(UUID.randomUUID().toString()); + fb.setCreatedAt(LocalDateTime.now()); + fb.setUpdatedAt(LocalDateTime.now()); + fb.setRating(req.rating()); + fb.setComment(req.comment() == null ? "" : req.comment()); + fb.setServiceLine(req.serviceLine()); + fb.setAnonymous(req.isAnonymous()); + fb.setCategory(category); + fb.setUserId(req.userId()); + + feedbackRepo.save(fb); + return new FeedbackResponse(fb.getFeedbackId(), fb.getCreatedAt()); + } + + private FeedbackCategory resolveCategory(String categoryId, String categorySlug, String serviceLine) { + if (categoryId != null && categorySlug != null) { + FeedbackCategory byId = categoryRepo.findById(categoryId) + .orElseThrow(() -> new BadRequestException("invalid categoryId")); + if (!byId.getSlug().equalsIgnoreCase(categorySlug)) { + throw new BadRequestException("categoryId and categorySlug mismatch"); + } + if (!byId.isActive()) throw new BadRequestException("category inactive"); + // optional: check scope matches serviceLine or GLOBAL + return byId; + } + + if (categoryId != null) { + FeedbackCategory byId = categoryRepo.findById(categoryId) + .orElseThrow(() -> new BadRequestException("invalid categoryId")); + if (!byId.isActive()) throw new BadRequestException("category inactive"); + return byId; + } + + if (categorySlug != null) { + FeedbackCategory bySlug = categoryRepo.findBySlugIgnoreCase(categorySlug) + .orElseThrow(() -> new BadRequestException("invalid categorySlug")); + if (!bySlug.isActive()) throw new BadRequestException("category inactive"); + return bySlug; + } + + throw new BadRequestException("categoryId or categorySlug required"); + } + + @Transactional(readOnly = true) + public List listCategories(String serviceLine) { + List all = categoryRepo.findByActiveTrueOrderByLabelAsc(); + // filter by scope or return all; FE can filter further + return all.stream() + .filter(c -> "GLOBAL".equals(c.getScope()) || c.getScope().equalsIgnoreCase(serviceLine)) + .map(c -> new CategoryResponse(c.getCategoryId(), c.getSlug(), c.getLabel(), c.getScope(), c.isActive())) + .toList(); + } +} diff --git a/src/main/java/com/iemr/common/utils/FilterConfig.java b/src/main/java/com/iemr/common/utils/FilterConfig.java index 9f6efb13..42bd04ad 100644 --- a/src/main/java/com/iemr/common/utils/FilterConfig.java +++ b/src/main/java/com/iemr/common/utils/FilterConfig.java @@ -1,28 +1,102 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ package com.iemr.common.utils; +import com.iemr.common.filter.PlatformFeedbackRateLimitFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.data.redis.core.StringRedisTemplate; @Configuration public class FilterConfig { - @Value("${cors.allowed-origins}") - private String allowedOrigins; + private static final Logger log = LoggerFactory.getLogger(FilterConfig.class); + + @Value("${cors.allowed-origins}") + private String allowedOrigins; + + @Bean + public FilterRegistrationBean jwtUserIdValidationFilter( + JwtAuthenticationUtil jwtAuthenticationUtil) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + + // Pass allowedOrigins explicitly to the filter constructor + JwtUserIdValidationFilter filter = new JwtUserIdValidationFilter(jwtAuthenticationUtil, allowedOrigins); + + registrationBean.setFilter(filter); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + registrationBean.addUrlPatterns("/*"); // Apply filter to all API endpoints + log.info("Registered JwtUserIdValidationFilter on /* with order {}", Ordered.HIGHEST_PRECEDENCE); + return registrationBean; + } + + /** + * Register the platform feedback rate-limit filter in a non-invasive way. + * + * - The filter is mapped only to the public feedback endpoints to avoid affecting other routes. + * - Order is intentionally set after the Jwt filter so authentication runs first. + */ + @Bean + public FilterRegistrationBean platformFeedbackRateLimitFilter( + StringRedisTemplate stringRedisTemplate, + Environment env) { + + // Read flag from environment (property file or env var) + boolean enabled = Boolean.parseBoolean(env.getProperty("platform.feedback.ratelimit.enabled", "false")); + + // Allow optional override for order if needed + int defaultOrder = Ordered.HIGHEST_PRECEDENCE + 10; + int order = defaultOrder; + String orderStr = env.getProperty("platform.feedback.ratelimit.order"); + if (orderStr != null) { + try { + order = Integer.parseInt(orderStr); + } catch (NumberFormatException e) { + log.warn("Invalid platform.feedback.ratelimit.order value '{}', using default {}", orderStr, defaultOrder); + } + } + + PlatformFeedbackRateLimitFilter filter = new PlatformFeedbackRateLimitFilter(stringRedisTemplate, env); + + FilterRegistrationBean reg = new FilterRegistrationBean<>(filter); + + reg.addUrlPatterns( + "/platform-feedback/*", + "/platform-feedback" + ); - @Bean - public FilterRegistrationBean jwtUserIdValidationFilter( - JwtAuthenticationUtil jwtAuthenticationUtil) { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + reg.setOrder(order); + // Do not remove the bean from context when disabled; keep registration but disable execution + reg.setEnabled(enabled); - // Pass allowedOrigins explicitly to the filter constructor - JwtUserIdValidationFilter filter = new JwtUserIdValidationFilter(jwtAuthenticationUtil, allowedOrigins); + log.info("Registered PlatformFeedbackRateLimitFilter (enabled={}, order={}) mapped to {}", + enabled, order, reg.getUrlPatterns()); - registrationBean.setFilter(filter); - registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); - registrationBean.addUrlPatterns("/*"); // Apply filter to all API endpoints - return registrationBean; - } -} + return reg; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/utils/JwtUserIdValidationFilter.java b/src/main/java/com/iemr/common/utils/JwtUserIdValidationFilter.java index 412352fc..81d79221 100644 --- a/src/main/java/com/iemr/common/utils/JwtUserIdValidationFilter.java +++ b/src/main/java/com/iemr/common/utils/JwtUserIdValidationFilter.java @@ -1,3 +1,24 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ package com.iemr.common.utils; import java.io.IOException; @@ -99,6 +120,13 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo logger.info("JwtUserIdValidationFilter invoked for path: " + path); + // NEW: if this is a platform-feedback endpoint, treat it as public (skip auth) + // and also ensure we don't clear any user cookies for these requests. + if (isPlatformFeedbackPath(path, contextPath)) { + logger.debug("Platform-feedback path detected - skipping authentication and leaving cookies intact: {}", path); + filterChain.doFilter(servletRequest, servletResponse); + return; + } // Log cookies for debugging Cookie[] cookies = request.getCookies(); if (cookies != null) { @@ -166,6 +194,19 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo } } + /** + * New helper: identifies platform-feedback endpoints so we can treat them + * specially (public + preserve cookies). + */ + private boolean isPlatformFeedbackPath(String path, String contextPath) { + if (path == null) return false; + String normalized = path.toLowerCase(); + String base = (contextPath == null ? "" : contextPath).toLowerCase(); + // match /platform-feedback and anything under it + return normalized.startsWith(base + "/platform-feedback"); + } + + private boolean isOriginAllowed(String origin) { if (origin == null || allowedOrigins == null || allowedOrigins.trim().isEmpty()) { logger.warn("No allowed origins configured or origin is null"); diff --git a/src/main/java/com/iemr/common/utils/RestTemplateUtil.java b/src/main/java/com/iemr/common/utils/RestTemplateUtil.java index 447ba80f..c8299fe7 100644 --- a/src/main/java/com/iemr/common/utils/RestTemplateUtil.java +++ b/src/main/java/com/iemr/common/utils/RestTemplateUtil.java @@ -10,14 +10,17 @@ import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import com.iemr.common.constant.Constants; + import jakarta.servlet.http.HttpServletRequest; public class RestTemplateUtil { private final static Logger logger = LoggerFactory.getLogger(RestTemplateUtil.class); - + public static HttpEntity createRequestEntity(Object body, String authorization) { - - ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()); + + ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder + .getRequestAttributes()); if (servletRequestAttributes == null) { MultiValueMap headers = new LinkedMultiValueMap<>(); headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"); @@ -25,29 +28,58 @@ public static HttpEntity createRequestEntity(Object body, String authori return new HttpEntity<>(body, headers); } HttpServletRequest requestHeader = servletRequestAttributes.getRequest(); - String jwtTokenFromCookie = null; + + String jwtTokenFromCookie = extractJwttoken(requestHeader); + + MultiValueMap headers = new LinkedMultiValueMap<>(); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"); + if (null != UserAgentContext.getUserAgent()) { + headers.add(HttpHeaders.USER_AGENT, UserAgentContext.getUserAgent()); + } + headers.add(HttpHeaders.AUTHORIZATION, authorization); + if (null != requestHeader.getHeader(Constants.JWT_TOKEN)) { + headers.add(Constants.JWT_TOKEN, requestHeader.getHeader(Constants.JWT_TOKEN)); + } + if (null != jwtTokenFromCookie) { + headers.add(HttpHeaders.COOKIE, "Jwttoken=" + jwtTokenFromCookie); + } + + return new HttpEntity<>(body, headers); + } + + private static String extractJwttoken(HttpServletRequest requestHeader) { + String jwtTokenFromCookie = null; try { jwtTokenFromCookie = CookieUtil.getJwtTokenFromCookie(requestHeader); - + } catch (Exception e) { - logger.error("Error while getting jwtToken from Cookie" + e.getMessage() ); - } - - MultiValueMap headers = new LinkedMultiValueMap<>(); - headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"); - if(null != UserAgentContext.getUserAgent()) { - logger.info("Common-API getting User-Agent as : "+UserAgentContext.getUserAgent()); - headers.add(HttpHeaders.USER_AGENT, UserAgentContext.getUserAgent()); - } - headers.add(HttpHeaders.AUTHORIZATION, authorization); - if(null != requestHeader.getHeader("JwtToken")) { - headers.add("JwtToken",requestHeader.getHeader("JwtToken")); - } - if(null != jwtTokenFromCookie) { - headers.add(HttpHeaders.COOKIE, "Jwttoken=" + jwtTokenFromCookie); - } - - return new HttpEntity<>(body, headers); - } - -} \ No newline at end of file + logger.error("Error while getting jwtToken from Cookie" + e.getMessage()); + } + return jwtTokenFromCookie; + } + + public static void getJwttokenFromHeaders(HttpHeaders headers) { + ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder + .getRequestAttributes()); + if (servletRequestAttributes == null) { + return; + } + HttpServletRequest requestHeader = servletRequestAttributes.getRequest(); + String jwtTokenFromCookie = extractJwttoken(requestHeader); + if (!headers.containsKey(HttpHeaders.CONTENT_TYPE)) { + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"); + } + if (null != UserAgentContext.getUserAgent()) { + if (!headers.containsKey(HttpHeaders.USER_AGENT)) { + headers.add(HttpHeaders.USER_AGENT, UserAgentContext.getUserAgent()); + } + } + if (null != jwtTokenFromCookie) { + headers.add(HttpHeaders.COOKIE, Constants.JWT_TOKEN + "=" + jwtTokenFromCookie); + } else if (null != requestHeader.getHeader(Constants.JWT_TOKEN)) { + headers.add(Constants.JWT_TOKEN, requestHeader.getHeader(Constants.JWT_TOKEN)); + } + + } + +} diff --git a/src/main/java/com/iemr/common/utils/http/HttpUtils.java b/src/main/java/com/iemr/common/utils/http/HttpUtils.java index 0f308619..4f49e662 100644 --- a/src/main/java/com/iemr/common/utils/http/HttpUtils.java +++ b/src/main/java/com/iemr/common/utils/http/HttpUtils.java @@ -40,6 +40,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import com.iemr.common.utils.RestTemplateUtil; import com.sun.jersey.multipart.FormDataBodyPart; import com.sun.jersey.multipart.FormDataMultiPart; @@ -54,9 +55,6 @@ public class HttpUtils { // @Autowired private HttpStatus status; private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); - - // @Autowired(required = true) - // @Qualifier("hibernateCriteriaBuilder") public HttpUtils() { if (rest == null) { rest = new RestTemplate(); @@ -64,33 +62,22 @@ public HttpUtils() { headers.add("Content-Type", "application/json"); } } - // public HttpUtils() { - // if (rest == null) { - // rest = new RestTemplate(); - // headers = new HttpHeaders(); - // headers.add("Content-Type", "application/json"); - // } - // } - - // @Bean - // public HttpUtils httpUtils() { - // return new HttpUtils(); - // } - + public String get(String uri) { String body; - HttpEntity requestEntity = new HttpEntity("", headers); + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.add("Content-Type", "application/json"); + RestTemplateUtil.getJwttokenFromHeaders(requestHeaders); + HttpEntity requestEntity = new HttpEntity("", requestHeaders); ResponseEntity responseEntity = rest.exchange(uri, HttpMethod.GET, requestEntity, String.class); setStatus((HttpStatus) responseEntity.getStatusCode()); - // if (status == HttpStatus.OK){ body = responseEntity.getBody(); - // }else{ - // responseEntity - // } + return body; } public ResponseEntity getV1(String uri) throws URISyntaxException, MalformedURLException { + RestTemplateUtil.getJwttokenFromHeaders(headers); HttpEntity requestEntity = new HttpEntity("", headers); ResponseEntity responseEntity = rest.exchange(uri, HttpMethod.GET, requestEntity, String.class); return responseEntity; @@ -107,6 +94,7 @@ public String get(String uri, HashMap header) { } else { headers.add("Content-Type", MediaType.APPLICATION_JSON); } + RestTemplateUtil.getJwttokenFromHeaders(headers); HttpEntity requestEntity = new HttpEntity("", headers); ResponseEntity responseEntity = rest.exchange(uri, HttpMethod.GET, requestEntity, String.class); setStatus((HttpStatus) responseEntity.getStatusCode()); @@ -116,6 +104,7 @@ public String get(String uri, HashMap header) { public String post(String uri, String json) { String body; + RestTemplateUtil.getJwttokenFromHeaders(headers); HttpEntity requestEntity = new HttpEntity(json, headers); ResponseEntity responseEntity = rest.exchange(uri, HttpMethod.POST, requestEntity, String.class); setStatus((HttpStatus) responseEntity.getStatusCode()); @@ -129,9 +118,7 @@ public String post(String uri, String data, HashMap header) { if (header.containsKey(headers.AUTHORIZATION)) { headers.add(headers.AUTHORIZATION, header.get(headers.AUTHORIZATION).toString()); } - - // headers.add("Content-Type", MediaType.APPLICATION_JSON); - + RestTemplateUtil.getJwttokenFromHeaders(headers); headers.add("Content-Type", MediaType.APPLICATION_JSON + ";charset=utf-8"); ResponseEntity responseEntity = new ResponseEntity(HttpStatus.BAD_REQUEST); HttpEntity requestEntity;