use anyhow::{Context, Result};
use clap::Subcommand;
use colored::Colorize;
use serde_json::json;
use std::sync::Arc;
use tabled::{Table, Tabled};
use tokio::sync::Semaphore;
use crate::api::{resolve_team_id, LinearClient};
use crate::display_options;
use crate::output::{
ensure_non_empty, filter_values, print_json, print_json_owned, sort_values, OutputOptions,
};
use crate::text::truncate;
use crate::types::Webhook;
#[derive(Subcommand)]
pub enum WebhookCommands {
#[command(alias = "ls")]
#[command(after_help = r#"EXAMPLES:
linear webhooks list # List all webhooks
linear wh list --output json # Output as JSON"#)]
List,
#[command(after_help = r#"EXAMPLES:
linear webhooks get WEBHOOK_ID # View webhook details"#)]
Get {
id: String,
#[arg(long)]
show_secret: bool,
},
#[command(after_help = r#"EXAMPLES:
linear webhooks create https://example.com/hook --events Issue,Comment
linear wh create URL --events Issue --team ENG --label "My Hook"
linear wh create URL --events Issue --all-teams"#)]
Create {
url: String,
#[arg(long, value_delimiter = ',')]
events: Vec<String>,
#[arg(short, long, conflicts_with = "all_teams")]
team: Option<String>,
#[arg(long, conflicts_with = "team")]
all_teams: bool,
#[arg(short, long)]
label: Option<String>,
#[arg(long)]
secret: Option<String>,
#[arg(long)]
show_secret: bool,
},
#[command(after_help = r#"EXAMPLES:
linear webhooks update WEBHOOK_ID --url https://new-url.com
linear wh update WEBHOOK_ID --enabled false --label "Disabled hook""#)]
Update {
id: String,
#[arg(long)]
url: Option<String>,
#[arg(long, value_delimiter = ',')]
events: Vec<String>,
#[arg(long, conflicts_with = "disabled")]
enabled: bool,
#[arg(long, conflicts_with = "enabled")]
disabled: bool,
#[arg(short, long)]
label: Option<String>,
},
#[command(after_help = r#"EXAMPLES:
linear webhooks delete WEBHOOK_ID # Delete with confirmation
linear wh delete WEBHOOK_ID --force # Delete without confirmation"#)]
Delete {
id: String,
#[arg(long)]
force: bool,
},
#[command(after_help = r#"EXAMPLES:
linear webhooks rotate-secret WEBHOOK_ID"#)]
RotateSecret {
id: String,
#[arg(long)]
show_secret: bool,
},
#[command(after_help = r#"EXAMPLES:
linear webhooks listen --port 9000 # Start local listener
linear wh listen --port 9000 --events Issue,Comment
linear wh listen --url https://my-tunnel.ngrok.io --events Issue
NOTE: Linear cannot reach localhost directly.
Use a tunnel service (ngrok, cloudflare tunnel) and pass --url with your public URL."#)]
Listen {
#[arg(short, long, default_value = "9000")]
port: u16,
#[arg(long, default_value = "127.0.0.1")]
bind: String,
#[arg(long, value_delimiter = ',')]
events: Vec<String>,
#[arg(short, long)]
team: Option<String>,
#[arg(long)]
secret: Option<String>,
#[arg(long)]
url: Option<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Tabled)]
struct WebhookRow {
#[tabled(rename = "Label")]
label: String,
#[tabled(rename = "URL")]
url: String,
#[tabled(rename = "Enabled")]
enabled: String,
#[tabled(rename = "Events")]
events: String,
#[tabled(rename = "Team")]
team: String,
#[tabled(rename = "ID")]
id: String,
}
pub async fn handle(cmd: WebhookCommands, output: &OutputOptions) -> Result<()> {
match cmd {
WebhookCommands::List => list_webhooks(output).await,
WebhookCommands::Get { id, show_secret } => get_webhook(&id, show_secret, output).await,
WebhookCommands::Create {
url,
events,
team,
all_teams,
label,
secret,
show_secret,
} => {
create_webhook(
&url,
events,
team,
all_teams,
label,
secret,
show_secret,
output,
)
.await
}
WebhookCommands::Update {
id,
url,
events,
enabled,
disabled,
label,
} => update_webhook(&id, url, events, enabled, disabled, label, output).await,
WebhookCommands::Delete { id, force } => delete_webhook(&id, force, output).await,
WebhookCommands::RotateSecret { id, show_secret } => {
rotate_secret(&id, show_secret, output).await
}
WebhookCommands::Listen {
port,
bind,
events,
team,
secret,
url,
json,
} => listen(port, bind, events, team, secret, url, json, output).await,
}
}
fn is_expected_webhook_path(path: &str) -> bool {
path.split_once('?').map(|(base, _)| base).unwrap_or(path) == "/webhook"
}
fn safe_terminal_value(value: &str) -> String {
crate::text::sanitize_terminal_text(value)
}
const MAX_CONCURRENT_WEBHOOK_CONNECTIONS: usize = 32;
async fn list_webhooks(output: &OutputOptions) -> Result<()> {
let client = LinearClient::new()?;
let query = r#"
query {
webhooks {
nodes {
id label url enabled secret resourceTypes allPublicTeams
team { id key name }
creator { id name }
createdAt updatedAt
}
}
}
"#;
let result = client.query(query, None).await?;
let empty = vec![];
let nodes = result["data"]["webhooks"]["nodes"]
.as_array()
.unwrap_or(&empty);
let mut webhooks: Vec<serde_json::Value> = nodes.clone();
if output.is_json() || output.has_template() {
print_json_owned(json!(webhooks), output)?;
return Ok(());
}
filter_values(&mut webhooks, &output.filters);
if let Some(sort_key) = output.json.sort.as_deref() {
sort_values(&mut webhooks, sort_key, output.json.order);
}
ensure_non_empty(&webhooks, output)?;
if webhooks.is_empty() {
println!("No webhooks found.");
return Ok(());
}
let width = display_options().max_width(30);
let rows: Vec<WebhookRow> = webhooks
.iter()
.map(|w| {
let events = w["resourceTypes"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| "-".to_string());
WebhookRow {
label: truncate(w["label"].as_str().unwrap_or("-"), width),
url: truncate(w["url"].as_str().unwrap_or("-"), width),
enabled: if w["enabled"].as_bool() == Some(true) {
"Yes".to_string()
} else {
"No".to_string()
},
events: truncate(&events, width),
team: truncate(w["team"]["name"].as_str().unwrap_or("All"), width),
id: w["id"].as_str().unwrap_or("").to_string(),
}
})
.collect();
let table = Table::new(rows).to_string();
println!("{}", table);
println!("\n{} webhooks", webhooks.len());
Ok(())
}
async fn get_webhook(id: &str, show_secret: bool, output: &OutputOptions) -> Result<()> {
let client = LinearClient::new()?;
let query = r#"
query($id: String!) {
webhook(id: $id) {
id label url enabled secret resourceTypes allPublicTeams
team { id key name }
creator { id name }
createdAt updatedAt
}
}
"#;
let result = client.query(query, Some(json!({ "id": id }))).await?;
let webhook = &result["data"]["webhook"];
if webhook.is_null() {
anyhow::bail!("Webhook not found: {}", id);
}
if output.is_json() || output.has_template() {
let mut webhook = webhook.clone();
if !show_secret {
webhook["secret"] = json!("<redacted>");
}
print_json_owned(webhook, output)?;
return Ok(());
}
let wh: Webhook = serde_json::from_value(webhook.clone())?;
println!("{}", wh.label.as_deref().unwrap_or("Webhook").bold());
println!("{}", "-".repeat(40));
println!("URL: {}", wh.url.as_deref().unwrap_or("-"));
println!("Enabled: {}", if wh.enabled { "Yes" } else { "No" });
if !wh.resource_types.is_empty() {
println!("Events: {}", wh.resource_types.join(", "));
}
println!(
"All Teams: {}",
if wh.all_public_teams { "Yes" } else { "No" }
);
if let Some(team) = &wh.team {
println!("Team: {} ({})", team.name, team.key);
}
if let Some(creator) = &wh.creator {
println!("Creator: {}", creator.name);
}
if let Some(secret) = &wh.secret {
if !secret.is_empty() {
if show_secret {
println!("Secret: {}", secret);
} else {
println!("Secret: <redacted> (use --show-secret to reveal)");
}
}
}
println!("ID: {}", wh.id);
if let Some(created) = &wh.created_at {
println!("Created: {}", created.chars().take(10).collect::<String>());
}
if let Some(updated) = &wh.updated_at {
println!("Updated: {}", updated.chars().take(10).collect::<String>());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn create_webhook(
url: &str,
events: Vec<String>,
team: Option<String>,
all_teams: bool,
label: Option<String>,
secret: Option<String>,
show_secret: bool,
output: &OutputOptions,
) -> Result<()> {
let client = LinearClient::new()?;
let mut input = json!({
"url": url,
"resourceTypes": events,
});
if let Some(ref t) = team {
let team_id = resolve_team_id(&client, t, &output.cache).await?;
input["teamId"] = json!(team_id);
}
if all_teams {
input["allPublicTeams"] = json!(true);
}
if let Some(l) = label {
input["label"] = json!(l);
}
if let Some(s) = secret {
input["secret"] = json!(s);
}
if output.dry_run {
if output.is_json() || output.has_template() {
print_json_owned(
json!({
"dry_run": true,
"would_create": { "input": input }
}),
output,
)?;
} else {
println!("{}", "[DRY RUN] Would create webhook:".yellow().bold());
println!(" URL: {}", url);
}
return Ok(());
}
let mutation = r#"
mutation($input: WebhookCreateInput!) {
webhookCreate(input: $input) {
success
webhook { id url secret enabled resourceTypes }
}
}
"#;
let result = client
.mutate(mutation, Some(json!({ "input": input })))
.await?;
if result["data"]["webhookCreate"]["success"].as_bool() == Some(true) {
let webhook = &result["data"]["webhookCreate"]["webhook"];
if output.is_json() || output.has_template() {
let mut webhook = webhook.clone();
if !show_secret {
webhook["secret"] = json!("<redacted>");
}
print_json_owned(webhook, output)?;
return Ok(());
}
println!("{} Created webhook", "+".green(),);
println!(" ID: {}", webhook["id"].as_str().unwrap_or(""));
println!(" URL: {}", webhook["url"].as_str().unwrap_or(""));
println!(
" Enabled: {}",
if webhook["enabled"].as_bool() == Some(true) {
"Yes"
} else {
"No"
}
);
if let Some(secret) = webhook["secret"].as_str() {
if !secret.is_empty() {
if show_secret {
println!(" Secret: {}", secret);
} else {
println!(" Secret: <redacted> (use --show-secret to reveal)");
}
}
}
} else {
anyhow::bail!("Failed to create webhook. Webhooks require admin scope — try re-authenticating with: linear-cli auth oauth");
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn update_webhook(
id: &str,
url: Option<String>,
events: Vec<String>,
enabled: bool,
disabled: bool,
label: Option<String>,
output: &OutputOptions,
) -> Result<()> {
let client = LinearClient::new()?;
let mut input = json!({});
if let Some(u) = url {
input["url"] = json!(u);
}
if !events.is_empty() {
input["resourceTypes"] = json!(events);
}
if enabled {
input["enabled"] = json!(true);
}
if disabled {
input["enabled"] = json!(false);
}
if let Some(l) = label {
input["label"] = json!(l);
}
if input.as_object().map(|o| o.is_empty()).unwrap_or(true) {
println!("No updates specified.");
return Ok(());
}
if output.dry_run {
if output.is_json() || output.has_template() {
print_json_owned(
json!({
"dry_run": true,
"would_update": {
"id": id,
"input": input,
}
}),
output,
)?;
} else {
println!("{}", "[DRY RUN] Would update webhook:".yellow().bold());
println!(" ID: {}", id);
}
return Ok(());
}
let mutation = r#"
mutation($id: String!, $input: WebhookUpdateInput!) {
webhookUpdate(id: $id, input: $input) {
success
webhook { id url enabled }
}
}
"#;
let result = client
.mutate(mutation, Some(json!({ "id": id, "input": input })))
.await?;
if result["data"]["webhookUpdate"]["success"].as_bool() == Some(true) {
if output.is_json() || output.has_template() {
print_json(&result["data"]["webhookUpdate"]["webhook"], output)?;
return Ok(());
}
println!("{} Webhook updated", "+".green());
} else {
anyhow::bail!("Failed to update webhook");
}
Ok(())
}
async fn delete_webhook(id: &str, force: bool, output: &OutputOptions) -> Result<()> {
let client = LinearClient::new()?;
if output.dry_run {
if output.is_json() || output.has_template() {
print_json_owned(
json!({
"dry_run": true,
"would_delete": { "id": id }
}),
output,
)?;
} else {
println!("{}", "[DRY RUN] Would delete webhook:".yellow().bold());
println!(" ID: {}", id);
}
return Ok(());
}
if !force && !crate::is_yes() {
use dialoguer::Confirm;
let confirm = Confirm::new()
.with_prompt(format!("Delete webhook {}?", id))
.default(false)
.interact()?;
if !confirm {
println!("Cancelled.");
return Ok(());
}
}
let mutation = r#"
mutation($id: String!) {
webhookDelete(id: $id) {
success
}
}
"#;
let result = client.mutate(mutation, Some(json!({ "id": id }))).await?;
if result["data"]["webhookDelete"]["success"].as_bool() == Some(true) {
if output.is_json() || output.has_template() {
print_json_owned(json!({ "deleted": true, "id": id }), output)?;
return Ok(());
}
println!("{} Webhook deleted: {}", "-".red(), id);
} else {
anyhow::bail!("Failed to delete webhook");
}
Ok(())
}
async fn rotate_secret(id: &str, show_secret: bool, output: &OutputOptions) -> Result<()> {
let client = LinearClient::new()?;
let mutation = r#"
mutation($id: String!) {
webhookRotateSecret(id: $id) {
success
webhook { id secret }
}
}
"#;
let result = client.mutate(mutation, Some(json!({ "id": id }))).await?;
if result["data"]["webhookRotateSecret"]["success"].as_bool() == Some(true) {
let webhook = &result["data"]["webhookRotateSecret"]["webhook"];
if output.is_json() || output.has_template() {
let mut webhook = webhook.clone();
if !show_secret {
webhook["secret"] = json!("<redacted>");
}
print_json_owned(webhook, output)?;
return Ok(());
}
println!("{} Secret rotated for webhook: {}", "+".green(), id);
if let Some(secret) = webhook["secret"].as_str() {
if show_secret {
println!(" New secret: {}", secret);
} else {
println!(" New secret: <redacted> (use --show-secret to reveal)");
}
}
} else {
anyhow::bail!("Failed to rotate webhook secret");
}
Ok(())
}
fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(body);
let Ok(sig_bytes) = hex::decode(signature) else {
return false;
};
mac.verify_slice(&sig_bytes).is_ok()
}
#[allow(clippy::too_many_arguments)]
async fn listen(
port: u16,
bind: String,
events: Vec<String>,
team: Option<String>,
secret: Option<String>,
url: Option<String>,
json_output: bool,
output: &OutputOptions,
) -> Result<()> {
let client = LinearClient::new()?;
let webhook_url = match url {
Some(ref u) => {
let base = u.trim_end_matches('/');
format!("{}/webhook", base)
}
None => {
println!(
"{}",
"Note: Linear cannot reach localhost directly.".yellow()
);
println!(
"Use a tunnel service (ngrok, cloudflare tunnel) and pass --url with your public URL."
);
println!("Starting local server on port {} anyway...", port);
format!("http://localhost:{}/webhook", port)
}
};
let mut webhook_input = json!({
"url": webhook_url,
"enabled": true,
});
if !events.is_empty() {
webhook_input["resourceTypes"] = json!(events);
}
if let Some(ref t) = team {
let team_id = resolve_team_id(&client, t, &output.cache).await?;
webhook_input["teamId"] = json!(team_id);
}
if let Some(ref s) = secret {
webhook_input["secret"] = json!(s);
}
webhook_input["label"] = json!("linear-cli-listen");
let create_mutation = r#"
mutation($input: WebhookCreateInput!) {
webhookCreate(input: $input) {
success
webhook { id url secret enabled }
}
}
"#;
let result = client
.mutate(create_mutation, Some(json!({ "input": webhook_input })))
.await?;
if result["data"]["webhookCreate"]["success"].as_bool() != Some(true) {
anyhow::bail!(
"Failed to create temporary webhook. Webhooks require admin scope — try re-authenticating with: linear-cli auth oauth"
);
}
let webhook_data = &result["data"]["webhookCreate"]["webhook"];
let webhook_id = webhook_data["id"]
.as_str()
.context("Missing webhook ID")?
.to_string();
let webhook_secret = secret.or_else(|| webhook_data["secret"].as_str().map(|s| s.to_string()));
if webhook_secret.is_none() {
let delete_mutation = r#"
mutation($id: String!) {
webhookDelete(id: $id) { success }
}
"#;
let _ = client
.mutate(delete_mutation, Some(json!({ "id": webhook_id })))
.await;
anyhow::bail!("Webhook secret was not returned; refusing to start unsigned listener");
}
println!("{} Temporary webhook created: {}", "+".green(), webhook_id);
println!(" URL: {}", webhook_url);
println!(" Listening on port {}...", port);
println!(" Press Ctrl+C to stop and clean up.\n");
let listener_addr = format!("{}:{}", bind, port);
let listener = match tokio::net::TcpListener::bind(&listener_addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("Failed to bind to port {}, cleaning up webhook...", port);
let delete_mutation = r#"
mutation($id: String!) {
webhookDelete(id: $id) { success }
}
"#;
let _ = client
.mutate(delete_mutation, Some(json!({ "id": webhook_id })))
.await;
return Err(e).context(format!("Failed to bind to {}", listener_addr));
}
};
let webhook_id_clone = webhook_id.clone();
let cleanup_client = LinearClient::new()?;
let shutdown = tokio::signal::ctrl_c();
tokio::pin!(shutdown);
let connection_limit = Arc::new(Semaphore::new(MAX_CONCURRENT_WEBHOOK_CONNECTIONS));
loop {
tokio::select! {
accept = listener.accept() => {
match accept {
Ok((mut stream, addr)) => {
let ws = webhook_secret.clone();
let json_out = json_output;
if let Ok(permit) = connection_limit.clone().try_acquire_owned() {
tokio::spawn(async move {
let _permit = permit;
if let Err(e) = handle_connection(stream, addr, ws.as_deref(), json_out).await {
eprintln!("Error handling connection from {}: {}", addr, e);
}
});
} else {
let _ = reject_busy_connection(&mut stream).await;
eprintln!("Too many concurrent webhook connections; rejecting {}", addr);
}
}
Err(e) => {
eprintln!("Accept error: {}", e);
}
}
}
_ = &mut shutdown => {
println!("\n{} Shutting down...", "!".yellow());
break;
}
}
}
let delete_mutation = r#"
mutation($id: String!) {
webhookDelete(id: $id) {
success
}
}
"#;
match cleanup_client
.mutate(delete_mutation, Some(json!({ "id": webhook_id_clone })))
.await
{
Ok(result) => {
if result["data"]["webhookDelete"]["success"].as_bool() == Some(true) {
println!(
"{} Temporary webhook cleaned up: {}",
"-".red(),
webhook_id_clone
);
} else {
eprintln!(
"Warning: Failed to delete temporary webhook {}",
webhook_id_clone
);
}
}
Err(e) => {
eprintln!(
"Warning: Failed to clean up temporary webhook {}: {}",
webhook_id_clone, e
);
}
}
Ok(())
}
async fn handle_connection(
mut stream: tokio::net::TcpStream,
addr: std::net::SocketAddr,
secret: Option<&str>,
json_output: bool,
) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut header_buf = vec![0u8; 8192];
let mut header_len = 0;
let header_end;
loop {
let n = tokio::time::timeout(
std::time::Duration::from_secs(10),
stream.read(&mut header_buf[header_len..]),
)
.await
.context("Read timeout")?
.context("Read error")?;
if n == 0 {
return Ok(());
}
header_len += n;
if let Some(pos) = header_buf[..header_len]
.windows(4)
.position(|w| w == b"\r\n\r\n")
{
header_end = pos;
break;
}
if header_len >= header_buf.len() {
let response =
"HTTP/1.1 431 Request Header Fields Too Large\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
}
let headers_str = String::from_utf8_lossy(&header_buf[..header_end]).to_string();
const MAX_WEBHOOK_BODY_BYTES: usize = 1024 * 1024;
let content_length: usize = headers_str
.lines()
.find(|l| l.to_lowercase().starts_with("content-length:"))
.and_then(|l| l.split_once(':'))
.and_then(|(_, v)| v.trim().parse().ok())
.unwrap_or(0);
if content_length > MAX_WEBHOOK_BODY_BYTES {
let response = "HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
let body_start = header_end + 4; let already_read = header_len - body_start;
if already_read > MAX_WEBHOOK_BODY_BYTES {
let response = "HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
let mut body_bytes = Vec::with_capacity(content_length.max(already_read));
body_bytes.extend_from_slice(&header_buf[body_start..header_len]);
if body_bytes.len() < content_length {
let remaining = content_length - body_bytes.len();
let mut rest = vec![0u8; remaining];
tokio::time::timeout(
std::time::Duration::from_secs(10),
stream.read_exact(&mut rest),
)
.await
.context("Body read timeout")?
.context("Body read error")?;
body_bytes.extend_from_slice(&rest);
}
let body = String::from_utf8_lossy(&body_bytes).to_string();
let request_line = headers_str.lines().next().unwrap_or("");
let mut request_parts = request_line.split_whitespace();
let method = request_parts.next().unwrap_or("");
let path = request_parts.next().unwrap_or("");
if method != "POST" {
let response = "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
if !is_expected_webhook_path(path) {
let response = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
let signature = headers_str
.lines()
.find(|l| l.to_lowercase().starts_with("linear-signature:"))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_string());
if let Some(s) = secret {
if let Some(ref sig) = signature {
if !verify_signature(s, body.as_bytes(), sig) {
eprintln!("{} Invalid signature from {}", "!".red(), addr);
let response = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
} else {
eprintln!("{} Missing signature from {}", "!".red(), addr);
let response = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
}
let body_json: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(_) => {
let response = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
};
if json_output {
println!("{}", serde_json::to_string(&body_json)?);
} else {
let action = safe_terminal_value(body_json["action"].as_str().unwrap_or("unknown"));
let event_type = safe_terminal_value(body_json["type"].as_str().unwrap_or("unknown"));
let timestamp = chrono::Utc::now().format("%H:%M:%S");
println!(
"[{}] {} {} {}",
timestamp.to_string().dimmed(),
event_type.cyan(),
action.yellow(),
format!("from {}", addr).dimmed()
);
if let Some(data) = body_json.get("data") {
if let Some(id) = data["id"].as_str() {
print!(" ID: {}", safe_terminal_value(id));
}
if let Some(title) = data["title"].as_str() {
print!(" Title: {}", safe_terminal_value(title));
}
if let Some(identifier) = data["identifier"].as_str() {
print!(" Key: {}", safe_terminal_value(identifier));
}
if let Some(name) = data["name"].as_str() {
print!(" Name: {}", safe_terminal_value(name));
}
println!();
}
}
let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok";
stream.write_all(response.as_bytes()).await?;
Ok(())
}
async fn reject_busy_connection(stream: &mut tokio::net::TcpStream) -> Result<()> {
use tokio::io::AsyncWriteExt;
let response =
"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verify_signature_valid() {
let secret = "test-secret";
let body = b"hello world";
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let expected = hex::encode(mac.finalize().into_bytes());
assert!(verify_signature(secret, body, &expected));
}
#[test]
fn test_verify_signature_invalid() {
assert!(!verify_signature("secret", b"body", "wrong-signature"));
}
#[test]
fn test_verify_signature_empty_body() {
let secret = "test-secret";
let body = b"";
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let expected = hex::encode(mac.finalize().into_bytes());
assert!(verify_signature(secret, body, &expected));
}
#[test]
fn test_is_expected_webhook_path_matches_only_webhook_endpoint() {
assert!(is_expected_webhook_path("/webhook"));
assert!(is_expected_webhook_path("/webhook?hello=world"));
assert!(!is_expected_webhook_path("/webhook/extra"));
assert!(!is_expected_webhook_path("/webhook-extra"));
assert!(!is_expected_webhook_path("/other"));
}
#[test]
fn test_safe_terminal_value_removes_escape_sequences() {
assert_eq!(
safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"),
"badtitle"
);
}
}