<?xml version="1.0" encoding="UTF-8"?>
<!--
  Webhook receiver (api-platform §13.3; Standard Webhooks). An HTTP listener verifies
  `webhook-signature` over `{webhook-id}.{webhook-timestamp}.{body}` with HMAC-SHA256 and the
  endpoint's `whsec_` secret (every `v1,` signature is tried, so a rotation overlap verifies with
  either secret), refuses timestamps more than 5 minutes off, dedupes on `webhook-id` for 30 days
  of retention in the global map (process once, acknowledge every duplicate with 2xx), answers
  2xx quickly and hands the event to the site's downstream channel.

  Configuration map keys:
    ingest.webhook.secrets     comma-separated `whsec_…` values (current, and previous during overlap)
    ingest.webhook.downstream  the channel name that consumes the verified events
-->
<channel version="4.5.0">
  <id>0b1d2c3e-4f50-4a61-8b72-93a4b5c6d7e3</id>
  <nextMetaDataId>2</nextMetaDataId>
  <name>Integration API - Webhook receiver</name>
  <description>Verifies Standard Webhooks signatures, dedupes on webhook-id and forwards the event.</description>
  <revision>1</revision>
  <sourceConnector version="4.5.0">
    <metaDataId>0</metaDataId>
    <name>sourceConnector</name>
    <properties class="com.mirth.connect.connectors.http.HttpReceiverProperties" version="4.5.0">
      <pluginProperties/>
      <listenerConnectorProperties version="4.5.0">
        <host>0.0.0.0</host>
        <port>8443</port>
      </listenerConnectorProperties>
      <sourceConnectorProperties version="4.5.0">
        <responseVariable>webhookResponse</responseVariable>
        <respondAfterProcessing>true</respondAfterProcessing>
        <processBatch>false</processBatch>
        <firstResponse>false</firstResponse>
        <processingThreads>4</processingThreads>
        <queueBufferSize>1000</queueBufferSize>
      </sourceConnectorProperties>
      <xmlBody>false</xmlBody>
      <parseMultipart>false</parseMultipart>
      <includeMetadata>true</includeMetadata>
      <binaryMimeTypes>application/.*(?&lt;!json|xml)$|image/.*|video/.*|audio/.*</binaryMimeTypes>
      <binaryMimeTypesRegex>true</binaryMimeTypesRegex>
      <responseContentType>application/json</responseContentType>
      <responseDataTypeBinary>false</responseDataTypeBinary>
      <responseStatusCode>${webhookStatus}</responseStatusCode>
      <responseHeaders class="linked-hash-map"/>
      <charset>UTF-8</charset>
      <contextPath>/webhooks</contextPath>
      <timeout>15000</timeout>
      <staticResources/>
    </properties>
    <transformer version="4.5.0">
      <elements>
        <com.mirth.connect.plugins.javascriptstep.JavaScriptStep version="4.5.0">
          <name>Verify and dedupe</name>
          <sequenceNumber>0</sequenceNumber>
          <enabled>true</enabled>
          <script><![CDATA[
var header = function (name) {
  var value = sourceMap.get('headers').getHeader(name);
  return value === null ? '' : String(value);
};
var id = header('webhook-id');
var timestamp = header('webhook-timestamp');
var signatures = header('webhook-signature').split(' ');
var body = String(connectorMessage.getRawData());

function hmacBase64(secret, content) {
  var raw = java.util.Base64.getDecoder().decode(String(secret).replace(/^whsec_/, ''));
  var mac = javax.crypto.Mac.getInstance('HmacSHA256');
  mac.init(new javax.crypto.spec.SecretKeySpec(raw, 'HmacSHA256'));
  return java.util.Base64.getEncoder().encodeToString(
    mac.doFinal(new java.lang.String(content).getBytes('UTF-8'))
  );
}

function constantTimeEquals(a, b) {
  return java.security.MessageDigest.isEqual(
    new java.lang.String(a).getBytes('UTF-8'),
    new java.lang.String(b).getBytes('UTF-8')
  );
}

var verified = false;
var now = Math.floor(java.lang.System.currentTimeMillis() / 1000);
var ts = parseInt(timestamp, 10);
if (id !== '' && !isNaN(ts) && Math.abs(now - ts) <= 300) {
  var secrets = String(configurationMap.get('ingest.webhook.secrets') || '').split(',');
  var content = id + '.' + timestamp + '.' + body;
  for (var s = 0; s < secrets.length && !verified; s++) {
    if (secrets[s].trim() === '') {
      continue;
    }
    var expected = hmacBase64(secrets[s].trim(), content);
    for (var i = 0; i < signatures.length; i++) {
      var parts = signatures[i].split(',');
      if (parts[0] === 'v1' && parts.length === 2 && constantTimeEquals(parts[1], expected)) {
        verified = true;
        break;
      }
    }
  }
}

if (!verified) {
  channelMap.put('webhookStatus', '401');
  responseMap.put('webhookResponse', '{"status":"invalid_signature"}');
  destinationSet.removeAll();
  return;
}

// Dedupe on webhook-id: retries and redeliveries carry the same id (AC-API-14).
var seenKey = 'ingest.webhook.seen.' + id;
if (globalMap.get(seenKey) !== null && globalMap.get(seenKey) !== undefined) {
  channelMap.put('webhookStatus', '200');
  responseMap.put('webhookResponse', '{"status":"duplicate"}');
  destinationSet.removeAll();
  return;
}
globalMap.put(seenKey, java.lang.System.currentTimeMillis());
channelMap.put('webhookStatus', '200');
responseMap.put('webhookResponse', '{"status":"accepted"}');
]]></script>
        </com.mirth.connect.plugins.javascriptstep.JavaScriptStep>
      </elements>
      <inboundDataType>RAW</inboundDataType>
      <outboundDataType>RAW</outboundDataType>
    </transformer>
    <filter version="4.5.0">
      <elements/>
    </filter>
    <transportName>HTTP Listener</transportName>
    <mode>SOURCE</mode>
    <enabled>true</enabled>
    <waitForPrevious>true</waitForPrevious>
  </sourceConnector>
  <destinationConnectors>
    <connector version="4.5.0">
      <metaDataId>1</metaDataId>
      <name>Forward verified event</name>
      <properties class="com.mirth.connect.connectors.vm.VmDispatcherProperties" version="4.5.0">
        <pluginProperties/>
        <destinationConnectorProperties version="4.5.0">
          <queueEnabled>true</queueEnabled>
          <sendFirst>true</sendFirst>
          <retryIntervalMillis>10000</retryIntervalMillis>
          <regenerateTemplate>false</regenerateTemplate>
          <retryCount>0</retryCount>
          <rotate>false</rotate>
          <includeFilterTransformer>false</includeFilterTransformer>
          <threadCount>1</threadCount>
          <threadAssignmentVariable/>
          <validateResponse>false</validateResponse>
          <resourceIds class="linked-hash-map">
            <entry>
              <string>Default Resource</string>
              <string>[Default Resource]</string>
            </entry>
          </resourceIds>
          <queueBufferSize>1000</queueBufferSize>
          <reattachAttachments>true</reattachAttachments>
        </destinationConnectorProperties>
        <channelId>${ingest.webhook.downstream}</channelId>
        <channelTemplate>${message.encodedData}</channelTemplate>
        <mapVariables/>
      </properties>
      <transformer version="4.5.0">
        <elements/>
        <inboundDataType>RAW</inboundDataType>
        <outboundDataType>RAW</outboundDataType>
      </transformer>
      <responseTransformer version="4.5.0">
        <elements/>
        <inboundDataType>RAW</inboundDataType>
        <outboundDataType>RAW</outboundDataType>
      </responseTransformer>
      <filter version="4.5.0">
        <elements/>
      </filter>
      <transportName>Channel Writer</transportName>
      <mode>DESTINATION</mode>
      <enabled>true</enabled>
      <waitForPrevious>true</waitForPrevious>
    </connector>
  </destinationConnectors>
  <preprocessingScript>return message;</preprocessingScript>
  <postprocessingScript>return;</postprocessingScript>
  <deployScript>return;</deployScript>
  <undeployScript>return;</undeployScript>
  <properties version="4.5.0">
    <clearGlobalChannelMap>true</clearGlobalChannelMap>
    <messageStorageMode>PRODUCTION</messageStorageMode>
    <encryptData>true</encryptData>
    <removeContentOnCompletion>true</removeContentOnCompletion>
    <removeOnlyFilteredOnCompletion>false</removeOnlyFilteredOnCompletion>
    <removeAttachmentsOnCompletion>true</removeAttachmentsOnCompletion>
    <initialState>STARTED</initialState>
    <storeAttachments>false</storeAttachments>
    <metaDataColumns/>
    <attachmentProperties version="4.5.0">
      <type>None</type>
      <properties/>
    </attachmentProperties>
    <resourceIds class="linked-hash-map">
      <entry>
        <string>Default Resource</string>
        <string>[Default Resource]</string>
      </entry>
    </resourceIds>
  </properties>
</channel>
