Skip to content

Commit e78c244

Browse files
author
Nguyen Son
committed
fix: harden chatbot and public proxy fallbacks
1 parent 7066d39 commit e78c244

15 files changed

Lines changed: 321 additions & 25 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@ cd frontend && BASE_URL=http://localhost:3001 npm run test:e2e:portfolio
126126
- `GROK_ENABLED=false` by default
127127
- Set `GROK_ENABLED=true` together with `GROK_API_KEY` to enable Grok in local/dev or Docker
128128
- Frontend chatbot health is driven by `/api/chatbot/health`
129+
- Public health responses are sanitized; raw provider errors and user email addresses are not returned to the browser.
130+
131+
## CI/CD registry secrets
132+
133+
The GitHub Actions pipeline publishes to GHCR automatically. Docker Hub publish is enabled when these repository secrets exist:
134+
135+
- `DOCKERHUB_USERNAME`
136+
- `DOCKERHUB_TOKEN`
137+
138+
If `DOCKERHUB_NAMESPACE` is not set as a repository variable, the workflow uses `DOCKERHUB_USERNAME` as the Docker Hub namespace.
129139

130140
## Payments
131141

README_VN.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99

1010
Dự án được xây dựng trên mô hình Micro-monolith hiện đại:
1111

12-
- **Backend**: Spring Boot 3.3, Java 17, Spring Security (JWT), Spring Data JPA.
13-
- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS, Shadcn/UI, React Query.
14-
- **Mobile**: React Native (Expo), Lucide Icons, Zustand.
12+
- **Backend**: Spring Boot 3.2, Java 17, Spring Security (JWT), Spring Data JPA.
13+
- **Frontend**: Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS, React Query.
14+
- **Mobile**: Expo SDK 55, React Native 0.83, Lucide Icons, Zustand.
1515
- **Database & DevOps**: MySQL 8, Docker, Docker Compose, GitHub Actions (CI/CD).
16-
- **Security**: Bucket4j (Rate Limiting), JWT Stateless Auth, CSP Headers.
16+
- **Security**: Rate limiting, JWT stateless auth, CSP/HSTS/Permissions-Policy headers.
1717

1818
## Tính năng Nổi bật
1919

@@ -51,13 +51,15 @@ docker compose up -d
5151
```
5252

5353
Hệ thống sẽ khả dụng tại:
54-
- Frontend: `http://localhost:3000`
54+
- Frontend: `http://localhost:3001`
5555
- Backend API: `http://localhost:8080/api`
56-
- Swagger UI: `http://localhost:8080/swagger-ui.html`
56+
- Swagger UI: `http://localhost:8080/api/swagger-ui.html`
5757

5858
## Bảo mật & Độ tin cậy
5959
- **Rate Limiting**: Chống tấn công brute-force ở các điểm nhạy cảm (Login, Register, Chatbot).
6060
- **CI/CD**: Tự động chạy Unit Test, Integration Test (MySQL), E2E Test (Playwright) và Security Scan (Trivy) trên mỗi lần push.
61+
- **Chatbot an toàn**: Health endpoint công khai không trả lỗi provider thô; context gửi sang Grok không kèm email người dùng.
62+
- **Docker Hub**: Pipeline publish thật khi repository secrets `DOCKERHUB_USERNAME``DOCKERHUB_TOKEN` đã được cấu hình.
6163

6264
## Tài liệu chi tiết
6365

backend/src/main/java/com/bookstore/config/RateLimitingFilter.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo
6060

6161
String clientIp = getClientIP(request);
6262
String requestUri = request.getRequestURI();
63+
64+
if (isHealthEndpoint(requestUri)) {
65+
filterChain.doFilter(servletRequest, servletResponse);
66+
return;
67+
}
68+
6369
String key = clientIp + ":" + requestUri;
6470

6571
RateLimitConfig config = getRateLimitConfig(request.getMethod(), requestUri);
@@ -124,6 +130,19 @@ private boolean isPublicCatalogEndpoint(String uri) {
124130
|| uri.equals("/api/coupons/available")
125131
|| uri.equals("/coupons/available");
126132
}
133+
134+
private boolean isHealthEndpoint(String uri) {
135+
return uri.equals("/api/health")
136+
|| uri.startsWith("/api/health/")
137+
|| uri.equals("/health")
138+
|| uri.startsWith("/health/")
139+
|| uri.equals("/api/actuator/health")
140+
|| uri.startsWith("/api/actuator/health/")
141+
|| uri.equals("/actuator/health")
142+
|| uri.startsWith("/actuator/health/")
143+
|| uri.equals("/api/chatbot/health")
144+
|| uri.equals("/chatbot/health");
145+
}
127146

128147
private String getClientIP(HttpServletRequest request) {
129148
String xForwardedFor = request.getHeader("X-Forwarded-For");

backend/src/main/java/com/bookstore/controller/ChatbotController.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import lombok.RequiredArgsConstructor;
1515
import org.springframework.http.ResponseEntity;
1616
import org.springframework.security.core.annotation.AuthenticationPrincipal;
17+
import org.springframework.security.access.prepost.PreAuthorize;
1718
import org.springframework.web.bind.annotation.DeleteMapping;
1819
import org.springframework.web.bind.annotation.GetMapping;
1920
import org.springframework.web.bind.annotation.PathVariable;
@@ -78,6 +79,7 @@ public ResponseEntity<ApiResponse<Void>> submitFeedback(
7879

7980
@GetMapping("/stats")
8081
@Operation(summary = "Lay thong ke chatbot (Admin)")
82+
@PreAuthorize("hasRole('ADMIN')")
8183
public ResponseEntity<ApiResponse<Map<String, Object>>> getChatbotStats() {
8284
return ResponseEntity.ok(ApiResponse.success(chatbotService.getChatbotStats()));
8385
}

backend/src/main/java/com/bookstore/service/AbstractChatbotService.java

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public abstract class AbstractChatbotService implements ChatbotService {
4949
- Thân thiện, gần gũi như đang trò chuyện với người bạn
5050
- Sử dụng tiếng Việt, có thể xen lẫn tiếng Anh cho tên sách/tác giả
5151
- Trả lời NGẮN GỌN (dưới 200 từ) trừ khi cần chi tiết
52-
- Nếu không biết thì gợi ý hotline: 1900-xxxx hoặc email: support@bookstore.com
52+
- Nếu không biết thì gợi ý trang Liên hệ hoặc email support@bookstore.com
5353
5454
CÁC TÍNH NĂNG CỦA CỬA HÀNG:
5555
- Giao hàng toàn quốc, miễn phí vận chuyển cho đơn từ 200.000đ
@@ -167,10 +167,10 @@ protected String getFallbackResponse() {
167167
168168
Bạn có thể:
169169
- Thử hỏi lại câu hỏi của mình
170-
- Liên hệ hotline: 1900-xxxx để được hỗ trợ nhanh hơn
171-
- Email: support@bookstore.com
170+
- Mở trang Liên hệ để gửi yêu cầu hỗ trợ
171+
- Email support@bookstore.com nếu cần kiểm tra đơn hàng gấp
172172
173-
Cảm ơn bạn đã thông cảm!
173+
Cảm ơn bạn đã thông cảm.
174174
""";
175175
}
176176

@@ -180,8 +180,8 @@ protected String getDisabledResponse() {
180180
181181
Bạn vẫn có thể:
182182
- Duyệt danh mục sách và tìm kiếm trực tiếp trên cửa hàng
183-
- Liên hệ hotline: 1900-xxxx
184-
- Email: support@bookstore.com
183+
- Mở trang Liên hệ để gửi yêu cầu hỗ trợ
184+
- Email support@bookstore.com nếu cần kiểm tra đơn hàng gấp
185185
186186
Khi trợ lý được bật lại, mình sẽ hỗ trợ tư vấn chi tiết hơn.
187187
""";
@@ -235,8 +235,9 @@ protected List<Map<String, String>> buildMessages(List<ChatMessage> history, Str
235235

236236
protected String buildUserContext(User user) {
237237
StringBuilder context = new StringBuilder("\n\nTHÔNG TIN KHÁCH HÀNG:");
238-
context.append("\n- Tên: ").append(user.getFullName());
239-
context.append("\n- Email: ").append(user.getEmail());
238+
String displayName = user.getFullName();
239+
context.append("\n- Tên hiển thị: ")
240+
.append(displayName == null || displayName.isBlank() ? "Khách hàng đã đăng nhập" : displayName);
240241

241242
try {
242243
var orders = orderRepository.findByUserId(user.getId(), PageRequest.of(0, 1));

backend/src/main/java/com/bookstore/service/GrokChatbotService.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,11 @@ protected int getMaxContextMessages() {
142142
@Override
143143
protected String getStatusMessage() {
144144
if (grokApiKey == null || grokApiKey.isBlank()) {
145-
return "Grok đang bật nhưng chưa có API key trong environment.";
145+
return "Trợ lý AI đang ở chế độ dự phòng do cấu hình provider chưa hoàn tất.";
146146
}
147147

148148
if (lastFailureAt != null && (lastSuccessAt == null || lastFailureAt.isAfter(lastSuccessAt))) {
149-
return "Grok đã được cấu hình nhưng lần gọi gần nhất thất bại: "
150-
+ (lastFailureMessage != null ? lastFailureMessage : "không rõ nguyên nhân");
149+
return "Trợ lý AI đã được cấu hình nhưng lần gọi gần nhất chưa ổn định. Chatbot đang dùng chế độ dự phòng.";
151150
}
152151

153152
return "Grok đã được cấu hình và sẵn sàng trả lời.";

backend/src/test/java/com/bookstore/controller/ChatbotControllerTest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ void getChatbotStats_asAdmin() throws Exception {
141141
.andExpect(jsonPath("$.data.totalConversations").value(100));
142142
}
143143

144+
@Test
145+
@WithMockUser(username = "customer@example.com", roles = {"CUSTOMER"})
146+
@DisplayName("GET /api/chatbot/stats - rejects non-admin users")
147+
void getChatbotStats_asCustomer_forbidden() throws Exception {
148+
mockMvc.perform(get("/chatbot/stats"))
149+
.andExpect(status().isForbidden());
150+
}
151+
144152
@Test
145153
@DisplayName("POST /api/chatbot/message - returns 401 when not authenticated")
146154
void sendMessage_unauthenticated() throws Exception {

backend/src/test/java/com/bookstore/service/ChatbotServiceHealthTest.java

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ void grokServiceReportsDegradedHealthWithoutApiKey() {
6666
.containsEntry("service", "Grok AI Chatbot")
6767
.containsEntry("model", "grok-3")
6868
.containsEntry("providerEnabled", "true");
69-
assertThat(health.get("message")).contains("API key");
69+
assertThat(health.get("message"))
70+
.contains("chế độ dự phòng")
71+
.doesNotContain("API key");
7072
}
7173

7274
@Test
@@ -98,12 +100,15 @@ void grokServiceKeepsStartupHealthyButMarksRecentFailures() {
98100

99101
assertThat(generatedReply.content())
100102
.contains("support@bookstore.com")
101-
.contains("1900-xxxx");
103+
.doesNotContain("1900-xxxx");
102104
assertThat(generatedReply.providerResponse()).isFalse();
103105
assertThat(health)
104106
.containsEntry("status", "DEGRADED")
105107
.containsEntry("providerEnabled", "true");
106-
assertThat(health.get("message")).contains("network down");
108+
assertThat(health.get("message"))
109+
.contains("chế độ dự phòng")
110+
.doesNotContain("network down")
111+
.doesNotContain("https://api.x.ai");
107112
}
108113

109114
@Test
@@ -136,6 +141,31 @@ void grokServiceTreatsEmptyProviderContentAsFallback() {
136141
assertThat(service.getHealthStatus()).containsEntry("status", "DEGRADED");
137142
}
138143

144+
@Test
145+
@SuppressWarnings("unchecked")
146+
void userContextSentToProviderDoesNotIncludeEmail() {
147+
GrokChatbotService service = newGrokService();
148+
User user = new User();
149+
user.setId(17L);
150+
user.setEmail("customer@example.com");
151+
user.setFirstName("Nguyễn Khách");
152+
153+
List<Map<String, String>> messages = ReflectionTestUtils.invokeMethod(
154+
service,
155+
"buildMessages",
156+
List.of(),
157+
"Tư vấn giúp tôi một cuốn sách kinh doanh",
158+
user
159+
);
160+
161+
assertThat(messages).isNotNull();
162+
String systemPrompt = messages.get(0).get("content");
163+
assertThat(systemPrompt)
164+
.contains("Nguyễn Khách")
165+
.doesNotContain("customer@example.com")
166+
.doesNotContain("Email:");
167+
}
168+
139169
private GrokChatbotService newGrokService() {
140170
return new GrokChatbotService(
141171
conversationRepository,

docs/architecture-and-cicd-vn.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ stateDiagram-v2
100100
- Publish image lên GHCR và Docker Hub.
101101
- Tag chuyên nghiệp theo semver:
102102
- `latest`
103-
- `v1.0.0`
103+
- `v1.1.2`
104104
- `v1`
105105
5. **Render deploy**
106106
- Gọi deploy hooks sau khi toàn bộ gate chính đã xanh.

docs/architecture-and-cicd.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ stateDiagram-v2
100100
- Publishes images to GHCR and Docker Hub.
101101
- Public tags follow semver-first naming:
102102
- `latest`
103-
- `v1.0.0`
103+
- `v1.1.2`
104104
- `v1`
105105
5. **Render deploy**
106106
- Triggers Render deploy hooks after the required gates pass.

0 commit comments

Comments
 (0)