1 | package edu.ucsb.cs156.frontiers.controllers; | |
2 | ||
3 | import com.fasterxml.jackson.core.JsonProcessingException; | |
4 | import com.fasterxml.jackson.databind.JsonNode; | |
5 | import edu.ucsb.cs156.frontiers.entities.Course; | |
6 | import edu.ucsb.cs156.frontiers.entities.CourseStaff; | |
7 | import edu.ucsb.cs156.frontiers.entities.RosterStudent; | |
8 | import edu.ucsb.cs156.frontiers.enums.OrgStatus; | |
9 | import edu.ucsb.cs156.frontiers.repositories.CourseRepository; | |
10 | import edu.ucsb.cs156.frontiers.repositories.CourseStaffRepository; | |
11 | import edu.ucsb.cs156.frontiers.repositories.RosterStudentRepository; | |
12 | import edu.ucsb.cs156.frontiers.utilities.WebhookSecurityUtils; | |
13 | import io.swagger.v3.oas.annotations.tags.Tag; | |
14 | import java.security.InvalidKeyException; | |
15 | import java.security.NoSuchAlgorithmException; | |
16 | import java.util.Optional; | |
17 | import lombok.extern.slf4j.Slf4j; | |
18 | import org.springframework.beans.factory.annotation.Value; | |
19 | import org.springframework.http.ResponseEntity; | |
20 | import org.springframework.web.bind.annotation.PostMapping; | |
21 | import org.springframework.web.bind.annotation.RequestBody; | |
22 | import org.springframework.web.bind.annotation.RequestHeader; | |
23 | import org.springframework.web.bind.annotation.RequestMapping; | |
24 | import org.springframework.web.bind.annotation.RestController; | |
25 | ||
26 | @Tag(name = "Webhooks Controller") | |
27 | @RestController | |
28 | @RequestMapping("/api/webhooks") | |
29 | @Slf4j | |
30 | public class WebhookController { | |
31 | ||
32 | private final CourseRepository courseRepository; | |
33 | private final RosterStudentRepository rosterStudentRepository; | |
34 | private final CourseStaffRepository courseStaffRepository; | |
35 | ||
36 | @Value("${app.webhook.secret}") | |
37 | private String webhookSecret; | |
38 | ||
39 | public WebhookController( | |
40 | CourseRepository courseRepository, | |
41 | RosterStudentRepository rosterStudentRepository, | |
42 | CourseStaffRepository courseStaffRepository) { | |
43 | this.courseRepository = courseRepository; | |
44 | this.rosterStudentRepository = rosterStudentRepository; | |
45 | this.courseStaffRepository = courseStaffRepository; | |
46 | } | |
47 | ||
48 | /** | |
49 | * Accepts webhooks from GitHub, currently to update the membership status of a RosterStudent. | |
50 | * | |
51 | * @param jsonBody body of the webhook. The description of the currently used webhook is available | |
52 | * in docs/webhooks.md | |
53 | * @param signature the GitHub webhook signature header for security validation | |
54 | * @return either the word success so GitHub will not flag the webhook as a failure, or the | |
55 | * updated RosterStudent | |
56 | */ | |
57 | @PostMapping("/github") | |
58 | public ResponseEntity<String> createGitHubWebhook( | |
59 | @RequestBody String requestBody, | |
60 | @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature) | |
61 | throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeyException { | |
62 | ||
63 | // Validate webhook signature | |
64 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!WebhookSecurityUtils.validateGitHubSignature(requestBody, signature, webhookSecret)) { |
65 | log.error("Webhook signature validation failed"); | |
66 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.status(401).body("Unauthorized: Invalid signature"); |
67 | } | |
68 | ||
69 | // Parse JSON after signature validation | |
70 | JsonNode jsonBody; | |
71 | try { | |
72 | jsonBody = new com.fasterxml.jackson.databind.ObjectMapper().readTree(requestBody); | |
73 | } catch (JsonProcessingException e) { | |
74 | log.error("Failed to parse webhook JSON body", e); | |
75 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.badRequest().body("Invalid JSON"); |
76 | } | |
77 | ||
78 | log.info("Received GitHub webhook: {}", jsonBody.toString()); | |
79 | ||
80 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!jsonBody.has("action")) { |
81 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
82 | } | |
83 | ||
84 | String action = jsonBody.get("action").asText(); | |
85 | log.info("Webhook action: {}", action); | |
86 | ||
87 | // Handle GitHub App uninstall (installation deleted) | |
88 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (action.equals("deleted")) { |
89 |
2
1. createGitHubWebhook : negated conditional → KILLED 2. createGitHubWebhook : negated conditional → KILLED |
if (!jsonBody.has("installation") || !jsonBody.get("installation").has("id")) { |
90 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
91 | } | |
92 | String installationIdForUninstall = jsonBody.get("installation").get("id").asText(); | |
93 | log.info("Processing uninstall for Installation ID: {}", installationIdForUninstall); | |
94 | Optional<Course> courseForUninstall = | |
95 | courseRepository.findByInstallationId(installationIdForUninstall); | |
96 | log.info("Course found for uninstall: {}", courseForUninstall.isPresent()); | |
97 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (courseForUninstall.isPresent()) { |
98 | Course c = courseForUninstall.get(); | |
99 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/Course::setInstallationId → KILLED |
c.setInstallationId(null); |
100 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/Course::setOrgName → KILLED |
c.setOrgName(null); |
101 | courseRepository.save(c); | |
102 | } else { | |
103 | log.warn( | |
104 | "No course found with installation ID for uninstall: {}", installationIdForUninstall); | |
105 | } | |
106 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
107 | } | |
108 | ||
109 | // Early return if not an action we care about | |
110 |
2
1. createGitHubWebhook : negated conditional → KILLED 2. createGitHubWebhook : negated conditional → KILLED |
if (!action.equals("member_added") && !action.equals("member_invited")) { |
111 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
112 | } | |
113 | ||
114 | // Extract GitHub login based on payload structure | |
115 | String githubLogin = null; | |
116 | String installationId = null; | |
117 | OrgStatus role = null; | |
118 | ||
119 | // For member_added events, the structure is different | |
120 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (action.equals("member_added")) { |
121 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!jsonBody.has("membership") |
122 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("membership").has("user") |
123 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("membership").has("role") |
124 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("membership").get("user").has("login") |
125 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.has("installation") |
126 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("installation").has("id")) { |
127 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
128 | } | |
129 | ||
130 | githubLogin = jsonBody.get("membership").get("user").get("login").asText(); | |
131 | installationId = jsonBody.get("installation").get("id").asText(); | |
132 | String textRole = jsonBody.get("membership").get("role").asText(); | |
133 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (textRole.equals("admin")) { |
134 | role = OrgStatus.OWNER; | |
135 | } else { | |
136 | role = OrgStatus.MEMBER; | |
137 | } | |
138 | } | |
139 | // For member_invited events, use the original structure | |
140 | else { // must be "member_invited" based on earlier check | |
141 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!jsonBody.has("user") |
142 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("user").has("login") |
143 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.has("installation") |
144 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("installation").has("id")) { |
145 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
146 | } | |
147 | ||
148 | githubLogin = jsonBody.get("user").get("login").asText(); | |
149 | installationId = jsonBody.get("installation").get("id").asText(); | |
150 | } | |
151 | ||
152 | log.info("GitHub login: {}, Installation ID: {}", githubLogin, installationId); | |
153 | ||
154 | Optional<Course> course = courseRepository.findByInstallationId(installationId); | |
155 | log.info("Course found: {}", course.isPresent()); | |
156 | ||
157 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!course.isPresent()) { |
158 | log.warn("No course found with installation ID: {}", installationId); | |
159 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
160 | } | |
161 | ||
162 | Optional<RosterStudent> student = | |
163 | rosterStudentRepository.findByCourseAndGithubLogin(course.get(), githubLogin); | |
164 | Optional<CourseStaff> staff = | |
165 | courseStaffRepository.findByCourseAndGithubLogin(course.get(), githubLogin); | |
166 | log.info("Student found: {}", student.isPresent()); | |
167 | log.info("Staff found: {}", staff.isPresent()); | |
168 | ||
169 |
2
1. createGitHubWebhook : negated conditional → KILLED 2. createGitHubWebhook : negated conditional → KILLED |
if (!student.isPresent() && !staff.isPresent()) { |
170 | log.warn( | |
171 | "No student or staff found with GitHub login: {} in course: {}", | |
172 | githubLogin, | |
173 | course.get().getCourseName()); | |
174 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
175 | } | |
176 | StringBuilder response = new StringBuilder(); | |
177 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (student.isPresent()) { |
178 | RosterStudent updatedStudent = student.get(); | |
179 | log.info("Current student org status: {}", updatedStudent.getOrgStatus()); | |
180 | ||
181 | // Update status based on action | |
182 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (action.equals("member_added")) { |
183 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/RosterStudent::setOrgStatus → KILLED |
updatedStudent.setOrgStatus(role); |
184 | log.info("Setting status to {}", role.toString()); | |
185 | } else { // must be "member_invited" based on earlier check | |
186 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/RosterStudent::setOrgStatus → KILLED |
updatedStudent.setOrgStatus(OrgStatus.INVITED); |
187 | log.info("Setting status to INVITED"); | |
188 | } | |
189 | ||
190 | rosterStudentRepository.save(updatedStudent); | |
191 | log.info("Student saved with new org status: {}", updatedStudent.getOrgStatus()); | |
192 | response.append(updatedStudent); | |
193 | } | |
194 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (staff.isPresent()) { |
195 | CourseStaff updatedStaff = staff.get(); | |
196 | log.info("Current course staff member org status: {}", updatedStaff.getOrgStatus()); | |
197 | ||
198 | // Update status based on action | |
199 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (action.equals("member_added")) { |
200 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/CourseStaff::setOrgStatus → KILLED |
updatedStaff.setOrgStatus(role); |
201 | log.info("Setting status to {}", role.toString()); | |
202 | } else { // must be "member_invited" based on earlier check | |
203 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/CourseStaff::setOrgStatus → KILLED |
updatedStaff.setOrgStatus(OrgStatus.INVITED); |
204 | log.info("Setting status to INVITED"); | |
205 | } | |
206 | ||
207 | courseStaffRepository.save(updatedStaff); | |
208 | log.info("Course staff member saved with new org status: {}", updatedStaff.getOrgStatus()); | |
209 | response.append(updatedStaff); | |
210 | } | |
211 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body(response.toString()); |
212 | } | |
213 | } | |
Mutations | ||
64 |
1.1 |
|
66 |
1.1 |
|
75 |
1.1 |
|
80 |
1.1 |
|
81 |
1.1 |
|
88 |
1.1 |
|
89 |
1.1 2.2 |
|
90 |
1.1 |
|
97 |
1.1 |
|
99 |
1.1 |
|
100 |
1.1 |
|
106 |
1.1 |
|
110 |
1.1 2.2 |
|
111 |
1.1 |
|
120 |
1.1 |
|
121 |
1.1 |
|
122 |
1.1 |
|
123 |
1.1 |
|
124 |
1.1 |
|
125 |
1.1 |
|
126 |
1.1 |
|
127 |
1.1 |
|
133 |
1.1 |
|
141 |
1.1 |
|
142 |
1.1 |
|
143 |
1.1 |
|
144 |
1.1 |
|
145 |
1.1 |
|
157 |
1.1 |
|
159 |
1.1 |
|
169 |
1.1 2.2 |
|
174 |
1.1 |
|
177 |
1.1 |
|
182 |
1.1 |
|
183 |
1.1 |
|
186 |
1.1 |
|
194 |
1.1 |
|
199 |
1.1 |
|
200 |
1.1 |
|
203 |
1.1 |
|
211 |
1.1 |