Compare commits

...

3 Commits

Author SHA1 Message Date
cottongin
78fec2946c feat: add webhook auth guard and IRC password/username support
Add WEBHOOK_SECRET env var for authenticating incoming Owncast webhooks
via a ?secret= query parameter. Requests with a missing or incorrect
secret are rejected with 401. If unset, all requests are accepted
(with a startup warning).

Also includes previously uncommitted work:
- IRC server password support (IRC_PASSWORD env var, PASS command)
- IRC username/ident field in config
- IRC_PASSWORD and SELinux volume flag in docker-compose.yml

Made-with: Cursor
2026-03-13 00:53:59 -04:00
cottongin
1af9bd1def feat: relay OwnCast /me messages as IRC CTCP ACTION with bold attribution
When an OwnCast user sends "/me claps", the bridge now relays it as a
proper CTCP ACTION so IRC clients render it natively. The username is
bolded with mIRC \x02 for visual attribution.

Made-with: Cursor
2026-03-12 17:14:17 -04:00
cottongin
e2fbd52009 fix: format IRC ACTION messages as * nick action * and strip CTCP delimiters
CTCP ACTION messages (/me) were relayed with raw \x01 bytes, rendering
as boxed-X characters in OwnCast. Detect the ACTION pattern, extract the
body, and format it like traditional IRC clients. Also strip \x01 in
irc_format as a safety net for other CTCP leakage.

Made-with: Cursor
2026-03-12 16:37:46 -04:00
17 changed files with 382 additions and 17 deletions

1
Cargo.lock generated
View File

@@ -1127,6 +1127,7 @@ dependencies = [
"tokio-test", "tokio-test",
"tokio-tungstenite", "tokio-tungstenite",
"toml 0.8.23", "toml 0.8.23",
"tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]

View File

@@ -29,3 +29,4 @@ futures-util = "0.3"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4"
tower = { version = "0.5", features = ["util"] }

View File

@@ -29,6 +29,7 @@ Or create a `.env` file (git-ignored):
``` ```
OWNCAST_ACCESS_TOKEN=your-token-here OWNCAST_ACCESS_TOKEN=your-token-here
WEBHOOK_SECRET=some-random-secret
``` ```
**4. Configure the Owncast webhook** **4. Configure the Owncast webhook**
@@ -36,9 +37,11 @@ OWNCAST_ACCESS_TOKEN=your-token-here
In your Owncast admin, go to **Integrations > Webhooks** and add a webhook pointing to: In your Owncast admin, go to **Integrations > Webhooks** and add a webhook pointing to:
``` ```
http://<bridge-host>:9078/webhook http://<bridge-host>:9078/webhook?secret=some-random-secret
``` ```
If `WEBHOOK_SECRET` is set, the bridge rejects any request that doesn't include a matching `?secret=` query parameter. If unset, all requests are accepted (a warning is logged at startup).
Select the events: **Chat Message**, **Stream Started**, **Stream Stopped**. Select the events: **Chat Message**, **Stream Started**, **Stream Stopped**.
**5. Run it** **5. Run it**
@@ -82,7 +85,13 @@ See [`config.example.toml`](config.example.toml) for all options. The only requi
| `bridge` | `owncast_prefix` | `[OC]` | Prefix for Owncast messages in IRC | | `bridge` | `owncast_prefix` | `[OC]` | Prefix for Owncast messages in IRC |
| `control` | `socket_path` | `/tmp/owncast-irc-bridge.sock` | Unix socket for `bridge-ctl` | | `control` | `socket_path` | `/tmp/owncast-irc-bridge.sock` | Unix socket for `bridge-ctl` |
The access token is always read from the `OWNCAST_ACCESS_TOKEN` environment variable (not the config file). Secrets are always read from environment variables (not the config file):
| Variable | Required | Description |
|----------|----------|-------------|
| `OWNCAST_ACCESS_TOKEN` | Yes | Owncast integration API token |
| `IRC_PASSWORD` | No | IRC server password (PASS command) |
| `WEBHOOK_SECRET` | No | Shared secret for webhook authentication |
## Runtime Control ## Runtime Control

View File

@@ -0,0 +1,20 @@
# IRC ACTION Message Formatting Fix
## Task
Fix IRC CTCP ACTION messages (`/me` commands) displaying as `[IRC] <cottongin> ☒ACTION sniffs☒` in OwnCast. The `\x01` CTCP delimiters were rendering as boxed X characters, and the ACTION keyword was not being parsed.
## Changes Made
- **`src/events.rs`**: Added `is_action: bool` field to `BridgeEvent::ChatMessage` variant.
- **`src/irc_task.rs`**: Added `parse_ctcp_action()` function to detect `\x01ACTION ...\x01` pattern, extract the action body, and set `is_action: true`. Added 5 unit tests.
- **`src/router.rs`**: Updated formatting for both IRC-to-OwnCast and OwnCast-to-IRC directions to use `* username body *` format when `is_action` is true.
- **`src/irc_format.rs`**: Added `\x01` to the stripped control codes as a safety net for any non-ACTION CTCP leakage. Added 2 unit tests.
- **`src/webhook.rs`**: Added `is_action: false` to OwnCast-origin ChatMessage constructor; updated test destructure.
- **`src/websocket.rs`**: Added `is_action: false` to OwnCast-origin ChatMessage constructor.
## Result
- Before: `[IRC] <cottongin> ☒ACTION sniffs☒`
- After: `[IRC] * cottongin sniffs *`
## Follow-up
- None identified. All 55 tests pass.

View File

@@ -0,0 +1,17 @@
# OwnCast `/me` to IRC CTCP ACTION
## Task
Detect when OwnCast users type `/me does something` and relay the message to IRC as a proper CTCP ACTION so IRC clients render it natively as an action, with bold attribution on the username.
## Changes Made
- **`src/router.rs`**: Extracted `format_owncast_to_irc()` function. When the body starts with `/me `, strips the prefix and wraps the outbound string as `\x01ACTION {prefix} \x02{username}\x02 {action_body}\x01`. Added 5 unit tests covering: action formatting, multi-word actions, normal passthrough, bare `/me` without space, and `/me` mid-message.
- **`src/irc_task.rs`**: Updated outbound handler to detect strings starting with `\x01` and send them as raw `Command::PRIVMSG` (preserving the CTCP wrapper) instead of using `send_privmsg` which would double-escape.
## Result
- OwnCast user sends: `/me claps`
- IRC clients see: `* bridge-bot [OC] **viewer42** claps` (bold username via mIRC formatting)
- Normal messages are unaffected.
## Follow-up
- None identified. All 60 tests pass.

View File

@@ -0,0 +1,17 @@
# IRC Server Password Support
## Task
Add support for connecting to password-protected IRC servers by sending the PASS command during connection, and allow setting a custom IRC username.
## Changes
- **src/config.rs**: Added `username: Option<String>` field to `IrcConfig`. Added `BridgeConfig::irc_server_password()` method that reads `IRC_PASSWORD` from the environment (returns `Option<String>`). Updated `default_for_test()` and tests for the new field and method.
- **src/irc_task.rs**: Set `password` and `username` on the `irc` crate's `Config` when building the IRC connection, sourcing the password from `BridgeConfig::irc_server_password()` and username from `IrcConfig.username`.
- **config.toml**: Removed stale `server_password` field, added comment about `IRC_PASSWORD` env var.
- **config.example.toml**: Added commented-out `username` field and `IRC_PASSWORD` env var documentation.
## Usage
Set the `IRC_PASSWORD` environment variable before running the bridge to authenticate with the IRC server. Optionally set `username` in `[irc]` config for a custom ident.
## Follow-up
- None identified.

View File

@@ -0,0 +1,40 @@
# Webhook Authentication Guard
**Date:** 2026-03-13
## Task
Add a shared secret (`WEBHOOK_SECRET`) to the webhook endpoint so only requests with a matching `?secret=` query parameter are accepted. This prevents unauthorized parties from injecting events into the bridge.
## Changes Made
### `src/config.rs`
- Added `webhook_secret()` static method to `BridgeConfig` — reads `WEBHOOK_SECRET` env var, returns `Option<String>`.
### `src/webhook.rs`
- Added `WebhookQuery` struct for axum query parameter extraction.
- Added `secret: Option<String>` field to `WebhookState`.
- Updated `handle_webhook` to validate the secret before processing: returns 401 if configured secret doesn't match.
- Updated `run_webhook_server` signature to accept `secret: Option<String>`; logs a warning at startup if unset.
- Added 4 integration tests using `tower::ServiceExt::oneshot`: correct secret (200), wrong secret (401), missing secret (401), no secret configured (200).
### `src/main.rs`
- Reads `WEBHOOK_SECRET` via `config::BridgeConfig::webhook_secret()`.
- Passes the secret to `webhook::run_webhook_server()`.
### `docker-compose.yml`
- Added `WEBHOOK_SECRET=${WEBHOOK_SECRET}` to environment section.
### `config.example.toml`
- Added comment documenting the `WEBHOOK_SECRET` env var.
### `README.md`
- Updated webhook URL example to include `?secret=` parameter.
- Added environment variables table documenting all three secrets.
### `Cargo.toml`
- Added `tower` (0.5, `util` feature) as dev dependency for handler tests.
## Follow-up Items
- None. All 65 tests pass.

View File

@@ -1,5 +1,7 @@
[irc] [irc]
server = "irc.zeronode.net" server = "irc.zeronode.net"
# Set IRC_PASSWORD env var for server password (PASS command)
# username = "myuser"
port = 6667 port = 6667
tls = false tls = false
nick = "owncast-bridge" nick = "owncast-bridge"
@@ -8,6 +10,7 @@ channel = "#BowlAfterBowl"
[owncast] [owncast]
url = "https://owncast.bowlafterbowl.com" url = "https://owncast.bowlafterbowl.com"
# Set OWNCAST_ACCESS_TOKEN env var for the token # Set OWNCAST_ACCESS_TOKEN env var for the token
# Set WEBHOOK_SECRET env var to require ?secret=<token> on incoming webhooks
webhook_port = 9078 webhook_port = 9078
websocket_enabled = true websocket_enabled = true
health_poll_interval_secs = 30 health_poll_interval_secs = 30

View File

@@ -5,8 +5,10 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- OWNCAST_ACCESS_TOKEN=${OWNCAST_ACCESS_TOKEN} - OWNCAST_ACCESS_TOKEN=${OWNCAST_ACCESS_TOKEN}
- IRC_PASSWORD=${IRC_PASSWORD}
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
- RUST_LOG=info - RUST_LOG=info
volumes: volumes:
- ./config.toml:/etc/owncast-irc-bridge/config.toml:ro - ./config.toml:/etc/owncast-irc-bridge/config.toml:ro,z
ports: ports:
- "9078:9078" - "9078:9078"

View File

@@ -21,6 +21,8 @@ pub struct IrcConfig {
pub tls: bool, pub tls: bool,
#[serde(default = "default_nick")] #[serde(default = "default_nick")]
pub nick: String, pub nick: String,
#[serde(default)]
pub username: Option<String>,
pub channel: String, pub channel: String,
} }
@@ -60,10 +62,18 @@ impl BridgeConfig {
Ok(config) Ok(config)
} }
pub fn irc_server_password() -> Option<String> {
std::env::var("IRC_PASSWORD").ok()
}
pub fn owncast_access_token(&self) -> anyhow::Result<String> { pub fn owncast_access_token(&self) -> anyhow::Result<String> {
std::env::var("OWNCAST_ACCESS_TOKEN") std::env::var("OWNCAST_ACCESS_TOKEN")
.map_err(|_| anyhow::anyhow!("OWNCAST_ACCESS_TOKEN env var not set")) .map_err(|_| anyhow::anyhow!("OWNCAST_ACCESS_TOKEN env var not set"))
} }
pub fn webhook_secret() -> Option<String> {
std::env::var("WEBHOOK_SECRET").ok()
}
} }
fn default_irc_port() -> u16 { 6667 } fn default_irc_port() -> u16 { 6667 }
@@ -100,6 +110,7 @@ impl BridgeConfig {
port: 6667, port: 6667,
tls: false, tls: false,
nick: "test-bot".to_string(), nick: "test-bot".to_string(),
username: None,
channel: "#test".to_string(), channel: "#test".to_string(),
}, },
owncast: OwncastConfig { owncast: OwncastConfig {
@@ -134,6 +145,7 @@ url = "https://owncast.example.com"
assert_eq!(config.irc.port, 6667); assert_eq!(config.irc.port, 6667);
assert_eq!(config.irc.tls, false); assert_eq!(config.irc.tls, false);
assert_eq!(config.irc.nick, "owncast-bridge"); assert_eq!(config.irc.nick, "owncast-bridge");
assert!(config.irc.username.is_none());
assert_eq!(config.irc.channel, "#test"); assert_eq!(config.irc.channel, "#test");
assert_eq!(config.owncast.url, "https://owncast.example.com"); assert_eq!(config.owncast.url, "https://owncast.example.com");
assert_eq!(config.owncast.webhook_port, 9078); assert_eq!(config.owncast.webhook_port, 9078);
@@ -153,6 +165,7 @@ server = "irc.example.com"
port = 6697 port = 6697
tls = true tls = true
nick = "mybot" nick = "mybot"
username = "myident"
channel = "#mychan" channel = "#mychan"
[owncast] [owncast]
@@ -173,12 +186,21 @@ socket_path = "/var/run/bridge.sock"
assert_eq!(config.irc.port, 6697); assert_eq!(config.irc.port, 6697);
assert!(config.irc.tls); assert!(config.irc.tls);
assert_eq!(config.irc.nick, "mybot"); assert_eq!(config.irc.nick, "mybot");
assert_eq!(config.irc.username.as_deref(), Some("myident"));
assert_eq!(config.owncast.webhook_port, 8888); assert_eq!(config.owncast.webhook_port, 8888);
assert!(config.owncast.websocket_enabled); assert!(config.owncast.websocket_enabled);
assert_eq!(config.bridge.message_buffer_size, 50); assert_eq!(config.bridge.message_buffer_size, 50);
assert_eq!(config.control.socket_path, "/var/run/bridge.sock"); assert_eq!(config.control.socket_path, "/var/run/bridge.sock");
} }
#[test]
fn test_irc_server_password_from_env() {
std::env::set_var("IRC_PASSWORD", "secret123");
assert_eq!(BridgeConfig::irc_server_password().as_deref(), Some("secret123"));
std::env::remove_var("IRC_PASSWORD");
assert!(BridgeConfig::irc_server_password().is_none());
}
#[test] #[test]
fn test_access_token_from_env() { fn test_access_token_from_env() {
std::env::set_var("OWNCAST_ACCESS_TOKEN", "test-token-123"); std::env::set_var("OWNCAST_ACCESS_TOKEN", "test-token-123");

View File

@@ -13,6 +13,7 @@ pub enum BridgeEvent {
username: String, username: String,
body: String, body: String,
id: Option<String>, id: Option<String>,
is_action: bool,
}, },
StreamStarted { StreamStarted {
title: String, title: String,

View File

@@ -1,8 +1,8 @@
/// Strip mIRC-style formatting control codes from a string. /// Strip mIRC-style formatting control codes from a string.
/// ///
/// Removes bold (\x02), color (\x03 + optional fg[,bg] digits), reset (\x0F), /// Removes CTCP delimiter (\x01), bold (\x02), color (\x03 + optional fg[,bg]
/// monospace (\x11), reverse (\x16), italic (\x1D), strikethrough (\x1E), /// digits), reset (\x0F), monospace (\x11), reverse (\x16), italic (\x1D),
/// and underline (\x1F). /// strikethrough (\x1E), and underline (\x1F).
pub fn strip_formatting(input: &str) -> String { pub fn strip_formatting(input: &str) -> String {
let bytes = input.as_bytes(); let bytes = input.as_bytes();
let len = bytes.len(); let len = bytes.len();
@@ -11,7 +11,7 @@ pub fn strip_formatting(input: &str) -> String {
while i < len { while i < len {
match bytes[i] { match bytes[i] {
b'\x02' | b'\x0F' | b'\x11' | b'\x16' | b'\x1D' | b'\x1E' | b'\x1F' => { b'\x01' | b'\x02' | b'\x0F' | b'\x11' | b'\x16' | b'\x1D' | b'\x1E' | b'\x1F' => {
i += 1; i += 1;
} }
b'\x03' => { b'\x03' => {
@@ -156,4 +156,17 @@ mod tests {
fn preserves_cjk_and_accented_chars() { fn preserves_cjk_and_accented_chars() {
assert_eq!(strip_formatting("\x02café\x02 日本語"), "café 日本語"); assert_eq!(strip_formatting("\x02café\x02 日本語"), "café 日本語");
} }
#[test]
fn strips_ctcp_delimiter() {
assert_eq!(strip_formatting("\x01ACTION sniffs\x01"), "ACTION sniffs");
}
#[test]
fn strips_ctcp_delimiter_mixed_with_formatting() {
assert_eq!(
strip_formatting("\x01\x02ACTION bold\x02\x01"),
"ACTION bold"
);
}
} }

View File

@@ -5,7 +5,7 @@ use irc::client::prelude::*;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{error, info}; use tracing::{error, info};
use crate::config::IrcConfig; use crate::config::{BridgeConfig, IrcConfig};
use crate::events::{BridgeEvent, Source}; use crate::events::{BridgeEvent, Source};
pub async fn run_irc_task( pub async fn run_irc_task(
@@ -52,6 +52,8 @@ async fn connect_and_run(
port: Some(config.port), port: Some(config.port),
use_tls: Some(config.tls), use_tls: Some(config.tls),
channels: vec![config.channel.clone()], channels: vec![config.channel.clone()],
password: BridgeConfig::irc_server_password(),
username: config.username.clone(),
..Config::default() ..Config::default()
}; };
@@ -71,11 +73,13 @@ async fn connect_and_run(
if let Command::PRIVMSG(ref target, ref text) = message.command { if let Command::PRIVMSG(ref target, ref text) = message.command {
if target == &config.channel { if target == &config.channel {
let nick = message.source_nickname().unwrap_or("unknown").to_string(); let nick = message.source_nickname().unwrap_or("unknown").to_string();
let (body, is_action) = parse_ctcp_action(text);
let event = BridgeEvent::ChatMessage { let event = BridgeEvent::ChatMessage {
source: Source::Irc, source: Source::Irc,
username: nick, username: nick,
body: crate::irc_format::strip_formatting(text), body: crate::irc_format::strip_formatting(&body),
id: None, id: None,
is_action,
}; };
if event_tx.send(event).await.is_err() { if event_tx.send(event).await.is_err() {
return Ok(()); return Ok(());
@@ -88,7 +92,11 @@ async fn connect_and_run(
} }
} }
Some(text) = outbound_rx.recv() => { Some(text) = outbound_rx.recv() => {
sender.send_privmsg(&config.channel, &text)?; if text.starts_with('\x01') {
sender.send(Command::PRIVMSG(config.channel.clone(), text))?;
} else {
sender.send_privmsg(&config.channel, &text)?;
}
} }
_ = shutdown.changed() => { _ = shutdown.changed() => {
let _ = sender.send_quit("Bridge shutting down"); let _ = sender.send_quit("Bridge shutting down");
@@ -97,3 +105,60 @@ async fn connect_and_run(
} }
} }
} }
const CTCP_DELIM: u8 = b'\x01';
const ACTION_PREFIX: &str = "ACTION ";
/// If `text` is a CTCP ACTION (`\x01ACTION ...\x01`), returns the inner
/// action body and `true`. Otherwise returns the original text and `false`.
fn parse_ctcp_action(text: &str) -> (String, bool) {
let bytes = text.as_bytes();
if bytes.len() >= 9
&& bytes[0] == CTCP_DELIM
&& bytes[bytes.len() - 1] == CTCP_DELIM
&& text[1..].starts_with(ACTION_PREFIX)
{
let action_body = &text[1 + ACTION_PREFIX.len()..text.len() - 1];
(action_body.to_string(), true)
} else {
(text.to_string(), false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_action_message() {
let (body, is_action) = parse_ctcp_action("\x01ACTION sniffs\x01");
assert!(is_action);
assert_eq!(body, "sniffs");
}
#[test]
fn parses_action_with_multiple_words() {
let (body, is_action) = parse_ctcp_action("\x01ACTION waves hello\x01");
assert!(is_action);
assert_eq!(body, "waves hello");
}
#[test]
fn normal_message_unchanged() {
let (body, is_action) = parse_ctcp_action("just a regular message");
assert!(!is_action);
assert_eq!(body, "just a regular message");
}
#[test]
fn incomplete_ctcp_not_treated_as_action() {
let (_, is_action) = parse_ctcp_action("\x01ACTION sniffs");
assert!(!is_action);
}
#[test]
fn non_action_ctcp_not_treated_as_action() {
let (_, is_action) = parse_ctcp_action("\x01VERSION\x01");
assert!(!is_action);
}
}

View File

@@ -38,6 +38,7 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let config = config::BridgeConfig::load(&cli.config)?; let config = config::BridgeConfig::load(&cli.config)?;
let access_token = config.owncast_access_token()?; let access_token = config.owncast_access_token()?;
let webhook_secret = config::BridgeConfig::webhook_secret();
info!("Starting owncast-irc-bridge"); info!("Starting owncast-irc-bridge");
@@ -64,7 +65,7 @@ async fn main() -> anyhow::Result<()> {
let webhook_port = config.owncast.webhook_port; let webhook_port = config.owncast.webhook_port;
let webhook_event_tx = event_tx.clone(); let webhook_event_tx = event_tx.clone();
let _webhook_handle = tokio::spawn(async move { let _webhook_handle = tokio::spawn(async move {
if let Err(e) = webhook::run_webhook_server(webhook_port, webhook_event_tx).await { if let Err(e) = webhook::run_webhook_server(webhook_port, webhook_event_tx, webhook_secret).await {
tracing::error!(error = %e, "Webhook server failed"); tracing::error!(error = %e, "Webhook server failed");
} }
}); });

View File

@@ -141,7 +141,7 @@ async fn handle_event(
owncast_state: &OwncastState, owncast_state: &OwncastState,
) { ) {
match event { match event {
BridgeEvent::ChatMessage { source, username, body, id } => { BridgeEvent::ChatMessage { source, username, body, id, is_action } => {
if let Some(ref msg_id) = id { if let Some(ref msg_id) = id {
if dedup.is_duplicate(msg_id) { if dedup.is_duplicate(msg_id) {
return; return;
@@ -153,7 +153,11 @@ async fn handle_event(
if *owncast_state == OwncastState::Unavailable { if *owncast_state == OwncastState::Unavailable {
return; return;
} }
let formatted = format!("{} &lt;{}&gt; {}", settings.irc_prefix, username, body); let formatted = if is_action {
format!("{} * {} {} *", settings.irc_prefix, username, body)
} else {
format!("{} &lt;{}&gt; {}", settings.irc_prefix, username, body)
};
echo.record_sent(&body); echo.record_sent(&body);
if let Err(e) = api_client.send_chat_message(&formatted).await { if let Err(e) = api_client.send_chat_message(&formatted).await {
warn!(error = %e, "Failed to send to Owncast"); warn!(error = %e, "Failed to send to Owncast");
@@ -163,7 +167,9 @@ async fn handle_event(
if echo.is_echo(&body) { if echo.is_echo(&body) {
return; return;
} }
let formatted = format!("{} <{}> {}", settings.owncast_prefix, username, body); let formatted = format_owncast_to_irc(
&settings.owncast_prefix, &username, &body,
);
if irc_tx.send(formatted).await.is_err() { if irc_tx.send(formatted).await.is_err() {
warn!("IRC outbound channel closed"); warn!("IRC outbound channel closed");
} }
@@ -200,6 +206,14 @@ async fn handle_state_change(
} }
} }
fn format_owncast_to_irc(prefix: &str, username: &str, body: &str) -> String {
if let Some(action_body) = body.strip_prefix("/me ") {
format!("\x01ACTION {} \x02{}\x02 {}\x01", prefix, username, action_body)
} else {
format!("{} <{}> {}", prefix, username, body)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -233,4 +247,34 @@ mod tests {
assert!(suppressor.is_echo("hello from IRC")); assert!(suppressor.is_echo("hello from IRC"));
assert!(!suppressor.is_echo("different message")); assert!(!suppressor.is_echo("different message"));
} }
#[test]
fn test_owncast_me_formats_as_ctcp_action() {
let result = format_owncast_to_irc("[OC]", "viewer42", "/me claps");
assert_eq!(result, "\x01ACTION [OC] \x02viewer42\x02 claps\x01");
}
#[test]
fn test_owncast_me_multi_word_action() {
let result = format_owncast_to_irc("[OC]", "viewer42", "/me claps loudly");
assert_eq!(result, "\x01ACTION [OC] \x02viewer42\x02 claps loudly\x01");
}
#[test]
fn test_owncast_normal_message_unchanged() {
let result = format_owncast_to_irc("[OC]", "viewer42", "hello world");
assert_eq!(result, "[OC] <viewer42> hello world");
}
#[test]
fn test_owncast_me_without_space_is_normal() {
let result = format_owncast_to_irc("[OC]", "viewer42", "/me");
assert_eq!(result, "[OC] <viewer42> /me");
}
#[test]
fn test_owncast_me_mid_message_is_normal() {
let result = format_owncast_to_irc("[OC]", "viewer42", "hello /me claps");
assert_eq!(result, "[OC] <viewer42> hello /me claps");
}
} }

View File

@@ -1,4 +1,4 @@
use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use axum::{extract::{Query, State}, http::StatusCode, routing::post, Json, Router};
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -52,6 +52,7 @@ impl WebhookPayload {
username: data.user.display_name, username: data.user.display_name,
body: strip_html(&data.body), body: strip_html(&data.body),
id: Some(data.id), id: Some(data.id),
is_action: false,
}) })
} }
"STREAM_STARTED" => { "STREAM_STARTED" => {
@@ -66,15 +67,32 @@ impl WebhookPayload {
} }
} }
#[derive(Deserialize)]
struct WebhookQuery {
secret: Option<String>,
}
#[derive(Clone)] #[derive(Clone)]
struct WebhookState { struct WebhookState {
event_tx: mpsc::Sender<BridgeEvent>, event_tx: mpsc::Sender<BridgeEvent>,
secret: Option<String>,
} }
async fn handle_webhook( async fn handle_webhook(
State(state): State<WebhookState>, State(state): State<WebhookState>,
Query(query): Query<WebhookQuery>,
Json(payload): Json<WebhookPayload>, Json(payload): Json<WebhookPayload>,
) -> StatusCode { ) -> StatusCode {
if let Some(ref expected) = state.secret {
match query.secret {
Some(ref provided) if provided == expected => {}
_ => {
warn!("Webhook request rejected: invalid or missing secret");
return StatusCode::UNAUTHORIZED;
}
}
}
info!(event_type = %payload.event_type, "Received webhook"); info!(event_type = %payload.event_type, "Received webhook");
match payload.into_bridge_event() { match payload.into_bridge_event() {
@@ -92,8 +110,13 @@ async fn handle_webhook(
pub async fn run_webhook_server( pub async fn run_webhook_server(
port: u16, port: u16,
event_tx: mpsc::Sender<BridgeEvent>, event_tx: mpsc::Sender<BridgeEvent>,
secret: Option<String>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let state = WebhookState { event_tx }; if secret.is_none() {
warn!("WEBHOOK_SECRET is not set — webhook endpoint accepts all requests");
}
let state = WebhookState { event_tx, secret };
let app = Router::new() let app = Router::new()
.route("/webhook", post(handle_webhook)) .route("/webhook", post(handle_webhook))
.with_state(state); .with_state(state);
@@ -109,6 +132,91 @@ pub async fn run_webhook_server(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn build_app(secret: Option<String>) -> (Router, mpsc::Receiver<BridgeEvent>) {
let (event_tx, rx) = mpsc::channel(16);
let state = WebhookState { event_tx, secret };
let app = Router::new()
.route("/webhook", post(handle_webhook))
.with_state(state);
(app, rx)
}
fn chat_body() -> String {
serde_json::json!({
"type": "CHAT",
"eventData": {
"user": { "displayName": "test", "isBot": false },
"body": "hi",
"id": "msg1",
"visible": true
}
})
.to_string()
}
#[tokio::test]
async fn test_auth_correct_secret_returns_200() {
let (app, _rx) = build_app(Some("mysecret".to_string()));
let resp = app
.oneshot(
Request::post("/webhook?secret=mysecret")
.header("content-type", "application/json")
.body(Body::from(chat_body()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_auth_wrong_secret_returns_401() {
let (app, _rx) = build_app(Some("mysecret".to_string()));
let resp = app
.oneshot(
Request::post("/webhook?secret=wrong")
.header("content-type", "application/json")
.body(Body::from(chat_body()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_auth_missing_secret_returns_401() {
let (app, _rx) = build_app(Some("mysecret".to_string()));
let resp = app
.oneshot(
Request::post("/webhook")
.header("content-type", "application/json")
.body(Body::from(chat_body()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_auth_no_secret_configured_accepts_all() {
let (app, _rx) = build_app(None);
let resp = app
.oneshot(
Request::post("/webhook")
.header("content-type", "application/json")
.body(Body::from(chat_body()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[test] #[test]
fn test_parse_chat_event() { fn test_parse_chat_event() {
@@ -124,7 +232,7 @@ mod tests {
let payload: WebhookPayload = serde_json::from_value(json).unwrap(); let payload: WebhookPayload = serde_json::from_value(json).unwrap();
let event = payload.into_bridge_event(); let event = payload.into_bridge_event();
assert!(event.is_some()); assert!(event.is_some());
if let Some(BridgeEvent::ChatMessage { source, username, body, id }) = event { if let Some(BridgeEvent::ChatMessage { source, username, body, id, .. }) = event {
assert_eq!(source, Source::Owncast); assert_eq!(source, Source::Owncast);
assert_eq!(username, "viewer42"); assert_eq!(username, "viewer42");
assert_eq!(body, "hello world"); assert_eq!(body, "hello world");

View File

@@ -148,6 +148,7 @@ fn parse_ws_message(text: &str) -> Option<BridgeEvent> {
username: display_name, username: display_name,
body: strip_html(body), body: strip_html(body),
id, id,
is_action: false,
}) })
} }
_ => None, _ => None,