view src/main.rs @ 72:b9d9b4508898

Minor timezone fix for correctness
author Lewin Bormann <lbo@spheniscida.de>
date Wed, 17 Aug 2022 17:51:46 +0200
parents eade8c0055bf
children 50e4c86ab20c
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(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(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| d.midnight().assume_utc()),
                )
                .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,
        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
}