@file:DependsOn("com.fasterxml.jackson.core:jackson-databind:2.20.0") @file:DependsOn("org.xerial:sqlite-jdbc:3.50.3.0") import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpServer import java.net.InetSocketAddress import java.nio.charset.StandardCharsets.UTF_8 import java.security.MessageDigest import java.sql.SQLException import java.time.Instant import java.util.HexFormat import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import org.sqlite.SQLiteDataSource val json = ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) val secretConfig = json.readTree(System.getenv("SERVICEAPP_WEBHOOK_SECRETS") ?: error("Укажите секреты")) require(secretConfig.isObject && !secretConfig.isEmpty) val secrets = secretConfig.properties().associate { (key, value) -> require(key.isNotEmpty() && value.isTextual && value.asText().isNotEmpty()) key to value.asText() } val subscriptionId = System.getenv("SERVICEAPP_WEBHOOK_SUBSCRIPTION_ID")?.takeIf { it.isNotEmpty() } ?: error("Укажите ID подписки") val database = System.getenv("SERVICEAPP_WEBHOOK_DB") ?: "webhook-inbox.sqlite3" fun header(exchange: HttpExchange, name: String): String { val values = exchange.requestHeaders[name] require(values != null && values.size == 1) return values.single() } fun verifySignature(rawBody: ByteArray, timestamp: String, signature: String, secret: String) { require(signature.matches(Regex("v1=[0-9a-f]{64}"))) val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(secret.toByteArray(UTF_8), "HmacSHA256")) mac.update("v1.$timestamp.".toByteArray(UTF_8)) val expected = mac.doFinal(rawBody) require(MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature.substring(3)))) } fun storeEvent(eventId: String, rawBody: ByteArray) { val dataSource = SQLiteDataSource() dataSource.url = "jdbc:sqlite:$database" dataSource.connection.use { connection -> connection.createStatement().use { statement -> 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))""") } connection.prepareStatement("""INSERT INTO inbox (subscription_id, event_id, body) VALUES (?, ?, ?) ON CONFLICT(subscription_id, event_id) DO NOTHING""").use { statement -> statement.setString(1, subscriptionId) statement.setString(2, eventId) statement.setBytes(3, rawBody) statement.executeUpdate() } } } fun receive(exchange: HttpExchange): Int { if (exchange.requestMethod != "POST") { exchange.responseHeaders.set("Allow", "POST") return 405 } val timestamp = header(exchange, "X-ServiceApp-Timestamp") val signature = header(exchange, "X-ServiceApp-Signature") val eventId = header(exchange, "X-ServiceApp-Event-Id") val secret = secrets[header(exchange, "X-ServiceApp-Key-Id")] ?: return 400 if (!timestamp.matches(Regex("[0-9]{1,20}"))) return 400 val seconds = timestamp.toLongOrNull() ?: return 400 val now = Instant.now().epochSecond if (seconds !in (now - 300)..(now + 300)) return 400 if (exchange.requestHeaders.containsKey("Transfer-Encoding")) return 400 val length = header(exchange, "Content-Length").toIntOrNull() ?: return 400 if (length < 1) return 413 if (!header(exchange, "Content-Type").substringBefore(';').trim().equals("application/json", true)) return 415 val rawBody = exchange.requestBody.readNBytes(65537) if (length > 65536 || rawBody.size > 65536) return 413 if (rawBody.size != length) return 400 verifySignature(rawBody, timestamp, signature, secret) val event = json.readTree(rawBody) if (!eventId.matches(Regex("[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")) || event?.isObject != true || event.path("event_id").asText() != eventId || !event.path("schema_version").isIntegralNumber || event.path("schema_version").asText() != "1" || !event.path("resource").isObject) return 400 storeEvent(eventId, rawBody) return 204 } val server = HttpServer.create(InetSocketAddress("127.0.0.1", 8080), 0) server.createContext("/") { exchange -> exchange.use { val status = try { receive(exchange) } catch (_: SQLException) { 503 } catch (_: com.fasterxml.jackson.core.JacksonException) { 400 } catch (_: IllegalArgumentException) { 400 } if (status == 503) exchange.responseHeaders.set("Retry-After", "2") exchange.sendResponseHeaders(status, -1) } } server.start()