//JAVA 21+ //DEPS com.fasterxml.jackson.core:jackson-databind:2.20.0 //DEPS org.xerial:sqlite-jdbc:3.50.3.0 import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.sql.DriverManager; import java.sql.SQLException; import java.time.Instant; import java.util.HashMap; import java.util.HexFormat; import java.util.Map; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; public class WebhookReceiver { private static final ObjectMapper JSON = new ObjectMapper() .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); static String header(HttpExchange exchange, String name) { var values = exchange.getRequestHeaders().get(name); if (values == null || values.size() != 1) { throw new IllegalArgumentException("Неверный заголовок"); } return values.get(0); } static void verifySignature(byte[] rawBody, String timestamp, String signature, String secret) throws GeneralSecurityException { if (!signature.matches("v1=[0-9a-f]{64}")) { throw new IllegalArgumentException("Неверный формат подписи"); } var mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update(("v1." + timestamp + ".").getBytes(StandardCharsets.UTF_8)); var expected = mac.doFinal(rawBody); if (!MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature.substring(3)))) { throw new IllegalArgumentException("Неверная подпись"); } } static void storeEvent(String database, String subscriptionId, String eventId, byte[] rawBody) throws SQLException { try (var connection = DriverManager.getConnection("jdbc:sqlite:" + database)) { try (var statement = connection.createStatement()) { statement.execute("PRAGMA busy_timeout = 5000"); statement.execute(""" CREATE TABLE IF NOT EXISTS inbox ( subscription_id TEXT NOT NULL, event_id TEXT NOT NULL, body BLOB NOT NULL, state TEXT NOT NULL DEFAULT 'pending', PRIMARY KEY (subscription_id, event_id)) """); } try (var statement = connection.prepareStatement(""" INSERT INTO inbox (subscription_id, event_id, body) VALUES (?, ?, ?) ON CONFLICT(subscription_id, event_id) DO NOTHING """)) { statement.setString(1, subscriptionId); statement.setString(2, eventId); statement.setBytes(3, rawBody); statement.executeUpdate(); } } } static int receive(HttpExchange exchange, Map secrets, String subscriptionId, String database) throws IOException, GeneralSecurityException, SQLException { if (!exchange.getRequestMethod().equals("POST")) { exchange.getResponseHeaders().set("Allow", "POST"); return 405; } var timestamp = header(exchange, "X-ServiceApp-Timestamp"); var signature = header(exchange, "X-ServiceApp-Signature"); var eventId = header(exchange, "X-ServiceApp-Event-Id"); var secret = secrets.get(header(exchange, "X-ServiceApp-Key-Id")); if (secret == null || !timestamp.matches("[0-9]{1,20}")) return 400; var seconds = Long.parseLong(timestamp); var now = Instant.now().getEpochSecond(); if (seconds < now - 300 || seconds > now + 300) return 400; if (exchange.getRequestHeaders().containsKey("Transfer-Encoding")) return 400; var length = Integer.parseInt(header(exchange, "Content-Length")); if (length < 1) return 413; if (!header(exchange, "Content-Type").split(";", 2)[0].trim().equalsIgnoreCase("application/json")) return 415; var rawBody = exchange.getRequestBody().readNBytes(65537); if (length > 65536 || rawBody.length > 65536) return 413; if (rawBody.length != length) return 400; verifySignature(rawBody, timestamp, signature, secret); var event = JSON.readTree(rawBody); if (!eventId.matches("[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}") || event == null || !event.isObject() || !event.path("event_id").asText().equals(eventId) || !event.path("schema_version").isIntegralNumber() || !event.path("schema_version").asText().equals("1") || !event.path("resource").isObject()) return 400; storeEvent(database, subscriptionId, eventId, rawBody); return 204; } public static void main(String[] args) throws Exception { JsonNode config = JSON.readTree(System.getenv("SERVICEAPP_WEBHOOK_SECRETS")); var subscriptionId = System.getenv("SERVICEAPP_WEBHOOK_SUBSCRIPTION_ID"); if (config == null || !config.isObject() || config.isEmpty() || subscriptionId == null || subscriptionId.isEmpty()) { throw new IllegalArgumentException("Укажите секреты и ID подписки"); } Map secrets = new HashMap<>(); for (var field : config.properties()) { if (field.getKey().isEmpty() || !field.getValue().isTextual() || field.getValue().asText().isEmpty()) { throw new IllegalArgumentException("Укажите пары идентификаторов ключей и секретов"); } secrets.put(field.getKey(), field.getValue().asText()); } var database = System.getenv().getOrDefault("SERVICEAPP_WEBHOOK_DB", "webhook-inbox.sqlite3"); var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 8080), 0); server.createContext("/", exchange -> { try (exchange) { int status; try { status = receive(exchange, secrets, subscriptionId, database); } catch (SQLException | GeneralSecurityException exception) { status = 503; } catch (com.fasterxml.jackson.core.JacksonException | IllegalArgumentException exception) { status = 400; } if (status == 503) exchange.getResponseHeaders().set("Retry-After", "2"); exchange.sendResponseHeaders(status, -1); } }); server.start(); } }