Compare commits

...

5 commits

20 changed files with 211 additions and 155 deletions

View file

@ -27,7 +27,7 @@ serde = { version = "1.0.213", features = ["derive"] }
serde_json = "1.0.132"
tokio = { version = "1.42.0", features = ["full"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "trace"] }
tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] }

View file

@ -68,20 +68,26 @@ pub type AppState = Arc<App>;
#[derive(Clone)]
pub struct ReqwestClient(pub reqwest::Client);
impl FromRef<AppState> for ReqwestClient {
fn from_ref(state: &AppState) -> Self {
ReqwestClient(state.reqwest_client.clone())
impl<S> FromRef<S> for ReqwestClient
where
S: Into<AppState> + Clone,
{
fn from_ref(state: &S) -> Self {
ReqwestClient(Into::<AppState>::into(state.clone()).reqwest_client.clone())
}
}
/// Extractor to automatically obtain a Deadpool database connection
pub struct DbConn(pub Connection);
impl FromRequestParts<AppState> for DbConn {
impl<S> FromRequestParts<S> for DbConn
where
S: Into<AppState> + Clone + Sync,
{
type Rejection = AppError;
async fn from_request_parts(_: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let conn = state.db_pool.get().await?;
async fn from_request_parts(_: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let conn = Into::<AppState>::into(state.clone()).db_pool.get().await?;
Ok(Self(conn))
}
}

View file

@ -158,7 +158,7 @@ async fn post_new_channel(
};
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
"{}/en/teams/{}/channels/{}",
base_path,
team.id.simple(),
channel.id.simple()
@ -276,7 +276,7 @@ async fn update_channel(
));
}
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
"{}/en/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
@ -366,7 +366,7 @@ async fn update_channel_email_recipient(
mailer.send_batch(vec![email]).await.remove(0)?;
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
"{}/en/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()
@ -447,7 +447,7 @@ async fn verify_email(
};
Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}",
"{}/en/teams/{}/channels/{}",
base_path,
team_id.simple(),
channel_id.simple()

91
src/cli.rs Normal file
View file

@ -0,0 +1,91 @@
use anyhow::Result;
use axum::{
http::{header::CONTENT_SECURITY_POLICY, HeaderValue},
middleware::map_request,
};
use chrono::{TimeDelta, Utc};
use clap::{Parser, Subcommand};
use tokio::time::sleep;
use tower::ServiceBuilder;
use tower_http::{
compression::CompressionLayer, normalize_path::NormalizePathLayer,
set_header::response::SetResponseHeaderLayer, trace::TraceLayer,
};
use crate::{
app_state::AppState, middleware::lowercase_uri_path, router::new_router, worker::run_worker,
};
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Parser)]
pub struct WorkerArgs {
/// Loop the every n seconds instead of exiting after execution
#[arg(long)]
auto_loop_seconds: Option<u32>,
}
#[derive(Subcommand)]
pub enum Commands {
/// Run web server
Serve,
/// Run background worker
Worker(WorkerArgs),
// TODO: add a low-frequency worker task exclusively for self-healing
// mechanisms like Governor::reset_all()
}
pub async fn serve_command(state: AppState) -> Result<()> {
let router = new_router(state.clone()).layer(
ServiceBuilder::new()
.layer(map_request(lowercase_uri_path))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(NormalizePathLayer::trim_trailing_slash())
.layer(SetResponseHeaderLayer::if_not_present(
CONTENT_SECURITY_POLICY,
HeaderValue::from_static("frame-ancestors 'none'"),
)),
);
let listener =
tokio::net::TcpListener::bind((state.settings.host.clone(), state.settings.port))
.await
.unwrap();
tracing::info!(
"App running at http://{}:{}{}",
state.settings.host,
state.settings.port,
state.settings.base_path
);
axum::serve(listener, router).await.map_err(Into::into)
}
pub async fn worker_command(args: &WorkerArgs, state: AppState) -> Result<()> {
if let Some(loop_seconds) = args.auto_loop_seconds {
let loop_delta = TimeDelta::seconds(i64::from(loop_seconds));
loop {
let t_next_loop = Utc::now() + loop_delta;
if let Err(err) = run_worker(state.clone()).await {
tracing::error!("{}", err)
}
let sleep_delta = t_next_loop - Utc::now();
match sleep_delta.to_std() {
Ok(duration) => {
sleep(duration).await;
}
Err(_) => { /* sleep_delta was < 0, so don't sleep */ }
}
}
} else {
run_worker(state).await
}
}

View file

@ -1,70 +1,43 @@
use axum::middleware::map_request;
use chrono::{TimeDelta, Utc};
use clap::{Parser, Subcommand};
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use clap::Parser as _;
use diesel_migrations::MigrationHarness;
use dotenvy::dotenv;
use tokio::time::sleep;
use tower::ServiceBuilder;
use tower_http::{
compression::CompressionLayer, normalize_path::NormalizePathLayer, trace::TraceLayer,
};
use tracing_subscriber::EnvFilter;
use crate::{
app_state::{App, AppState},
middleware::lowercase_uri_path,
router::new_router,
cli::{serve_command, worker_command, Cli, Commands},
migrations::MIGRATIONS,
settings::Settings,
worker::run_worker,
};
pub mod api_keys;
pub mod app_error;
pub mod app_state;
pub mod auth;
pub mod channel_selections;
pub mod channels;
mod api_keys;
mod app_error;
mod app_state;
mod auth;
mod channel_selections;
mod channels;
mod channels_router;
pub mod csrf;
pub mod email;
pub mod governors;
pub mod guards;
pub mod messages;
pub mod middleware;
mod cli;
mod csrf;
mod email;
mod governors;
mod guards;
mod messages;
mod middleware;
mod migrations;
mod nav_state;
pub mod projects;
mod projects;
mod projects_router;
pub mod router;
pub mod schema;
pub mod sessions;
pub mod settings;
pub mod team_memberships;
pub mod teams;
mod router;
mod schema;
mod sessions;
mod settings;
mod team_memberships;
mod teams;
mod teams_router;
pub mod users;
mod users;
mod v0_router;
pub mod worker;
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Run web server
Serve,
/// Run background worker
Worker {
/// Loop the every n seconds instead of exiting after execution
#[arg(long)]
auto_loop_seconds: Option<u32>,
},
// TODO: add a low-frequency worker task exclusively for self-healing
// mechanisms like Governor::reset_all()
}
mod worker;
/// Run CLI
#[tokio::main]
@ -80,7 +53,6 @@ async fn main() {
let state: AppState = App::from_settings(settings.clone()).await.unwrap().into();
if settings.run_database_migrations == Some(1) {
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/");
// Run migrations on server startup
let conn = state.db_pool.get().await.unwrap();
conn.interact(|conn| conn.run_pending_migrations(MIGRATIONS).map(|_| ()))
@ -91,48 +63,7 @@ async fn main() {
let cli = Cli::parse();
match &cli.command {
Commands::Serve => {
let router = new_router(state.clone()).layer(
ServiceBuilder::new()
.layer(map_request(lowercase_uri_path))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(NormalizePathLayer::trim_trailing_slash()),
);
let listener = tokio::net::TcpListener::bind((settings.host.clone(), settings.port))
.await
.unwrap();
tracing::info!(
"App running at http://{}:{}{}",
settings.host,
settings.port,
settings.base_path
);
axum::serve(listener, router).await.unwrap();
}
Commands::Worker { auto_loop_seconds } => {
if let Some(loop_seconds) = auto_loop_seconds {
let loop_delta = TimeDelta::seconds(i64::from(*loop_seconds));
loop {
let t_next_loop = Utc::now() + loop_delta;
if let Err(err) = run_worker(state.clone()).await {
tracing::error!("{}", err)
}
let sleep_delta = t_next_loop - Utc::now();
match sleep_delta.to_std() {
Ok(duration) => {
sleep(duration).await;
}
Err(_) => { /* sleep_delta was < 0, so don't sleep */ }
}
}
} else {
run_worker(state).await.unwrap();
}
}
Commands::Serve => serve_command(state).await.unwrap(),
Commands::Worker(args) => worker_command(args, state).await.unwrap(),
}
}

3
src/migrations.rs Normal file
View file

@ -0,0 +1,3 @@
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/");

View file

@ -34,11 +34,11 @@ impl NavState {
self.team_id = Some(team.id);
self.navbar_active_item = "teams".to_string();
self.breadcrumbs.push(Breadcrumb {
href: format!("{}/teams", self.base_path),
href: format!("{}/en/teams", self.base_path),
label: "Teams".to_string(),
});
self.breadcrumbs.push(Breadcrumb {
href: format!("{}/teams/{}", self.base_path, team.id.simple()),
href: format!("{}/en/teams/{}", self.base_path, team.id.simple()),
label: team.name.clone(),
});
self
@ -50,12 +50,12 @@ impl NavState {
))?;
self.navbar_active_item = "projects".to_string();
self.breadcrumbs.push(Breadcrumb {
href: format!("{}/teams/{}/projects", self.base_path, team_id),
href: format!("{}/en/teams/{}/projects", self.base_path, team_id),
label: "Projects".to_string(),
});
self.breadcrumbs.push(Breadcrumb {
href: format!(
"{}/teams/{}/projects/{}",
"{}/en/teams/{}/projects/{}",
self.base_path,
team_id,
project.id.simple()

View file

@ -230,7 +230,7 @@ async fn update_enabled_channels(
.unwrap()?;
Ok(Redirect::to(&format!(
"{}/teams/{}/projects/{}",
"{}/en/teams/{}/projects/{}",
base_path, team_id, project_id
))
.into_response())

View file

@ -1,10 +1,15 @@
use axum::{
extract::State,
http::{header::CACHE_CONTROL, HeaderValue},
response::{IntoResponse, Redirect},
routing::get,
Router,
};
use tower_http::services::{ServeDir, ServeFile};
use tower::ServiceBuilder;
use tower_http::{
services::{ServeDir, ServeFile},
set_header::SetResponseHeaderLayer,
};
use crate::{
app_state::AppState, auth, channels_router, projects_router, settings::Settings, teams_router,
@ -13,15 +18,36 @@ use crate::{
pub fn new_router(state: AppState) -> Router<()> {
let base_path = state.settings.base_path.clone();
let app = Router::new()
let ui_router = Router::new()
.route("/", get(landing_page))
.merge(channels_router::new_router())
.merge(projects_router::new_router())
.merge(teams_router::new_router())
.merge(teams_router::new_router());
let app = Router::new()
.route("/", get(landing_page))
.merge(v0_router::new_router())
.nest("/en", ui_router)
.nest("/auth", auth::new_router())
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.fallback_service(
ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")),
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
))
.service(
ServeDir::new("static").not_found_service(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new("static/_404.html")),
),
),
)
.with_state(state);
if base_path.is_empty() {
@ -34,5 +60,5 @@ pub fn new_router(state: AppState) -> Router<()> {
}
async fn landing_page(State(Settings { base_path, .. }): State<Settings>) -> impl IntoResponse {
Redirect::to(&format!("{}/teams", base_path))
Redirect::to(&format!("{}/en/teams", base_path))
}

View file

@ -108,8 +108,11 @@ impl Settings {
}
}
impl FromRef<AppState> for Settings {
fn from_ref(state: &AppState) -> Self {
state.settings.clone()
impl<S> FromRef<S> for Settings
where
S: Into<AppState> + Clone,
{
fn from_ref(state: &S) -> Self {
Into::<AppState>::into(state.clone()).settings.clone()
}
}

View file

@ -51,7 +51,7 @@ async fn teams_page(
let nav_state = NavState::new()
.set_base_path(&base_path)
.push_slug(Breadcrumb {
href: "teams".to_string(),
href: "en/teams".to_string(),
label: "Teams".to_string(),
})
.set_navbar_active_item("teams");
@ -76,7 +76,7 @@ async fn team_page(
State(Settings { base_path, .. }): State<Settings>,
Path(team_id): Path<Uuid>,
) -> impl IntoResponse {
Redirect::to(&format!("{}/teams/{}/projects", base_path, team_id))
Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id))
}
#[derive(Deserialize)]
@ -96,7 +96,7 @@ async fn post_new_api_key(
ApiKey::generate_for_team(&db_conn, team.id).await?;
Ok(Redirect::to(&format!(
"{}/teams/{}/projects",
"{}/en/teams/{}/projects",
base_path,
team.id.hyphenated()
))
@ -113,7 +113,7 @@ async fn new_team_page(
let nav_state = NavState::new()
.set_base_path(&base_path)
.push_slug(Breadcrumb {
href: "new-team".to_string(),
href: "en/new-team".to_string(),
label: "New Team".to_string(),
})
.set_navbar_active_item("teams");

View file

@ -53,15 +53,16 @@ impl User {
#[derive(Clone, Debug)]
pub struct CurrentUser(pub User);
impl FromRequestParts<AppState> for CurrentUser {
impl<S> FromRequestParts<S> for CurrentUser
where
S: Into<AppState> + Clone + Sync,
{
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, <Self as FromRequestParts<AppState>>::Rejection> {
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state: AppState = state.clone().into();
let auth_info = parts
.extract_with_state::<AuthInfo, AppState>(state)
.extract_with_state::<AuthInfo, AppState>(&state)
.await
.map_err(|_| {
AppError::auth_redirect_from_base_path(state.settings.base_path.clone())

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -12,7 +12,7 @@
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-channel"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-channel"
>
<div class="mb-3">
<label for="channel-name-input" class="form-label">Channel Name</label>
@ -51,7 +51,7 @@
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
>
<div class="mb-3">
<label for="channel-recipient-input" class="form-label">Recipient Email</label>
@ -81,7 +81,7 @@
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/verify-email"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/verify-email"
>
<div class="mb-3">
<label for="channel-recipient-verification-code" class="form-label">
@ -114,7 +114,7 @@
<form
id="email-verification-form"
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
>
<input type="hidden" name="recipient" value="{{ email_data.recipient }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">

View file

@ -24,7 +24,7 @@
<li>
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/new-channel"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/new-channel"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="channel_type" value="email">
@ -41,7 +41,7 @@
Channels are places to send messages, alerts, and so on. Once created, they
can be connected to specific projects at the
<a
href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/projects"
href="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/projects"
>Projects page</a>.
</div>
<section class="mb-3">
@ -50,7 +50,7 @@
{% for channel in channels %}
<tr>
<td>
<a href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}">
<a href="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}">
{{ channel.name }}
</a>
</td>

View file

@ -18,7 +18,7 @@
<a
class="nav-link{% if nav_state.navbar_active_item == "teams" %} active{% endif %}"
{% if nav_state.navbar_active_item == "teams" %}aria-current="page"{% endif %}
href="{{ base_path }}/teams"
href="{{ base_path }}/en/teams"
>
Teams
</a>
@ -28,7 +28,7 @@
<a
class="nav-link{% if nav_state.navbar_active_item == "projects" %} active{% endif %}"
{% if nav_state.navbar_active_item == "projects" %}aria-current="page"{% endif %}
href="{{ base_path }}/teams/{{ team_id.simple() }}/projects"
href="{{ base_path }}/en/teams/{{ team_id.simple() }}/projects"
>
Projects
</a>
@ -37,7 +37,7 @@
<a
class="nav-link{% if nav_state.navbar_active_item == "channels" %} active{% endif %}"
{% if nav_state.navbar_active_item == "channels" %}aria-current="page"{% endif %}
href="{{ base_path }}/teams/{{ team_id.simple() }}/channels"
href="{{ base_path }}/en/teams/{{ team_id.simple() }}/channels"
>
Channels
</a>

View file

@ -1,12 +1,7 @@
{% extends "base.html" %}
{% block main %}
<nav class="container mt-4" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ base_path }}/teams">Teams</a></li>
<li class="breadcrumb-item active" aria-current="page">New Team</li>
</ol>
</nav>
{% include "breadcrumbs.html" %}
<main class="mt-5">
<section class="container mb-3">
<h1 class="mt-5">New Team</h1>
@ -19,7 +14,7 @@
</div>
<div class="mb-3">
<button class="btn btn-primary" type="submit">Submit</button>
<a class="btn btn-secondary" role="button" href="{{ base_path }}/teams">
<a class="btn btn-secondary" role="button" href="{{ base_path }}/en/teams">
Cancel
</a>
</div>

View file

@ -12,7 +12,7 @@
<h2>Enabled Channels</h2>
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/projects/{{ project.id.simple() }}/update-enabled-channels"
action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/projects/{{ project.id.simple() }}/update-enabled-channels"
>
<div class="mb-3">
<table class="table">
@ -29,7 +29,7 @@
<label for="enable-channel-switch-{{ channel.id.simple() }}">
<a
target="_blank"
href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}"
href="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}"
>
{{ channel.name }}
</a>

View file

@ -38,7 +38,7 @@
{% for project in projects %}
<tr>
<td>
<a href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/projects/{{ project.id.simple() }}">
<a href="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/projects/{{ project.id.simple() }}">
{{ project.name }}
</a>
</td>
@ -53,7 +53,7 @@
<h1 class="mb-4">API Keys</h1>
</section>
<section class="mb-3">
<form method="post" action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/new-api-key">
<form method="post" action="{{ base_path }}/en/teams/{{ nav_state.team_id.unwrap().simple() }}/new-api-key">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Generate Key</button>
</form>

View file

@ -9,7 +9,7 @@
<div class="d-flex justify-content-between align-items-center">
<h1>Teams</h1>
<div>
<a href="{{ base_path }}/new-team" role="button" class="btn btn-primary">New Team</a>
<a href="{{ base_path }}/en/new-team" role="button" class="btn btn-primary">New Team</a>
</div>
</div>
</section>
@ -24,7 +24,7 @@
{% for team in teams %}
<tr>
<td>
<a href="{{ base_path }}/teams/{{ team.id }}">
<a href="{{ base_path }}/en/teams/{{ team.id }}">
{{ team.name }}
</a>
</td>