Initial import

This commit is contained in:
2023-09-15 00:07:47 -07:00
commit e31abbd9ec
9 changed files with 3694 additions and 0 deletions

125
src/config.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::{collections::HashMap, fmt, fs::File, io::BufReader};
use matrix_sdk::ruma::{OwnedRoomOrAliasId, OwnedUserId, RoomOrAliasId, UserId};
use serde::de;
#[derive(Deserialize)]
pub struct RepoConfig {
pub token: String,
#[serde(default)]
#[serde(deserialize_with = "deser_optional_room_or_alias_id")]
pub room: Option<OwnedRoomOrAliasId>,
}
#[derive(Deserialize)]
pub struct Config {
pub bind_address: Option<String>,
pub bind_port: Option<u16>,
#[serde(deserialize_with = "deser_user_id")]
pub user_id: OwnedUserId,
pub password: String,
#[serde(default)]
#[serde(deserialize_with = "deser_optional_room_or_alias_id")]
pub default_room: Option<OwnedRoomOrAliasId>,
pub repo_configs: HashMap<String, RepoConfig>, // key is repo url without scheme; e.g.
// gitlab.xfce.org/xfce/xfdesktop
}
fn deser_user_id<'de, D>(deserializer: D) -> Result<OwnedUserId, D::Error>
where
D: de::Deserializer<'de>,
{
struct UserIdVisitor;
impl<'de> de::Visitor<'de> for UserIdVisitor {
type Value = OwnedUserId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a matrix user ID")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
UserId::parse(v).map_err(E::custom)
}
}
deserializer.deserialize_any(UserIdVisitor)
}
fn deser_room_or_alias_id<'de, D>(deserializer: D) -> Result<OwnedRoomOrAliasId, D::Error>
where
D: de::Deserializer<'de>,
{
struct RoomOrAliasIdVisitor;
impl<'de> de::Visitor<'de> for RoomOrAliasIdVisitor {
type Value = OwnedRoomOrAliasId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a matrix room ID")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
RoomOrAliasId::parse(v).map_err(E::custom)
}
}
deserializer.deserialize_any(RoomOrAliasIdVisitor)
}
fn deser_optional_room_or_alias_id<'de, D>(deserializer: D) -> Result<Option<OwnedRoomOrAliasId>, D::Error>
where
D: de::Deserializer<'de>,
{
struct OptionalRoomOrAliasIdVisitor;
impl<'de> de::Visitor<'de> for OptionalRoomOrAliasIdVisitor {
type Value = Option<OwnedRoomOrAliasId>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("null or matrix room ID")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Some(deser_room_or_alias_id(deserializer)?))
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
RoomOrAliasId::parse(v).map(Some).map_err(E::custom)
}
}
deserializer.deserialize_any(OptionalRoomOrAliasIdVisitor)
}
fn load_blocking(path: String) -> anyhow::Result<Config> {
let f = File::open(path)?;
let r = BufReader::new(f);
let config: Config = serde_yaml::from_reader(r)?;
Ok(config)
}
pub async fn load<S: AsRef<str>>(path: S) -> anyhow::Result<Config> {
let p = String::from(path.as_ref());
let config = tokio::task::spawn_blocking(move || load_blocking(p)).await??;
Ok(config)
}

188
src/event.rs Normal file
View File

@@ -0,0 +1,188 @@
pub trait GitlabEventExt {
fn project(&self) -> &Project;
fn r#ref(&self) -> &str;
fn user(&self) -> &str;
}
#[derive(Debug, Deserialize)]
pub struct Project {
pub id: u64,
pub name: String,
pub description: String,
pub web_url: String,
pub avatar_url: Option<String>,
pub git_ssh_url: String,
pub git_http_url: String,
pub namespace: String,
pub visibility_level: u32,
pub path_with_namespace: String,
pub default_branch: String,
pub homepage: String,
pub url: String,
pub ssh_url: String,
pub http_url: String,
}
#[derive(Debug, Deserialize)]
pub struct Repository {
pub name: String,
pub url: String,
pub description: String,
pub homepage: String,
pub git_http_url: String,
pub git_ssh_url: String,
pub visibility_level: u32,
}
#[derive(Debug, Deserialize)]
pub struct Commit {
pub id: String,
pub title: String,
pub url: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "object_kind")]
pub enum GitlabEvent {
#[serde(rename = "push")]
Push {
event_name: String,
before: String,
after: String,
r#ref: String,
ref_protected: bool,
checkout_sha: String,
user_id: u64,
user_name: String,
user_username: String,
user_email: String,
user_avatar: Option<String>,
project_id: u64,
project: Project,
repository: Repository,
commits: Vec<Commit>,
total_commits_count: u64,
},
#[serde(rename = "tag_push")]
TagPush {
event_name: String,
before: String,
after: String,
r#ref: String,
ref_protected: bool,
checkout_sha: String,
user_id: u64,
user_name: String,
user_avatar: Option<String>,
project_id: u64,
project: Project,
repository: Repository,
commits: Vec<Commit>,
total_commits_count: u64,
},
}
impl GitlabEventExt for GitlabEvent {
fn project(&self) -> &Project {
match self {
GitlabEvent::Push { project, .. } => &project,
GitlabEvent::TagPush { project, .. } => &project,
}
}
fn r#ref(&self) -> &str {
match self {
GitlabEvent::Push { r#ref, .. } => &r#ref,
GitlabEvent::TagPush { r#ref, .. } => &r#ref,
}
}
fn user(&self) -> &str {
match self {
GitlabEvent::Push { user_name, .. } => &user_name,
GitlabEvent::TagPush { user_name, .. } => &user_name,
}
}
}
#[cfg(test)]
mod test {
use super::*;
use std::{fs::File, io::BufReader};
fn load_test_data(name: &str) -> anyhow::Result<GitlabEvent> {
let f = File::open(&format!("{}/test-data/{}.json", env!("CARGO_MANIFEST_DIR"), name))?;
let r = BufReader::new(f);
let event: GitlabEvent = serde_json::from_reader(r)?;
Ok(event)
}
#[test]
pub fn parse_push_event() -> anyhow::Result<()> {
let event = load_test_data("push-event")?;
match event {
GitlabEvent::Push {
event_name,
before,
after,
r#ref,
checkout_sha,
user_username,
project,
repository,
total_commits_count,
..
} => {
assert_eq!(event_name, "push");
assert_eq!(before, "95790bf891e76fee5e1747ab589903a6a1f80f22");
assert_eq!(after, "da1560886d4f094c3e6c9ef40349f7d38b5d27d7");
assert_eq!(r#ref, "refs/heads/master");
assert_eq!(checkout_sha, "da1560886d4f094c3e6c9ef40349f7d38b5d27d7");
assert_eq!(user_username, "jsmith");
assert_eq!(project.name, "Diaspora");
assert_eq!(project.namespace, "Mike");
assert_eq!(repository.name, "Diaspora");
assert_eq!(repository.url, "git@example.com:mike/diaspora.git");
assert_eq!(total_commits_count, 4);
}
_ => panic!("not a push event"),
};
Ok(())
}
#[test]
pub fn parse_tag_push_event() -> anyhow::Result<()> {
let event = load_test_data("tag-push-event")?;
match event {
GitlabEvent::TagPush {
event_name,
before,
after,
r#ref,
checkout_sha,
user_name,
project,
repository,
total_commits_count,
..
} => {
assert_eq!(event_name, "tag_push");
assert_eq!(before, "0000000000000000000000000000000000000000");
assert_eq!(after, "82b3d5ae55f7080f1e6022629cdb57bfae7cccc7");
assert_eq!(r#ref, "refs/tags/v1.0.0");
assert_eq!(checkout_sha, "82b3d5ae55f7080f1e6022629cdb57bfae7cccc7");
assert_eq!(user_name, "John Smith");
assert_eq!(project.name, "Example");
assert_eq!(project.namespace, "Jsmith");
assert_eq!(repository.name, "Example");
assert_eq!(total_commits_count, 0);
}
_ => panic!("not a tag push event"),
};
Ok(())
}
}

205
src/main.rs Normal file
View File

@@ -0,0 +1,205 @@
#[macro_use(anyhow)]
extern crate anyhow;
#[macro_use]
extern crate log;
#[macro_use]
extern crate serde;
mod config;
mod event;
use std::{env, net::IpAddr, ops::Deref, sync::Arc, time::Duration};
use constant_time_eq::constant_time_eq;
use event::{GitlabEvent, GitlabEventExt};
use http::StatusCode;
use matrix_sdk::{
config::SyncSettings,
ruma::{events::room::message::RoomMessageEventContent, OwnedRoomOrAliasId},
BaseRoom, Client,
};
use warp::Filter;
async fn build_sync_settings(matrix_client: &Client) -> SyncSettings {
let mut settings = SyncSettings::default().timeout(Duration::from_secs(30));
if let Some(token) = matrix_client.sync_token().await {
settings = settings.token(token);
}
settings
}
async fn matrix_connect(config: &config::Config) -> anyhow::Result<Client> {
let client = Client::builder()
.server_name(config.user_id.server_name())
.user_agent(format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")))
.build()
.await?;
client
.login_username(&config.user_id, &config.password)
.initial_device_display_name("Bebot")
.send()
.await?;
info!("Connected to matrix as {}; waiting for first sync", config.user_id);
let settings = build_sync_settings(&client).await;
client.sync_once(settings).await?;
info!("First matrix sync complete");
let sync_client = client.clone();
tokio::spawn(async move {
let settings = build_sync_settings(&sync_client).await;
sync_client.sync(settings).await.unwrap();
});
Ok(client)
}
async fn handle_gitlab_event(
event: GitlabEvent,
room_id: &OwnedRoomOrAliasId,
matrix_client: &Client,
) -> anyhow::Result<()> {
let project = event.project();
let refname = event
.r#ref()
.split('/')
.last()
.unwrap_or_else(|| event.r#ref())
.to_string();
let mut msg = format!("*{}* {} **{}** ", project.path_with_namespace, refname, event.user());
fn find_commit<'a>(commits: &'a Vec<event::Commit>, sha: &str) -> Option<&'a event::Commit> {
commits.iter().find(|commit| commit.id == sha)
}
match event {
GitlabEvent::Push { after, commits, .. } => {
if let Some(commit) = find_commit(&commits, &after) {
msg.push_str(&format!("{} ({})", commit.title, commit.url));
}
}
GitlabEvent::TagPush {
checkout_sha,
project,
commits,
..
} => {
if let Some(commit) = find_commit(&commits, &checkout_sha) {
msg.push_str(&format!("{} ({})", commit.title, commit.url));
} else {
msg.push_str(&format!("New tag pushed ({}/-/tags/{})", project.web_url, refname));
}
}
};
fn room_matches(a_room: &BaseRoom, our_room: &OwnedRoomOrAliasId) -> bool {
let our_room_str = our_room.as_str();
a_room.room_id().as_str() == our_room_str
|| a_room
.canonical_alias()
.iter()
.find(|alias| alias.as_str() == our_room_str)
.is_some()
|| a_room
.alt_aliases()
.iter()
.find(|alias| alias.as_str() == our_room_str)
.is_some()
}
let mut room = matrix_client
.joined_rooms()
.iter()
.find(|a_room| room_matches(Deref::deref(Deref::deref(*a_room)), room_id))
.map(|room| room.clone());
if room.is_none() {
if let Some(invited) = matrix_client
.invited_rooms()
.iter()
.find(|a_room| room_matches(Deref::deref(Deref::deref(*a_room)), room_id))
{
invited.accept_invitation().await?;
} else {
matrix_client.join_room_by_id_or_alias(room_id, &[]).await?;
}
let settings = build_sync_settings(&matrix_client).await;
matrix_client.sync_once(settings).await?;
room = matrix_client
.joined_rooms()
.iter()
.find(|a_room| room_matches(Deref::deref(Deref::deref(*a_room)), room_id))
.map(|room| room.clone());
}
if let Some(room) = room {
debug!("Sending message to {}: {}", room_id, msg);
let msg_content = RoomMessageEventContent::text_markdown(&msg);
room.send(msg_content, None).await?;
Ok(())
} else {
Err(anyhow!("Unable to join room {}", room_id))
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let lenv = env_logger::Env::new()
.filter("BEBOT_LOG")
.write_style("BEBOT_LOG_STYLE");
env_logger::init_from_env(lenv);
let config_path = env::args()
.nth(1)
.expect("Config file should be passed as only parameter");
let config = Arc::new(config::load(config_path).await?);
let addr = config
.bind_address
.as_ref()
.map(|ba| ba.clone())
.unwrap_or_else(|| "127.0.0.1".to_string())
.parse::<IpAddr>()?;
let port = config.bind_port.unwrap_or(3000);
let matrix_client = matrix_connect(&config).await?;
let gitlab = warp::path!("hooks" / "gitlab")
.and(warp::path::end())
.and(warp::post())
.and(warp::header::<String>("x-gitlab-token"))
.and(warp::body::json())
.and_then(move |token: String, event: event::GitlabEvent| {
let config = Arc::clone(&config);
let matrix_client = matrix_client.clone();
async move {
let project = event.project();
let config_key = project.homepage.replace("http://", "").replace("https://", "");
if let Some(repo_config) = config.repo_configs.get(&config_key) {
if !constant_time_eq(token.as_bytes(), repo_config.token.as_bytes()) {
warn!("Invalid token for repo '{}'", config_key);
Err(warp::reject::reject())
} else {
debug!("payload: {:?}", event);
if let Some(room) = repo_config.room.as_ref().or(config.default_room.as_ref()) {
if let Err(err) = handle_gitlab_event(event, &room, &matrix_client).await {
warn!("Failed to handle payload: {}", err);
}
Ok(warp::reply::with_status("OK", StatusCode::OK))
} else {
info!("Channel not configured for repo '{}'", config_key);
Err(warp::reject::reject())
}
}
} else {
info!("Repo '{}' unconfigured", config_key);
Err(warp::reject::reject())
}
}
});
let routes = gitlab.with(warp::log("bebot"));
warp::serve(routes).run((addr, port)).await;
Ok(())
}