2025-03-12 23:12:13 -07:00
|
|
|
use std::sync::LazyLock;
|
2025-03-12 23:17:18 -07:00
|
|
|
|
2025-02-26 13:10:47 -08:00
|
|
|
use anyhow::Context;
|
|
|
|
use axum::{
|
2025-03-08 22:18:24 -08:00
|
|
|
extract::Query,
|
2025-02-26 13:10:47 -08:00
|
|
|
response::{IntoResponse, Json},
|
|
|
|
routing::get,
|
|
|
|
Router,
|
|
|
|
};
|
2025-03-10 14:52:02 -07:00
|
|
|
use chrono::TimeDelta;
|
2025-02-26 13:10:47 -08:00
|
|
|
use diesel::{dsl::insert_into, prelude::*, update};
|
2025-03-12 23:12:13 -07:00
|
|
|
use regex::Regex;
|
2025-02-26 13:10:45 -08:00
|
|
|
use serde::Deserialize;
|
|
|
|
use serde_json::json;
|
2025-02-26 13:10:47 -08:00
|
|
|
use uuid::Uuid;
|
2025-03-12 23:12:13 -07:00
|
|
|
use validator::Validate;
|
2025-02-26 13:10:47 -08:00
|
|
|
|
|
|
|
use crate::{
|
2025-03-13 00:09:19 -07:00
|
|
|
api_keys::{try_parse_as_uuid, ApiKey},
|
2025-02-26 13:10:47 -08:00
|
|
|
app_error::AppError,
|
2025-02-26 13:10:43 -08:00
|
|
|
app_state::{AppState, DbConn},
|
2025-03-08 22:18:24 -08:00
|
|
|
channels::Channel,
|
2025-03-10 14:52:02 -07:00
|
|
|
governors::Governor,
|
2025-03-12 23:17:18 -07:00
|
|
|
projects::{Project, DEFAULT_PROJECT_NAME},
|
2025-03-12 23:16:22 -07:00
|
|
|
schema::{api_keys, messages},
|
2025-02-26 13:10:47 -08:00
|
|
|
};
|
|
|
|
|
2025-03-10 14:52:02 -07:00
|
|
|
const TEAM_GOVERNOR_DEFAULT_WINDOW_SIZE_SEC: i64 = 300;
|
|
|
|
const TEAM_GOVERNOR_DEFAULT_MAX_COUNT: i32 = 50;
|
|
|
|
|
2025-03-12 23:12:13 -07:00
|
|
|
static RE_PROJECT_NAME: LazyLock<Regex> =
|
|
|
|
LazyLock::new(|| Regex::new(r"^[a-z0-9_-]{1,100}$").unwrap());
|
|
|
|
|
2025-02-26 13:10:47 -08:00
|
|
|
pub fn new_router(state: AppState) -> Router<AppState> {
|
|
|
|
Router::new().route("/say", get(say_get)).with_state(state)
|
|
|
|
}
|
|
|
|
|
2025-03-12 23:12:13 -07:00
|
|
|
#[derive(Deserialize, Validate)]
|
2025-02-26 13:10:47 -08:00
|
|
|
struct SayQuery {
|
2025-03-12 23:12:13 -07:00
|
|
|
#[serde(alias = "k")]
|
2025-03-13 00:09:19 -07:00
|
|
|
key: String,
|
2025-03-12 23:12:13 -07:00
|
|
|
#[serde(alias = "p")]
|
2025-03-12 23:17:18 -07:00
|
|
|
#[serde(default = "default_project")]
|
2025-03-12 23:12:13 -07:00
|
|
|
#[validate(regex(
|
|
|
|
path = *RE_PROJECT_NAME,
|
|
|
|
message = "may be no more than 100 characters and contain only alphanumerics, -, and _",
|
|
|
|
))]
|
2025-02-26 13:10:47 -08:00
|
|
|
project: String,
|
2025-03-12 23:12:13 -07:00
|
|
|
#[serde(alias = "m")]
|
|
|
|
#[validate(length(
|
|
|
|
min = 1,
|
|
|
|
max = 2048,
|
|
|
|
message = "message must be non-empty and no larger than 2KiB"
|
|
|
|
))]
|
2025-02-26 13:10:47 -08:00
|
|
|
message: String,
|
|
|
|
}
|
|
|
|
|
2025-03-12 23:17:18 -07:00
|
|
|
fn default_project() -> String {
|
|
|
|
DEFAULT_PROJECT_NAME.to_string()
|
|
|
|
}
|
|
|
|
|
2025-02-26 13:10:47 -08:00
|
|
|
async fn say_get(
|
|
|
|
DbConn(db_conn): DbConn,
|
2025-03-12 23:12:13 -07:00
|
|
|
Query(mut query): Query<SayQuery>,
|
2025-02-26 13:10:47 -08:00
|
|
|
) -> Result<impl IntoResponse, AppError> {
|
2025-03-12 23:12:13 -07:00
|
|
|
query.project = query.project.to_lowercase().replace(" ", "_");
|
|
|
|
query.validate().map_err(AppError::from_validation_errors)?;
|
|
|
|
|
2025-02-26 13:10:27 -08:00
|
|
|
let api_key = {
|
2025-03-13 00:09:19 -07:00
|
|
|
let query_key = try_parse_as_uuid(&query.key).or(Err(AppError::ForbiddenError(
|
|
|
|
"key not accepted".to_string(),
|
|
|
|
)))?;
|
2025-02-26 13:10:27 -08:00
|
|
|
db_conn
|
|
|
|
.interact::<_, Result<ApiKey, AppError>>(move |conn| {
|
|
|
|
update(api_keys::table.filter(ApiKey::with_id(query_key)))
|
|
|
|
.set(api_keys::last_used_at.eq(diesel::dsl::now))
|
|
|
|
.returning(ApiKey::as_returning())
|
|
|
|
.get_result(conn)
|
|
|
|
.optional()
|
|
|
|
.context("failed to get API key")?
|
2025-03-13 00:09:19 -07:00
|
|
|
.ok_or(AppError::ForbiddenError("key not accepted.".to_string()))
|
2025-02-26 13:10:27 -08:00
|
|
|
})
|
|
|
|
.await
|
|
|
|
.unwrap()?
|
|
|
|
};
|
|
|
|
|
2025-03-08 22:18:24 -08:00
|
|
|
let project = {
|
2025-03-12 23:12:13 -07:00
|
|
|
let project_name = query.project.clone();
|
2025-02-26 13:10:27 -08:00
|
|
|
db_conn
|
2025-03-08 22:18:24 -08:00
|
|
|
.interact::<_, Result<Project, AppError>>(move |conn| {
|
2025-02-26 13:10:27 -08:00
|
|
|
conn.transaction(move |conn| {
|
2025-03-08 22:18:24 -08:00
|
|
|
Ok(
|
|
|
|
match Project::all()
|
|
|
|
.filter(Project::with_team(api_key.team_id))
|
|
|
|
.filter(Project::with_name(project_name.clone()))
|
|
|
|
.first(conn)
|
|
|
|
.optional()
|
|
|
|
.context("failed to load project")?
|
|
|
|
{
|
|
|
|
Some(project) => project,
|
2025-03-12 23:16:22 -07:00
|
|
|
None => Project::insert_new(conn, &api_key.team_id, &project_name)
|
2025-03-08 22:18:24 -08:00
|
|
|
.context("failed to insert project")?,
|
|
|
|
},
|
|
|
|
)
|
2025-02-26 13:10:27 -08:00
|
|
|
})
|
|
|
|
})
|
|
|
|
.await
|
|
|
|
.unwrap()?
|
2025-02-26 13:10:47 -08:00
|
|
|
};
|
2025-03-10 14:52:02 -07:00
|
|
|
|
|
|
|
let team_governor = {
|
|
|
|
let team_id = project.team_id.clone();
|
|
|
|
db_conn
|
|
|
|
.interact::<_, Result<Governor, AppError>>(move |conn| {
|
|
|
|
// TODO: extract this logic to a method in crate::governors,
|
|
|
|
// and create governor proactively on team creation
|
|
|
|
match Governor::all()
|
|
|
|
.filter(Governor::with_team(team_id.clone()))
|
|
|
|
.filter(Governor::with_project(None))
|
|
|
|
.first(conn)
|
|
|
|
{
|
|
|
|
diesel::QueryResult::Ok(governor) => Ok(governor),
|
|
|
|
diesel::QueryResult::Err(diesel::result::Error::NotFound) => {
|
|
|
|
// Lazily initialize governor
|
2025-03-12 23:16:22 -07:00
|
|
|
Governor::insert_new(
|
|
|
|
conn,
|
|
|
|
&team_id,
|
|
|
|
None,
|
|
|
|
&TimeDelta::seconds(TEAM_GOVERNOR_DEFAULT_WINDOW_SIZE_SEC),
|
|
|
|
TEAM_GOVERNOR_DEFAULT_MAX_COUNT,
|
|
|
|
)
|
|
|
|
.map_err(Into::into)
|
2025-03-10 14:52:02 -07:00
|
|
|
}
|
|
|
|
diesel::QueryResult::Err(err) => Err(err.into()),
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.await
|
|
|
|
.unwrap()?
|
|
|
|
};
|
|
|
|
|
|
|
|
if db_conn
|
|
|
|
.interact::<_, Result<Option<_>, anyhow::Error>>(move |conn| {
|
|
|
|
team_governor.create_entry(conn)
|
|
|
|
})
|
|
|
|
.await
|
|
|
|
.unwrap()?
|
|
|
|
.is_none()
|
|
|
|
{
|
|
|
|
return Err(AppError::TooManyRequestsError(
|
|
|
|
"team rate limit exceeded".to_string(),
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
2025-03-08 22:18:24 -08:00
|
|
|
let selected_channels = {
|
|
|
|
let project = project.clone();
|
|
|
|
db_conn
|
|
|
|
.interact::<_, Result<Vec<Channel>, AppError>>(move |conn| {
|
|
|
|
Ok(project
|
|
|
|
.selected_channels()
|
|
|
|
.load(conn)
|
|
|
|
.context("failed to load selected channels")?)
|
|
|
|
})
|
|
|
|
.await
|
|
|
|
.unwrap()?
|
|
|
|
};
|
2025-02-26 13:10:45 -08:00
|
|
|
|
2025-03-08 22:18:24 -08:00
|
|
|
{
|
|
|
|
let selected_channels = selected_channels.clone();
|
|
|
|
db_conn
|
|
|
|
.interact::<_, Result<_, AppError>>(move |conn| {
|
|
|
|
for channel in selected_channels {
|
|
|
|
insert_into(messages::table)
|
|
|
|
.values((
|
|
|
|
messages::id.eq(Uuid::now_v7()),
|
|
|
|
messages::channel_id.eq(&channel.id),
|
|
|
|
messages::project_id.eq(&project.id),
|
|
|
|
messages::message.eq(&query.message),
|
|
|
|
))
|
|
|
|
.execute(conn)?;
|
|
|
|
}
|
|
|
|
Ok(())
|
|
|
|
})
|
|
|
|
.await
|
|
|
|
.unwrap()?;
|
2025-02-26 13:10:47 -08:00
|
|
|
}
|
2025-03-08 22:18:24 -08:00
|
|
|
tracing::debug!("queued {} messages", selected_channels.len());
|
2025-02-26 13:10:45 -08:00
|
|
|
|
|
|
|
Ok(Json(json!({ "ok": true })))
|
2025-02-26 13:10:47 -08:00
|
|
|
}
|