1
0
Fork 0
forked from 2sys/shoutdotdev
shoutdotdev/src/v0_router.rs

188 lines
6 KiB
Rust
Raw Normal View History

2025-03-12 23:12:13 -07:00
use std::sync::LazyLock;
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,
};
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::{
api_keys::ApiKey,
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,
governors::Governor,
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
};
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-02-26 13:10:47 -08:00
key: Uuid,
2025-03-12 23:12:13 -07:00
#[serde(alias = "p")]
#[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,
}
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)?;
let api_key = {
let query_key = query.key.clone();
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")?
.ok_or(AppError::ForbiddenError("Key not accepted.".to_string()))
})
.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();
db_conn
2025-03-08 22:18:24 -08:00
.interact::<_, Result<Project, AppError>>(move |conn| {
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")?,
},
)
})
})
.await
.unwrap()?
2025-02-26 13:10:47 -08: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)
}
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
}