| 1 | package edu.ucsb.cs.scaffold.controller; | |
| 2 | ||
| 3 | import edu.ucsb.cs.scaffold.entity.PatCredential; | |
| 4 | import edu.ucsb.cs.scaffold.enums.PatPlatform; | |
| 5 | import edu.ucsb.cs.scaffold.errors.EntityNotFoundException; | |
| 6 | import edu.ucsb.cs.scaffold.repository.PatCredentialRepository; | |
| 7 | import edu.ucsb.cs.scaffold.services.PatEncryptionService; | |
| 8 | import io.swagger.v3.oas.annotations.Operation; | |
| 9 | import io.swagger.v3.oas.annotations.Parameter; | |
| 10 | import io.swagger.v3.oas.annotations.tags.Tag; | |
| 11 | import java.time.LocalDate; | |
| 12 | import java.util.Map; | |
| 13 | import java.util.regex.Pattern; | |
| 14 | import lombok.extern.slf4j.Slf4j; | |
| 15 | import org.springframework.beans.factory.annotation.Autowired; | |
| 16 | import org.springframework.format.annotation.DateTimeFormat; | |
| 17 | import org.springframework.http.HttpStatus; | |
| 18 | import org.springframework.security.access.prepost.PreAuthorize; | |
| 19 | import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 20 | import org.springframework.web.bind.annotation.GetMapping; | |
| 21 | import org.springframework.web.bind.annotation.PostMapping; | |
| 22 | import org.springframework.web.bind.annotation.RequestMapping; | |
| 23 | import org.springframework.web.bind.annotation.RequestParam; | |
| 24 | import org.springframework.web.bind.annotation.ResponseStatus; | |
| 25 | import org.springframework.web.bind.annotation.RestController; | |
| 26 | ||
| 27 | /** | |
| 28 | * Lets each admin or instructor store one personal access token (PAT) per platform: a GitHub PAT at | |
| 29 | * /api/pat/github and a PrairieLearn PAT at /api/pat/pl. Tokens are encrypted before they are saved | |
| 30 | * and are write-only: no endpoint ever returns one, only metadata (last four characters, expiration | |
| 31 | * date). To replace a lost or expired token, the user simply POSTs a new one. See | |
| 32 | * docs/Github_PAT.md and docs/PrairieLearn_PAT.md for how to create suitable tokens. | |
| 33 | */ | |
| 34 | @Tag(name = "PatCredential") | |
| 35 | @RequestMapping("/api/pat") | |
| 36 | @RestController | |
| 37 | @Slf4j | |
| 38 | public class PatCredentialController extends ApiController { | |
| 39 | ||
| 40 | // A GitHub classic PAT: "ghp_" followed by letters and digits. Only classic tokens work here: | |
| 41 | // a fine-grained token can only be scoped to repos owned by the user's own account or an org | |
| 42 | // the user is a member of, and this app's users are outside collaborators on repos owned by | |
| 43 | // another org — so a fine-grained token can never reach them. See docs/PAT-design.md. | |
| 44 | private static final Pattern CLASSIC_PAT_PATTERN = Pattern.compile("^ghp_[A-Za-z0-9]{20,244}$"); | |
| 45 | ||
| 46 | @Autowired private PatCredentialRepository patCredentialRepository; | |
| 47 | ||
| 48 | @Autowired private PatEncryptionService patEncryptionService; | |
| 49 | ||
| 50 | @Operation( | |
| 51 | summary = "Get metadata about the current user's stored GitHub PAT (never the token itself)") | |
| 52 | @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')") | |
| 53 | @GetMapping("/github") | |
| 54 | public PatCredential getGithubPatCredential() { | |
| 55 |
1
1. getGithubPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getGithubPatCredential → KILLED |
return getPatCredential(PatPlatform.GITHUB); |
| 56 | } | |
| 57 | ||
| 58 | @Operation( | |
| 59 | summary = | |
| 60 | "Get metadata about the current user's stored PrairieLearn PAT (never the token itself)") | |
| 61 | @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')") | |
| 62 | @GetMapping("/pl") | |
| 63 | public PatCredential getPlPatCredential() { | |
| 64 |
1
1. getPlPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getPlPatCredential → KILLED |
return getPatCredential(PatPlatform.PRAIRIELEARN); |
| 65 | } | |
| 66 | ||
| 67 | @Operation(summary = "Set (create or replace) the current user's GitHub PAT") | |
| 68 | @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')") | |
| 69 | @PostMapping("/github") | |
| 70 | public PatCredential postGithubPatCredential( | |
| 71 | @Parameter(name = "token", description = "GitHub classic PAT (starts with ghp_)") | |
| 72 | @RequestParam | |
| 73 | String token, | |
| 74 | @Parameter( | |
| 75 | name = "expiresAt", | |
| 76 | description = "Expiration date of the token in ISO format, e.g. 2026-12-31") | |
| 77 | @RequestParam(required = false) | |
| 78 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | |
| 79 | LocalDate expiresAt) { | |
| 80 | String trimmedToken = token.strip(); | |
| 81 |
1
1. postGithubPatCredential : removed call to edu/ucsb/cs/scaffold/controller/PatCredentialController::validateGithubToken → KILLED |
validateGithubToken(trimmedToken); |
| 82 |
1
1. postGithubPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::postGithubPatCredential → KILLED |
return savePatCredential(PatPlatform.GITHUB, trimmedToken, expiresAt); |
| 83 | } | |
| 84 | ||
| 85 | @Operation(summary = "Set (create or replace) the current user's PrairieLearn PAT") | |
| 86 | @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')") | |
| 87 | @PostMapping("/pl") | |
| 88 | public PatCredential postPlPatCredential( | |
| 89 | @Parameter(name = "token", description = "PrairieLearn personal access token") @RequestParam | |
| 90 | String token, | |
| 91 | @Parameter( | |
| 92 | name = "expiresAt", | |
| 93 | description = "Expiration date of the token in ISO format, e.g. 2026-12-31") | |
| 94 | @RequestParam(required = false) | |
| 95 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | |
| 96 | LocalDate expiresAt) { | |
| 97 | String trimmedToken = token.strip(); | |
| 98 |
1
1. postPlPatCredential : negated conditional → KILLED |
if (trimmedToken.isBlank()) { |
| 99 | throw new IllegalArgumentException("token is required"); | |
| 100 | } | |
| 101 |
1
1. postPlPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::postPlPatCredential → KILLED |
return savePatCredential(PatPlatform.PRAIRIELEARN, trimmedToken, expiresAt); |
| 102 | } | |
| 103 | ||
| 104 | private PatCredential getPatCredential(PatPlatform platform) { | |
| 105 | long userId = getCurrentUser().getUser().getId(); | |
| 106 |
1
1. getPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getPatCredential → KILLED |
return patCredentialRepository |
| 107 | .findByUserIdAndPlatform(userId, platform) | |
| 108 |
1
1. lambda$getPatCredential$0 : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::lambda$getPatCredential$0 → KILLED |
.orElseThrow(() -> new EntityNotFoundException(PatCredential.class, userId)); |
| 109 | } | |
| 110 | ||
| 111 | private PatCredential savePatCredential( | |
| 112 | PatPlatform platform, String trimmedToken, LocalDate expiresAt) { | |
| 113 | PatEncryptionService.EncryptedPat encrypted = patEncryptionService.encrypt(trimmedToken); | |
| 114 | ||
| 115 | long userId = getCurrentUser().getUser().getId(); | |
| 116 | PatCredential credential = | |
| 117 | patCredentialRepository | |
| 118 | .findByUserIdAndPlatform(userId, platform) | |
| 119 |
1
1. lambda$savePatCredential$1 : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::lambda$savePatCredential$1 → KILLED |
.orElseGet(() -> PatCredential.builder().userId(userId).platform(platform).build()); |
| 120 |
1
1. savePatCredential : removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setCiphertext → KILLED |
credential.setCiphertext(encrypted.ciphertext()); |
| 121 |
1
1. savePatCredential : removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setKeyVersion → KILLED |
credential.setKeyVersion(encrypted.keyVersion()); |
| 122 |
2
1. savePatCredential : Replaced integer subtraction with addition → KILLED 2. savePatCredential : removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setLastFour → KILLED |
credential.setLastFour(trimmedToken.substring(trimmedToken.length() - 4)); |
| 123 |
1
1. savePatCredential : removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setExpiresAt → KILLED |
credential.setExpiresAt(expiresAt); |
| 124 |
1
1. savePatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::savePatCredential → KILLED |
return patCredentialRepository.save(credential); |
| 125 | } | |
| 126 | ||
| 127 | private void validateGithubToken(String token) { | |
| 128 |
1
1. validateGithubToken : negated conditional → KILLED |
if (token.isBlank()) { |
| 129 | throw new IllegalArgumentException("token is required"); | |
| 130 | } | |
| 131 |
1
1. validateGithubToken : negated conditional → KILLED |
if (token.startsWith("github_pat_")) { |
| 132 | throw new IllegalArgumentException( | |
| 133 | "fine-grained tokens (github_pat_...) cannot reach this app's repositories; create a classic token (ghp_...) instead — see docs/Github_PAT.md"); | |
| 134 | } | |
| 135 |
1
1. validateGithubToken : negated conditional → KILLED |
if (!CLASSIC_PAT_PATTERN.matcher(token).matches()) { |
| 136 | throw new IllegalArgumentException( | |
| 137 | "token must be a GitHub classic personal access token (starting with ghp_); see docs/Github_PAT.md"); | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * Thrown by PatEncryptionService when no PAT_ENCRYPTION_KEY is configured — a server-side | |
| 143 | * deployment problem, not a client error, so it maps to 503 rather than 400. | |
| 144 | */ | |
| 145 | @ExceptionHandler({IllegalStateException.class}) | |
| 146 | @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) | |
| 147 | public Object handleIllegalStateException(Throwable e) { | |
| 148 |
1
1. handleIllegalStateException : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::handleIllegalStateException → KILLED |
return Map.of( |
| 149 | "type", e.getClass().getSimpleName(), | |
| 150 | "message", e.getMessage()); | |
| 151 | } | |
| 152 | } | |
Mutations | ||
| 55 |
1.1 |
|
| 64 |
1.1 |
|
| 81 |
1.1 |
|
| 82 |
1.1 |
|
| 98 |
1.1 |
|
| 101 |
1.1 |
|
| 106 |
1.1 |
|
| 108 |
1.1 |
|
| 119 |
1.1 |
|
| 120 |
1.1 |
|
| 121 |
1.1 |
|
| 122 |
1.1 2.2 |
|
| 123 |
1.1 |
|
| 124 |
1.1 |
|
| 128 |
1.1 |
|
| 131 |
1.1 |
|
| 135 |
1.1 |
|
| 148 |
1.1 |