Mercurial > lbo > hg > analyrics
view src/main.rs @ 53:f81d2fdcbe63
Split index template
author | Lewin Bormann <lbo@spheniscida.de> |
---|---|
date | Fri, 22 Jul 2022 18:11:28 -0700 |
parents | ce8e102ee2e0 |
children | 649097da34ae |
line wrap: on
line source
mod cacheresponder; mod configdb; mod db; mod fromparam; mod geoip; mod guards; mod logsdb; use crate::configdb::{ConfigDB, ConfigDBSession}; use crate::geoip::GeoIP; use crate::guards::{HeadersGuard, LoggedInGuard, USER_ID_COOKIE_KEY}; use crate::logsdb::{LogsDB, LogsDBSession}; use anyhow::{self, Context, Error}; use either::Either; use log::{debug, error, info, warn, Level}; use time::{Date, Duration, OffsetDateTime}; use rocket::form::Form; use rocket::fs::{relative, FileServer, NamedFile}; use rocket::futures::StreamExt; use rocket::http::{Cookie, CookieJar, HeaderMap, RawStr, Status}; use rocket::request::{self, FlashMessage, FromRequest, Outcome, Request}; use rocket::response::{self, Flash, Redirect, Responder}; use rocket::serde::json::{json, Value}; use rocket::serde::{self, Serialize}; use rocket::State; use rocket_db_pools::sqlx::{Executor, Row, Sqlite, SqlitePool}; use rocket_db_pools::{Connection, Database, Pool}; use sqlx::prelude::FromRow; use rocket_dyn_templates::{context, Template}; use std::collections::HashMap; use std::net::IpAddr; use std::path::Path; use std::str::FromStr; #[derive(Responder)] enum LoginResponse { // use templates later. #[response(status = 200, content_type = "html")] Ok { body: Template }, #[response(status = 302, content_type = "html")] LoggedInAlready { redirect: Redirect }, } #[derive(Responder)] #[response(content_type = "text", status = 500)] struct InternalServerError { body: String, } #[rocket::get("/login")] async fn route_login_form<'r>( cc: &rocket::State<CustomConfig>, flash: Option<FlashMessage<'_>>, cookies: &CookieJar<'_>, ) -> Result<LoginResponse, InternalServerError> { let f; if let Some(ref flash) = flash { f = Some(format!("{}: {}", flash.kind(), flash.message())); } else { f = None; } if let Some(cookie) = cookies.get_private(USER_ID_COOKIE_KEY) { Ok(LoginResponse::LoggedInAlready { redirect: Redirect::to(cc.deploy_path.clone()), }) } else { Ok(LoginResponse::Ok { body: Template::render("login", context![flash: f]), }) } } #[rocket::post("/logout")] fn route_logout(cc: &rocket::State<CustomConfig>, cookies: &CookieJar<'_>) -> Flash<Redirect> { if let Some(cookie) = cookies.get_private(USER_ID_COOKIE_KEY) { cookies.remove_private(cookie); } Flash::success( Redirect::to(cc.deploy_path.clone()), format!("Logged out. Goodbye!"), ) } #[derive(rocket::FromForm)] struct LoginForm { username: String, password: String, } #[rocket::post("/login", data = "<login>")] async fn route_login_post( mut db: Connection<ConfigDB>, cc: &rocket::State<CustomConfig>, cookies: &CookieJar<'_>, login: Form<LoginForm>, ) -> Flash<Redirect> { // TO DO: look up user in database. let mut conn = ConfigDBSession(&mut db); match conn .check_user_password(&login.username, &login.password) .await { Ok(true) => { let c = Cookie::new(USER_ID_COOKIE_KEY, login.username.clone()); cookies.add_private(c); Flash::success( Redirect::to(cc.deploy_path.clone()), "Successfully logged in.", ) } Ok(false) => Flash::error( Redirect::to(format!("{}/login", cc.deploy_path)), "User/password not found", ), Err(e) => Flash::error( Redirect::to(format!("{}/login", cc.deploy_path)), format!("User/password lookup failed: {}", e), ), } } #[derive(Default)] struct ChartOptions { typ: String, // bar/line/etc. index_axis: Option<String>, // x/y stack: Option<String>, } fn create_chart<S1: Serialize, S2: Serialize>( labels: Vec<S1>, values: Vec<(String, Vec<S2>)>, opt: &ChartOptions, ) -> Value { let colors = vec![ "red", "blue", "orange", "green", "gray", "purple", "black", "brown", ]; let bordercolor = if opt.typ == "line" { colors.clone() } else { vec!["white"] }; let bgcolor = if opt.typ != "line" { colors.clone() } else { vec![] }; if values.len() > colors.len() { error!("Not enough colors for line chart!"); } let datasets: Vec<Value> = values .iter() .map(|(name, val)| { json!({ "label": name, "data": val, // TODO: these colors don't work well: differently for different chart types "borderColor": bordercolor, "backgroundColor": bgcolor, }) }) .collect(); let inner = json!({ "type": &opt.typ, "data": { "labels": labels, "datasets": datasets, }, "options": { "maintainAspectRatio": false, "scales": { "y": { "beginAtZero": true }}, "indexAxis": opt.index_axis, "stack": opt.stack.as_ref().map(String::as_str), "plugins": { "legend": { "display": opt.typ != "bar" || opt.index_axis.as_ref().map(String::as_str) != Some("y") }, }, }, }); inner } /// Main analytics page for logged-in users. #[rocket::get("/?<domain>&<from>&<duration>&<includebots>", rank = 1)] async fn route_index_loggedin( mut conn: Connection<LogsDB>, mut config_conn: Connection<ConfigDB>, lig: LoggedInGuard, flash: Option<FlashMessage<'_>>, domain: Option<String>, from: Option<&str>, duration: Option<&str>, includebots: Option<bool>, ) -> Template { let f; if let Some(ref flash) = flash { f = Some(format!("{}: {}", flash.kind(), flash.message())); } else { f = None; } // Parameter treatment let duration = duration .map(|d| i64::from_str_radix(d, 10).unwrap_or(30)) .map(|d| Duration::new(84600 * d, 0)) .unwrap_or(Duration::days(30)); let from = from .map(|p| { OffsetDateTime::parse(p, &time::format_description::well_known::Iso8601::PARSING) .or( Date::parse(p, &time::format_description::well_known::Iso8601::PARSING) .map(|d| d.midnight().assume_utc()), ) .unwrap_or(OffsetDateTime::now_utc() - duration) }) .unwrap_or(OffsetDateTime::now_utc() - duration); let begin = from; let end = begin + duration; // Chart rendering // in seconds for this user. let tz_offset = match ConfigDBSession(&mut config_conn) .get_user_details(&lig.0) .await { Ok(ur) => ur.tz_offset, Err(e) => { error!("Couldn't obtain user details: {}", e); 0 } }; let includebots = includebots.unwrap_or(false); let ctx = logsdb::LogsQueryContext { domain: domain.clone(), from: begin, to: end, tz_offset, include_bots: includebots, }; let vissess = match LogsDBSession(&mut conn) .query_visits_sessions_counts(&ctx) .await { Ok((dates, visits, sessions)) => create_chart( dates, vec![("Visits".into(), visits), ("Sessions".into(), sessions)], &ChartOptions { typ: "line".into(), ..ChartOptions::default() }, ) .to_string(), Err(e) => { error!("Couldn't build chart: {}", e); "undefined".to_string() } }; let toppaths = match LogsDBSession(&mut conn).query_top_paths(&ctx, 10).await { Ok(tp) => create_chart( tp.iter().map(|(p, c)| p).collect(), vec![("Top Pages".into(), tp.iter().map(|(p, c)| c).collect())], &ChartOptions { typ: "bar".into(), index_axis: Some("y".into()), ..ChartOptions::default() }, ) .to_string(), Err(e) => { error!("Couldn't build chart: {}", e); "undefined".to_string() } }; let reqbyses = match LogsDBSession(&mut conn) .query_requests_per_session(&ctx) .await { Ok(rs) => create_chart( rs.iter().map(|(p, c)| p).collect(), vec![( "Requests per Session".into(), rs.iter().map(|(p, c)| c).collect(), )], &ChartOptions { typ: "line".into(), ..ChartOptions::default() }, ) .to_string(), Err(e) => { error!("Couldn't build chart: {}", e); "undefined".to_string() } }; let sesbycountry = match LogsDBSession(&mut conn).query_top_countries(&ctx).await { Ok(rs) => create_chart( rs.iter().map(|(c, n)| c).collect(), vec![( "Sessions by Country".into(), rs.iter().map(|(c, n)| n).collect(), )], &ChartOptions { typ: "pie".into(), ..ChartOptions::default() }, ) .to_string(), Err(e) => { error!("Couldn't build chart: {}", e); "undefined".to_string() } }; let toprefer = match LogsDBSession(&mut conn).query_top_refer_domains(&ctx).await { Ok(rs) => create_chart( rs.iter().map(|(dom, ct)| dom).collect(), vec![( "Top External Referers".into(), rs.iter().map(|(dom, ct)| ct).collect(), )], &ChartOptions { typ: "bar".into(), index_axis: Some("y".into()), ..ChartOptions::default() }, ) .to_string(), Err(e) => { error!("Couldn't build chart: {}", e); "undefined".to_string() } }; let ymd_format = time::format_description::parse("[year]-[month]-[day]").unwrap(); let tmpl_today = begin.date().format(&ymd_format).unwrap(); let tmpl_duration = duration.whole_days().to_string(); Template::render( "index_dashboard", context![ loggedin: true, domain: domain, username: lig.0, flash: f, date: HashMap::<&str,&str>::from_iter([ ("today", tmpl_today.as_str()), ("duration", tmpl_duration.as_str())].into_iter()), beginnav: vec![HashMap::<&str, &str>::from_iter([("link", "/abc"), ("title", "3d")].into_iter())], chartconfig: context![ visitsAndSessions: vissess, topPaths: toppaths, requestsBySession: reqbyses, sessionsByCountry: sesbycountry, topRefer: toprefer, ] ], ) } #[rocket::get("/", rank = 2)] async fn route_index_loggedout(flash: Option<FlashMessage<'_>>) -> Template { let f; if let Some(ref flash) = flash { f = Some(format!("{}: {}", flash.kind(), flash.message())); } else { f = None; } Template::render("index", context![loggedin: false, flash: f]) } // TODO: ignore requests when logged in. #[rocket::get("/log?<host>&<status>&<path>&<pagename>&<referer>&<tags>")] async fn route_log( mut conn: Connection<LogsDB>, cookies: &CookieJar<'_>, geoipdb: &State<Option<GeoIP>>, host: Option<String>, status: Option<u32>, path: Option<String>, pagename: Option<String>, referer: Option<String>, tags: Vec<String>, config: &rocket::State<CustomConfig>, ip: IpAddr, headers: HeadersGuard<'_>, ) -> (Status, Either<NamedFile, &'static str>) { let mut conn = LogsDBSession(&mut conn); let mut session_id = None; // Only log if this is not an analytics user. if cookies.get_private(USER_ID_COOKIE_KEY).is_none() { // Get session ID from cookie, or start new session. if let Some(sessioncookie) = cookies.get_private("analyrics_session") { if let Ok(id) = u32::from_str_radix(sessioncookie.value(), 10) { session_id = Some(id); match conn.update_session_time(id).await { Ok(()) => {} Err(e) => error!("Couldn't update session time: {}", e), } } } let sip = ip.to_string(); let sip = headers.0.get_one("x-real-ip").unwrap_or(&sip); let ua = headers.0.get_one("user-agent").unwrap_or(""); let numip = IpAddr::from_str(sip).unwrap_or(ip); if session_id.is_none() { let orig = geoipdb.as_ref().and_then(|g| g.lookup(numip)); let is_bot = ua.to_lowercase().contains("bot"); match conn .start_session( host.as_ref().map(String::as_str).unwrap_or(""), orig, is_bot, ) .await { Ok(id) => { session_id = Some(id); let c = Cookie::build("analyrics_session", id.to_string()) .max_age(time::Duration::hours(12)) .finish(); cookies.add_private(c); } Err(e) => error!("Couldn't start session: {}", e), } } let ntags = tags.len() as u32; let host: String = host.unwrap_or( headers .0 .get("host") .take(1) .map(str::to_string) .collect::<Vec<String>>() .pop() .unwrap_or(String::new()), ); match conn .log_request( session_id, sip, host, path.unwrap_or(String::new()), status.unwrap_or(200), pagename, referer, ua, ntags, ) .await { Err(e) => error!("Couldn't log request: {}", e), Ok(id) => { conn.log_tags(id, tags.iter()).await.ok(); } } } if let Ok(f) = NamedFile::open(Path::new(config.asset_path.as_str()).join("pixel.png")).await { (Status::Ok, Either::Left(f)) } else { (Status::Ok, Either::Right("")) } } #[derive(rocket::serde::Deserialize)] #[serde(crate = "rocket::serde")] struct CustomConfig { asset_path: String, deploy_path: String, geodb_path: String, } #[rocket::launch] fn rocketmain() -> _ { env_logger::init(); let r = rocket::build(); let cfg = r .figment() .extract::<CustomConfig>() .expect("custom config"); let geoip = match crate::geoip::GeoIP::new(cfg.geodb_path) { Ok(g) => Some(g), Err(e) => { error!("Couldn't open geoip database: {}", e); None } }; let ap = Path::new(cfg.asset_path.as_str()); let ap = ap .canonicalize() .expect("absolute path to asset directory") .to_path_buf(); let fileserver = cacheresponder::CachedHandler::wrap( FileServer::from(ap), cacheresponder::CacheControl::new("max-age=43200"), ); let r = r .attach(ConfigDB::init()) .attach(LogsDB::init()) .attach(Template::fairing()) .attach(rocket::fairing::AdHoc::config::<CustomConfig>()) .manage(geoip) .mount("/static", fileserver) .mount( "/", rocket::routes![ route_index_loggedin, route_index_loggedout, route_logout, route_login_form, route_login_post, route_log, ], ); r }