import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
@RestController
public class WebhookController {
private final String secret;
private final ObjectMapper mapper;
private final EventInbox inbox;
public WebhookController(@Value("${YCLOUD_WEBHOOK_SECRET}") String secret,
ObjectMapper mapper, EventInbox inbox) {
if (secret.isBlank()) throw new IllegalArgumentException("Missing webhook secret");
this.secret = secret;
this.mapper = mapper;
this.inbox = inbox;
}
@PostMapping("/webhook")
public ResponseEntity<Void> receive(
@RequestHeader(value = "YCloud-Signature", required = false) String header,
@RequestBody byte[] body) {
if (!verify(body, header)) return ResponseEntity.status(401).build();
JsonNode event;
try {
event = mapper.readTree(body);
if (event == null || !event.path("id").isTextual() || !event.path("type").isTextual()) {
return ResponseEntity.badRequest().build();
}
} catch (Exception invalidJson) {
return ResponseEntity.badRequest().build();
}
try {
inbox.insertIfAbsent(event.path("id").asText(), body);
return ResponseEntity.noContent().build();
} catch (Exception storageFailure) {
return ResponseEntity.status(503).build();
}
}
private boolean verify(byte[] body, String header) {
if (header == null) return false;
String timestamp = null;
java.util.List<String> signatures = new java.util.ArrayList<>();
for (String part : header.split(",")) {
String[] pair = part.trim().split("=", 2);
if (pair.length != 2) continue;
if (pair[0].equals("t")) {
if (timestamp != null) return false;
timestamp = pair[1];
}
if (pair[0].equals("s")) signatures.add(pair[1]);
}
if (timestamp == null || !timestamp.matches("[0-9]{1,12}")) return false;
try {
long age = Instant.now().getEpochSecond() - Long.parseLong(timestamp);
if (Math.abs(age) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(body);
for (String signature : signatures) {
if (signature.matches("[a-fA-F0-9]{64}") &&
MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature))) return true;
}
return false;
} catch (Exception invalidSignature) {
return false;
}
}
public interface EventInbox {
void insertIfAbsent(String eventId, byte[] eventBody) throws Exception;
}
}