use std::sync::Arc;
use std::time::Duration;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Html;
use axum::routing::{get, post};
use axum::Router;
use log::info;
use metrics_process::Collector;
use minijinja::context;
use tower_http::classify::ServerErrorsFailureClass;
use tower_http::services::ServeDir;
use tower_http::trace::{DefaultMakeSpan, TraceLayer};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use axum_prometheus::PrometheusMetricLayer;
use fieri_eternal::middleware::{
api_auth_middleware, optional_auth_middleware, web_auth_middleware, UserContext,
};
use fieri_eternal::live::{self, Live};
use fieri_eternal::osm::OsmClient;
use fieri_eternal::{
add_config_globals, build_environment, create_db_connection, handlers, AppState, Config,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"fieri_eternal=debug,webservice=debug,tower_http=info".into()
}))
.with(tracing_subscriber::fmt::layer())
.init();
let config = Arc::new(Config::from_env());
info!("configuration loaded");
let db = Arc::new(create_db_connection(&config.database_url).await?);
info!("database connection established");
if config.auto_migrate {
use migration::MigratorTrait;
info!("running pending migrations (set AUTO_MIGRATE=false to skip)");
migration::Migrator::up(db.as_ref(), None).await?;
}
// Prometheus request metrics + process (RSS/CPU/fd) metrics at /metrics.
let collector = Arc::new(Collector::default());
collector.describe();
let (prometheus_layer, metric_handle) = PrometheusMetricLayer::pair();
// Relay outing changes from Postgres NOTIFY to this replica's SSE streams.
let live = Live::new();
live.spawn_listener(live::pool(&db));
let mut env = build_environment();
add_config_globals(&mut env, &config);
let state = AppState {
env,
db: db.clone(),
config: config.clone(),
osm: Arc::new(OsmClient::new(&config.geocode_url, &config.poi_url, &config.osm_api_url)?),
live,
};
// Landing page: reads Option<UserContext>, so anonymous and signed-in
// visitors both work — it gets the non-redirecting optional-auth layer.
let public = Router::new().route("/", get(root)).layer(
axum::middleware::from_fn_with_state(db.clone(), optional_auth_middleware),
);
// Everything requiring a login. The web-auth layer 302s anonymous
// visitors to /login.
let protected = Router::new()
.route("/profile", get(handlers::show_profile))
.route("/invites", get(handlers::show_invites).post(handlers::handle_create_invite))
.route("/areas", get(handlers::areas::list_areas).post(handlers::areas::create_area))
.route("/areas/new", get(handlers::areas::new_area))
.route("/areas/{id}", get(handlers::areas::show_area))
.route("/areas/{id}/restaurants/new", get(handlers::restaurants::new_restaurant))
.route(
"/areas/{id}/plan",
get(handlers::lunch::show_planner).post(handlers::lunch::make_plan),
)
.route("/areas/{id}/history", get(handlers::lunch::show_history))
.route("/areas/{id}/rename", post(handlers::areas::rename_area))
.route("/areas/{id}/members", get(handlers::members::show_members))
.route("/areas/{id}/members/invite", post(handlers::members::invite))
.route("/areas/{id}/members/{user_id}/role", post(handlers::members::set_role))
.route("/areas/{id}/members/{user_id}/remove", post(handlers::members::remove_member))
.route(
"/areas/{id}/invitations/{user_id}/cancel",
post(handlers::members::cancel_invitation),
)
.route("/invitations/{area_id}/{answer}", post(handlers::members::answer_invitation))
.route("/friends", get(handlers::friends::show_friends).post(handlers::friends::request))
.route("/friends/{user_id}/{action}", post(handlers::friends::answer))
.route("/devices", get(handlers::devices::list_devices).post(handlers::devices::create_device))
.route("/devices/{id}/revoke", post(handlers::devices::revoke_device))
.route("/areas/{id}/refresh", post(handlers::areas::refresh_area))
.route("/cuisines", get(handlers::cuisines::list_cuisines))
.route("/cuisines/{name}", get(handlers::cuisines::show_cuisine))
.route("/areas/{id}/outings", post(handlers::outings::create_outing))
.route("/outings/{id}", get(handlers::outings::show_outing))
.route("/outings/{id}/panel", get(handlers::outings::panel))
.route("/outings/{id}/events", get(handlers::outings::events))
.route("/outings/{id}/join", post(handlers::outings::join_outing))
.route("/outings/{id}/leave", post(handlers::outings::leave_outing))
.route("/outings/{id}/vote", post(handlers::outings::vote))
.route("/outings/{id}/reroll", post(handlers::outings::reroll))
.route("/outings/{id}/decide", post(handlers::outings::decide))
.route("/outings/{id}/cancel", post(handlers::outings::cancel))
.route("/places/search", get(handlers::places::search))
.route("/places/nearby", get(handlers::places::nearby))
.route("/restaurants", post(handlers::restaurants::create_restaurant))
.route(
"/restaurants/{id}",
get(handlers::restaurants::show_restaurant).post(handlers::restaurants::update_restaurant),
)
.route("/restaurants/{id}/rank", post(handlers::restaurants::set_rank))
.route("/restaurants/{id}/archive", post(handlers::restaurants::toggle_archive))
.route("/restaurants/{id}/refresh", post(handlers::restaurants::refresh_restaurant))
.route("/restaurants/{id}/similar", get(handlers::restaurants::similar))
.route("/lunches", post(handlers::lunch::log_lunch))
.route("/lunches/{id}/delete", post(handlers::lunch::delete_lunch))
.layer(axum::middleware::from_fn_with_state(db.clone(), web_auth_middleware));
// The app's JSON API: bearer tokens, 401s rather than login redirects.
// Sign-in itself is outside, since it's how a token is obtained.
let api = Router::new()
.route("/api/auth/logout", post(handlers::api::logout))
.route("/api/profile", get(handlers::api::profile))
.route("/api/areas", get(handlers::api::list_areas))
.route("/api/areas/{id}", get(handlers::api::show_area))
.route("/api/areas/{id}/people", get(handlers::api::people))
.route("/api/areas/{id}/outings", post(handlers::api::start_outing))
.route("/api/restaurants/{id}", get(handlers::api::show_restaurant))
.route("/api/restaurants/{id}/rank", post(handlers::api::set_rank))
.route("/api/restaurants/{id}/visits", post(handlers::api::log_visit))
.route("/api/outings/{id}", get(handlers::api::show_outing))
.route("/api/outings/{id}/{action}", post(handlers::api::act))
.layer(axum::middleware::from_fn_with_state(db.clone(), api_auth_middleware));
let app = Router::new()
.route("/api/auth/login", post(handlers::api::login))
.merge(api)
.route("/login", get(handlers::show_login))
.route("/register", get(handlers::show_register))
.route("/auth/login", post(handlers::handle_login))
.route("/auth/register", post(handlers::handle_register))
.route("/auth/logout", post(handlers::handle_logout))
.route(
"/metrics",
get({
let collector = collector.clone();
let handle = metric_handle.clone();
move || {
let collector = collector.clone();
let handle = handle.clone();
async move {
collector.collect();
handle.render()
}
}
}),
)
.nest_service("/assets", ServeDir::new("assets/f"))
.merge(public)
.merge(protected)
.with_state(state)
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::default().include_headers(false))
.on_failure(
|error: ServerErrorsFailureClass, _latency: Duration, _span: &tracing::Span| {
tracing::error!("request failed: {error}");
},
),
)
.layer(prometheus_layer);
let listener = tokio::net::TcpListener::bind(&config.bind_address).await?;
info!("server listening on {}", config.bind_address);
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await?;
info!("shut down");
Ok(())
}
/// Resolves on SIGTERM (what Kubernetes and podman send) or Ctrl-C. As PID 1
/// in a container the process gets no default signal handling, so without
/// this it ignores SIGTERM and is killed after the grace period instead.
///
/// axum then stops accepting and lets in-flight requests finish — but live
/// vote streams (SSE) never finish on their own, so give everything a few
/// seconds and then exit. Browsers' EventSource reconnects by itself, to
/// whichever pod is up.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let term = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut s) => {
s.recv().await;
}
Err(_) => std::future::pending::<()>().await,
}
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = term => {},
}
info!("shutting down: draining requests for up to {}s", SHUTDOWN_GRACE.as_secs());
tokio::spawn(async {
tokio::time::sleep(SHUTDOWN_GRACE).await;
info!("grace period over; exiting with streams still open");
std::process::exit(0);
});
}
/// Well inside Kubernetes' default 30 s terminationGracePeriodSeconds.
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
/// Landing page. htmx `hx-boost` swaps the `#content` region on navigation,
/// so this can just render the whole page every time.
async fn root(
State(state): State<AppState>,
user: Option<UserContext>,
) -> Result<Html<String>, StatusCode> {
let user = user.map(|c| c.user);
// Signed in, the landing page is your areas, plus anything waiting on
// you: area invitations and friend requests.
let (areas, invitations, friend_requests) = match &user {
Some(u) => (
handlers::areas::my_areas(state.db.as_ref(), u.id).await?,
handlers::members::pending_for(state.db.as_ref(), u.id).await?,
handlers::friends::incoming_count(state.db.as_ref(), u.id).await?,
),
None => (vec![], vec![], 0),
};
let template = state
.env
.get_template("index.html")
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let body = template
.render(context! {
title => "Home",
user => user,
areas => areas,
invitations => invitations,
friend_requests => friend_requests,
registration_open => state.config.registration_enabled,
})
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Html(body))
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
11.4 KiB · text
History
7a00c02