Add webhook-snippets in all languages

This commit is contained in:
pabloFuente 2025-09-05 14:09:06 +02:00
parent bd8c0a7b5d
commit ce900fdbed
20 changed files with 1824 additions and 101 deletions

37
webhooks-snippets/dotnet/.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
*.swp
*.*~
project.lock.json
.DS_Store
*.pyc
nupkg/
# Visual Studio Code
.vscode
# Rider
.idea
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
build/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
msbuild.log
msbuild.err
msbuild.wrn
# Visual Studio 2015
.vs/

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,22 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenViduMeetWebhookHandler", "OpenViduMeetWebhookHandler.csproj", "{15FDC2D1-F92F-4787-959E-06421AEEEBA0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{15FDC2D1-F92F-4787-959E-06421AEEEBA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{15FDC2D1-F92F-4787-959E-06421AEEEBA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{15FDC2D1-F92F-4787-959E-06421AEEEBA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{15FDC2D1-F92F-4787-959E-06421AEEEBA0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,31 @@
using System.Text.Json;
const int SERVER_PORT = 5080;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/webhook", async (HttpContext context) =>
{
using var reader = new StreamReader(context.Request.Body);
var bodyContent = await reader.ReadToEndAsync();
var headers = context.Request.Headers.ToDictionary(
h => h.Key.ToLower(),
h => h.Value.ToString()
);
if (!WebhookValidator.IsWebhookEventValid(bodyContent, headers))
{
Console.WriteLine("Invalid webhook signature");
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Invalid webhook signature");
return;
}
Console.WriteLine($"Webhook received: {bodyContent}");
context.Response.StatusCode = 200;
});
Console.WriteLine($"Webhook server listening on port {SERVER_PORT}");
app.Run($"http://0.0.0.0:{SERVER_PORT}");

View File

@ -0,0 +1,40 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public class WebhookValidator
{
private const long MAX_WEBHOOK_AGE = 120 * 1000; // 2 minutes in milliseconds
private const string OPENVIDU_MEET_API_KEY = "meet-api-key";
public static bool IsWebhookEventValid(string body, Dictionary<string, string> headers)
{
if (!headers.TryGetValue("x-signature", out var signature) ||
!headers.TryGetValue("x-timestamp", out var timestampStr))
{
return false;
}
if (!long.TryParse(timestampStr, out long timestamp))
{
return false;
}
long current = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long diffTime = current - timestamp;
if (diffTime >= MAX_WEBHOOK_AGE)
{
return false;
}
string signedPayload = $"{timestamp}.{body}";
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(OPENVIDU_MEET_API_KEY)))
{
byte[] expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
byte[] actual = Convert.FromHexString(signature);
return CryptographicOperations.FixedTimeEquals(expected, actual);
}
}
}

View File

@ -3,9 +3,9 @@ package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
@ -15,59 +15,62 @@ import (
)
const (
SERVER_PORT = "5080"
API_KEY = "meet-api-key"
MAX_ELAPSED_TIME = 5 * time.Minute
serverPort = "5080"
maxWebhookAge = 120 * 1000 // 2 minutes in milliseconds
openviduMeetApiKey = "meet-api-key"
)
func main() {
router := gin.Default()
router.POST("/webhook", handleWebhook)
router.Run(":" + SERVER_PORT)
router.Run(":" + serverPort)
}
func handleWebhook(c *gin.Context) {
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return
}
signature := c.GetHeader("x-signature")
timestampStr := c.GetHeader("x-timestamp")
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid timestamp"})
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
if !isWebhookEventValid(body, signature, timestamp) {
if !isWebhookEventValid(bodyBytes, c.Request.Header) {
log.Println("Invalid webhook signature")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid webhook signature"})
return
}
event, _ := json.Marshal(body)
log.Println("Webhook received:", string(event))
log.Println("Webhook received:", string(bodyBytes))
c.Status(http.StatusOK)
}
func isWebhookEventValid(body map[string]interface{}, signature string, timestamp int64) bool {
current := time.Now().UnixMilli()
diffTime := current - timestamp
if diffTime > MAX_ELAPSED_TIME.Milliseconds() {
func isWebhookEventValid(bodyBytes []byte, headers http.Header) bool {
signature := headers.Get("x-signature")
tsStr := headers.Get("x-timestamp")
if signature == "" || tsStr == "" {
return false
}
bodyBytes, err := json.Marshal(body)
timestamp, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
return false
}
signedPayload := fmt.Sprintf("%d.%s", timestamp, string(bodyBytes))
h := hmac.New(sha256.New, []byte(API_KEY))
h.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(h.Sum(nil))
current := time.Now().UnixMilli()
diffTime := current - timestamp
if diffTime >= maxWebhookAge {
return false
}
return hmac.Equal([]byte(expectedSignature), []byte(signature))
signedPayload := tsStr + "." + string(bodyBytes)
mac := hmac.New(sha256.New, []byte(openviduMeetApiKey))
mac.Write([]byte(signedPayload))
expected := mac.Sum(nil)
actual, err := hex.DecodeString(signature)
if err != nil {
return false
}
return subtle.ConstantTimeCompare(expected, actual) == 1
}

View File

@ -1,15 +1,9 @@
package io.openvidu.meet.webhooks;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@ -18,15 +12,12 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
public class Controller {
private static final String API_KEY = "meet-api-key";
private static final long MAX_ELAPSED_TIME = 5 * 60 * 1000;
@PostMapping("/webhook")
public ResponseEntity<String> handleWebhook(@RequestBody String body,
@RequestHeader(value = "x-signature") String signature,
@RequestHeader(value = "x-timestamp") String timestampHeader) {
public ResponseEntity<String> handleWebhook(@RequestBody String body, @RequestHeader Map<String, String> headers) {
if (!isWebhookEventValid(body, signature, timestampHeader)) {
WebhookValidator validator = new WebhookValidator();
if (!validator.isWebhookEventValid(body, headers)) {
System.err.println("Invalid webhook signature");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid webhook signature");
}
@ -34,52 +25,4 @@ public class Controller {
System.out.println("Webhook received: " + body);
return ResponseEntity.ok().build();
}
private boolean isWebhookEventValid(String body, String signature, String timestampHeader) {
if (!StringUtils.hasText(signature) || !StringUtils.hasText(timestampHeader)) {
return false;
}
long timestamp;
try {
timestamp = Long.parseLong(timestampHeader);
} catch (NumberFormatException e) {
return false;
}
long current = Instant.now().toEpochMilli();
long diffTime = current - timestamp;
if (diffTime >= MAX_ELAPSED_TIME) {
return false;
}
String signedPayload = timestamp + "." + body;
String expectedSignature = hmacSha256(signedPayload, API_KEY);
return MessageDigest.isEqual(expectedSignature.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
}
private String hmacSha256(String data, String key) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (Exception e) {
throw new RuntimeException("Failed to calculate HMAC SHA-256", e);
}
}
private String bytesToHex(byte[] hash) {
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}

View File

@ -0,0 +1,70 @@
package io.openvidu.meet.webhooks;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public class WebhookValidator {
private static final long MAX_WEBHOOK_AGE = 120 * 1000; // 2 minutes in milliseconds
private static final String OPENVIDU_MEET_API_KEY = "meet-api-key";
public boolean isWebhookEventValid(Object body, Map<String, String> headers) {
String signature = headers.get("x-signature");
String ts = headers.get("x-timestamp");
if (signature == null || ts == null) return false;
long timestamp;
try {
timestamp = Long.parseLong(ts);
} catch (NumberFormatException e) {
return false;
}
long current = System.currentTimeMillis();
long diffTime = current - timestamp;
if (diffTime >= MAX_WEBHOOK_AGE) {
// Webhook event too old
return false;
}
String signedPayload = timestamp + "." + body.toString();
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(
new SecretKeySpec(
OPENVIDU_MEET_API_KEY.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
)
);
byte[] expected = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
byte[] actual = hexToBytes(signature);
return timingSafeEqual(expected, actual);
} catch (Exception e) {
return false;
}
}
// Helper method to convert hex string to byte array
private byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
// Time safe comparison to prevent timing attacks
private boolean timingSafeEqual(byte[] a, byte[] b) {
if (a.length != b.length) return false;
int result = 0;
for (int i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result == 0;
}
}

View File

@ -2,8 +2,8 @@ import express from "express";
import crypto from "crypto";
const SERVER_PORT = 5080;
const API_KEY = "meet-api-key";
const MAX_ELAPSED_TIME = 5 * 60 * 1000; // 5 minutes in milliseconds
const OPENVIDU_MEET_API_KEY = "meet-api-key";
const MAX_WEBHOOK_AGE = 120 * 1000; // 2 minutes in milliseconds
const app = express();
app.use(express.json());
@ -22,7 +22,7 @@ app.post("/webhook", (req, res) => {
});
app.listen(SERVER_PORT, () =>
console.log("Webhook server listening on port 3000")
console.log("Webhook server listening on port " + SERVER_PORT)
);
function isWebhookEventValid(body, headers) {
@ -35,17 +35,19 @@ function isWebhookEventValid(body, headers) {
const current = Date.now();
const diffTime = current - timestamp;
if (diffTime >= MAX_WEBHOOK_AGE) {
// Webhook event too old
return false;
}
const signedPayload = `${timestamp}.${JSON.stringify(body)}`;
const expectedSignature = crypto
.createHmac("sha256", API_KEY)
.update(signedPayload)
.createHmac("sha256", OPENVIDU_MEET_API_KEY)
.update(signedPayload, "utf8")
.digest("hex");
return (
crypto.timingSafeEqual(
Buffer.from(expectedSignature, "utf-8"),
Buffer.from(signature, "utf-8")
) && diffTime < MAX_ELAPSED_TIME
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, "hex"),
Buffer.from(signature, "hex")
);
}

View File

@ -0,0 +1,60 @@
<?php
const MAX_WEBHOOK_AGE = 120 * 1000; // 2 minutes in milliseconds
const OPENVIDU_MEET_API_KEY = "meet-api-key";
$requestPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($requestMethod === 'POST' && $requestPath === '/webhook') {
handleWebhook();
} else {
http_response_code(404);
echo "Not Found\n";
}
function handleWebhook()
{
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
// Convert header keys to lowercase for consistent access
$headers = array_change_key_case($headers, CASE_LOWER);
if (!isWebhookEventValid($body, $headers)) {
http_response_code(401);
echo "Invalid webhook signature\n";
return;
}
http_response_code(200);
$msg = "Webhook received: " . json_encode($body);
echo $msg . "\n";
error_log($msg); // Log to server console
}
function isWebhookEventValid($body, $headers)
{
$signature = $headers['x-signature'] ?? null;
$timestampStr = $headers['x-timestamp'] ?? null;
if (!$signature || !$timestampStr) {
return false;
}
$timestamp = filter_var($timestampStr, FILTER_VALIDATE_INT);
if ($timestamp === false) {
return false;
}
$current = intval(microtime(true) * 1000);
$diffTime = $current - $timestamp;
if ($diffTime >= MAX_WEBHOOK_AGE) {
return false;
}
$signedPayload = $timestamp . '.' . json_encode($body, JSON_UNESCAPED_SLASHES);
$expected = hash_hmac('sha256', $signedPayload, OPENVIDU_MEET_API_KEY);
return hash_equals($expected, $signature);
}

4
webhooks-snippets/php/start.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
echo "Webhook server listening on port 5080"
php -S 0.0.0.0:5080 index.php

159
webhooks-snippets/python/.gitignore vendored Normal file
View File

@ -0,0 +1,159 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

View File

@ -0,0 +1,57 @@
import hashlib
import hmac
import json
import time
from flask import Flask, request
SERVER_PORT = 5080
MAX_WEBHOOK_AGE = 120 * 1000 # 2 minutes in milliseconds
OPENVIDU_MEET_API_KEY = "meet-api-key"
app = Flask(__name__)
@app.route("/webhook", methods=["POST"])
def webhook():
body = request.get_json()
headers = request.headers
if not is_webhook_event_valid(body, headers):
print("Invalid webhook signature")
return "Invalid webhook signature", 401
print("Webhook received:", body)
return "", 200
def is_webhook_event_valid(body, headers):
signature = headers.get("x-signature")
timestamp_str = headers.get("x-timestamp")
if not signature or not timestamp_str:
return False
try:
timestamp = int(timestamp_str)
except ValueError:
return False
current = int(time.time() * 1000)
diff_time = current - timestamp
if diff_time >= MAX_WEBHOOK_AGE:
return False
json_body = json.dumps(body, separators=(",", ":"))
signed_payload = str(timestamp) + "." + json_body
expected = hmac.new(
OPENVIDU_MEET_API_KEY.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
if __name__ == "__main__":
print("Webhook server listening on port " + str(SERVER_PORT))
app.run(debug=False, host="0.0.0.0", port=SERVER_PORT)

Binary file not shown.

56
webhooks-snippets/ruby/.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
*.gem
*.rbc
/.config
/coverage/
/InstalledFiles
/pkg/
/spec/reports/
/spec/examples.txt
/test/tmp/
/test/version_tmp/
/tmp/
# Used by dotenv library to load environment variables.
# .env
# Ignore Byebug command history file.
.byebug_history
## Specific to RubyMotion:
.dat*
.repl_history
build/
*.bridgesupport
build-iPhoneOS/
build-iPhoneSimulator/
## Specific to RubyMotion (use of CocoaPods):
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# vendor/Pods/
## Documentation cache and generated files:
/.yardoc/
/_yardoc/
/doc/
/rdoc/
## Environment normalization:
/.bundle/
/vendor/bundle
/lib/bundler/man/
# for a library or gem, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
Gemfile.lock
# .ruby-version
# .ruby-gemset
# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
.rvmrc
# Used by RuboCop. Remote config files pulled in from inherit_from directive.
# .rubocop-https?--*

View File

@ -0,0 +1,4 @@
source 'https://rubygems.org'
gem 'sinatra', '~> 4.1'
gem 'json', '~> 2.13'

View File

@ -0,0 +1,65 @@
require 'sinatra'
require 'json'
require 'openssl'
SERVER_PORT = 5080
MAX_WEBHOOK_AGE = 120 * 1000 # 2 minutes in milliseconds
OPENVIDU_MEET_API_KEY = 'meet-api-key'
set :port, SERVER_PORT
set :bind, '0.0.0.0'
set :environment, :production
disable :protection
post '/webhook' do
body_content = request.body.read
begin
body = JSON.parse(body_content)
rescue JSON::ParserError
status 400
return 'Invalid JSON'
end
headers = {}
request.env.each do |key, value|
if key.start_with?('HTTP_')
header_name = key[5..-1].downcase.gsub('_', '-')
headers[header_name] = value
end
end
unless webhook_event_valid?(body, headers)
puts 'Invalid webhook signature'
status 401
return 'Invalid webhook signature'
end
puts "Webhook received: #{body_content}"
status 200
''
end
def webhook_event_valid?(body, headers)
signature = headers['x-signature']
timestamp_str = headers['x-timestamp']
return false if signature.nil? || timestamp_str.nil?
begin
timestamp = Integer(timestamp_str)
rescue ArgumentError
return false
end
current = (Time.now.to_f * 1000).to_i
diff_time = current - timestamp
return false if diff_time >= MAX_WEBHOOK_AGE
signed_payload = "#{timestamp}.#{body.to_json}"
expected = OpenSSL::HMAC.hexdigest('SHA256', OPENVIDU_MEET_API_KEY, signed_payload)
OpenSSL.fixed_length_secure_compare(expected, signature)
end
puts "Webhook server listening on port #{SERVER_PORT}"

1033
webhooks-snippets/rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
[package]
name = "openvidu-meet-webhook-server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
tokio = { version = "1", features = ["full"] }
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
chrono = "0.4"

View File

@ -0,0 +1,116 @@
use axum::{
extract::Request,
http::{HeaderMap, StatusCode},
response::Response,
routing::post,
Router,
};
use chrono::Utc;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::HashMap;
use tokio::net::TcpListener;
const SERVER_PORT: u16 = 5080;
const MAX_WEBHOOK_AGE: i64 = 120 * 1000; // 2 minutes in milliseconds
const OPENVIDU_MEET_API_KEY: &str = "meet-api-key";
type HmacSha256 = Hmac<Sha256>;
#[tokio::main]
async fn main() {
println!("Webhook server listening on port {}", SERVER_PORT);
let app = Router::new().route("/webhook", post(webhook_handler));
let listener = TcpListener::bind(format!("0.0.0.0:{}", SERVER_PORT))
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn webhook_handler(
headers: HeaderMap,
request: Request,
) -> Result<Response<String>, StatusCode> {
let body = match axum::body::to_bytes(request.into_body(), usize::MAX).await {
Ok(bytes) => bytes,
Err(_) => return Err(StatusCode::BAD_REQUEST),
};
let body_str = match std::str::from_utf8(&body) {
Ok(s) => s,
Err(_) => return Err(StatusCode::BAD_REQUEST),
};
// Extract headers
let mut header_map = HashMap::new();
for (key, value) in headers.iter() {
if let Ok(value_str) = value.to_str() {
header_map.insert(key.as_str().to_lowercase(), value_str.to_string());
}
}
if !is_webhook_event_valid(body_str, &header_map) {
println!("Invalid webhook signature");
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("Invalid webhook signature".to_string())
.unwrap());
}
println!("Webhook received: {}", body_str);
Ok(Response::builder()
.status(StatusCode::OK)
.body("".to_string())
.unwrap())
}
fn is_webhook_event_valid(body_str: &str, headers: &HashMap<String, String>) -> bool {
let signature = match headers.get("x-signature") {
Some(sig) => sig,
None => return false,
};
let timestamp_str = match headers.get("x-timestamp") {
Some(ts) => ts,
None => return false,
};
let timestamp: i64 = match timestamp_str.parse() {
Ok(ts) => ts,
Err(_) => return false,
};
// Check timestamp age
let current = Utc::now().timestamp_millis();
let diff_time = current - timestamp;
if diff_time >= MAX_WEBHOOK_AGE {
return false;
}
// Create signed payload using the raw body string
let signed_payload = format!("{}.{}", timestamp, body_str);
// Calculate HMAC
let mut mac = match HmacSha256::new_from_slice(OPENVIDU_MEET_API_KEY.as_bytes()) {
Ok(mac) => mac,
Err(_) => return false,
};
mac.update(signed_payload.as_bytes());
let expected = mac.finalize().into_bytes();
let expected_hex = hex::encode(expected);
// Timing-safe comparison
if signature.len() != expected_hex.len() {
return false;
}
let mut result = 0u8;
for (a, b) in signature.bytes().zip(expected_hex.bytes()) {
result |= a ^ b;
}
result == 0
}