Mercurial > lbo > hg > analyrics
view src/main.rs @ 76:a58e1922e173
Fix some redirects and date calculations
author | Lewin Bormann <lbo@spheniscida.de> |
---|---|
date | Fri, 09 Sep 2022 07:08:20 +0200 |
parents | 9c62bf90689b |
children | fd0237049be0 |
line wrap: on
line source
mod cacheresponder; mod configdb; mod db; mod fromparam; mod geoip; mod guards; mod logsdb; mod template_types; 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(format!("{}/", cc.deploy_path.clone())), }) } else { Ok(LoginResponse::Ok { body: Template::render("login", context![error: 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(format!("{}/", 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>, cc: &rocket::State<CustomConfig>, 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; } // Query user info. 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); // Parameter treatment let duration = Duration::days(i64::from_str_radix(duration.unwrap_or("30"), 10).unwrap_or(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| OffsetDateTime::now_utc().replace_date(d)), ) .unwrap_or(OffsetDateTime::now_utc() - duration) }) .unwrap_or(OffsetDateTime::now_utc() - duration); let begin = from.to_offset(time::UtcOffset::from_whole_seconds(tz_offset as i32).expect("UtcOffset")); let end = begin + duration; let ymd_format = time::format_description::parse("[year]-[month]-[day]").unwrap(); let ymdhms_format = time::format_description::parse( "[year]-[month]-[day] [hour]:[minute]:[second] UTC[offset_hour sign:mandatory]", ) .unwrap(); // Chart rendering 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 recent_sessions = match LogsDBSession(&mut conn) .query_recent_sessions(&ctx, 15) .await { Ok(rs) => Some( rs.into_iter() .map(|r| { template_types::RecentSessionsTableRow::from_row( r, time::UtcOffset::from_whole_seconds(tz_offset as i32) .unwrap_or(time::UtcOffset::UTC), &ymdhms_format, ) }) .collect::<Vec<template_types::RecentSessionsTableRow>>(), ), Err(e) => { error!("Couldn't query recent sessions: {}", e); None } }; let tmpl_today = begin.date().format(&ymd_format).unwrap(); let tmpl_duration = (duration + Duration::seconds(1)).whole_days().to_string(); Template::render( "index_dashboard", context![ loggedin: true, basepath: &cc.deploy_path, 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, ], recentSessions: recent_sessions, ], ) } #[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::FromForm)] struct SettingsForm { oldpassword: Option<String>, newpassword: Option<String>, tz_offset: Option<i64>, } /// Settings: Submit form #[rocket::post("/settings", data = "<settings>")] async fn route_settings_post( mut cdb: Connection<ConfigDB>, cc: &rocket::State<CustomConfig>, lig: LoggedInGuard, settings: rocket::form::Form<SettingsForm>, ) -> Flash<Redirect> { let mut db = ConfigDBSession(&mut cdb); let SettingsForm { oldpassword, newpassword, tz_offset, } = settings.into_inner(); let deploy_path = cc.deploy_path.clone(); let settingspath = Path::new(&deploy_path) .join("settings") .to_str() .unwrap_or("/settings") .to_string(); // Update settings before rendering. if let (Some(newpassword), Some(oldpassword)) = (&newpassword, &oldpassword) { if let Err(e) = db .update_user_password(&lig.0, &oldpassword, &newpassword) .await { return Flash::error( Redirect::to(settingspath), format!("Couldn't update user password: {}", e), ); } } else if newpassword.is_some() ^ oldpassword.is_some() { return Flash::error( Redirect::to(settingspath), "Internal error: must supply old and new password".to_string(), ); } if let Some(tz_offset) = tz_offset { if let Err(e) = db.update_user_tz(&lig.0, tz_offset * 3600).await { return Flash::error( Redirect::to(settingspath), format!("Couldn't update timezone: {}", e), ); } } Flash::success(Redirect::to(settingspath), format!("Saved successfully!")) } #[rocket::get("/settings?<configure_user>", rank = 1)] async fn route_settings( mut cdb: Connection<ConfigDB>, flash: Option<FlashMessage<'_>>, cc: &rocket::State<CustomConfig>, lig: LoggedInGuard, configure_user: Option<String>, ) -> Template { let mut db = ConfigDBSession(&mut cdb); let user = match db.get_user_details(&lig.0).await { Ok(r) => r, Err(e) => { return Template::render( "settings", context![ basepath: &cc.deploy_path, error: format!("Error getting user details: {}", e) ], ) } }; let (success, error) = if let Some(flash) = &flash { if flash.kind() == "error" { (None, Some(flash.message())) } else if flash.kind() == "success" { (Some(flash.message()), None) } else { (None, None) } } else { (None, None) }; let is_admin = match db.is_admin(&lig.0).await { Ok(b) => b, Err(e) => { error!("Couldn't query admin status: {}", e); false } }; Template::render( "settings", context![ basepath: &cc.deploy_path, thispath: format!("{}{}", &cc.deploy_path, "settings"), user: &user.name, error: error, flash: success, admin: context![ active: is_admin, configure_user: configure_user ], tz_offset: format!("{:}", user.tz_offset / 3600), ], ) } #[rocket::get("/settings", rank = 2)] async fn route_settings_loggedout(cc: &rocket::State<CustomConfig>) -> Redirect { Redirect::to(cc.deploy_path.clone()) } #[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_settings, route_settings_post, route_settings_loggedout, route_logout, route_login_form, route_login_post, route_log, ], ); r }