From a4193f830fe39a6aceec0c361d6d88447022ac63 Mon Sep 17 00:00:00 2001 From: Shav Kinderlehrer Date: Mon, 8 Apr 2024 06:55:05 -0400 Subject: [PATCH] Add basic analytics --- Cargo.lock | 144 ++++++++++++++++++++++++++++- Cargo.toml | 5 +- README.md | 14 ++- src/get.rs | 255 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 22 ++++- 5 files changed, 431 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd371e4..4eb3689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,21 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "async-trait" version = "0.1.79" @@ -168,6 +183,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" + [[package]] name = "byteorder" version = "1.5.0" @@ -194,9 +215,10 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chela" -version = "1.2.0" +version = "1.3.0" dependencies = [ "axum", + "chrono", "color-eyre", "eyre", "hyper", @@ -210,6 +232,21 @@ dependencies = [ "url", ] +[[package]] +name = "chrono" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.4", +] + [[package]] name = "color-eyre" version = "0.6.3" @@ -243,6 +280,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + [[package]] name = "cpufeatures" version = "0.2.12" @@ -724,6 +767,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -777,6 +843,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1450,6 +1525,7 @@ dependencies = [ "atoi", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -1532,6 +1608,7 @@ dependencies = [ "bitflags 2.5.0", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -1573,6 +1650,7 @@ dependencies = [ "base64", "bitflags 2.5.0", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -1608,6 +1686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -1954,6 +2033,60 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.58", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + [[package]] name = "webpki-roots" version = "0.25.4" @@ -1970,6 +2103,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.4", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 9e5ef6e..5b4a5dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "chela" -version = "1.2.0" +version = "1.3.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] axum = { version = "0.7.5", features = ["tokio"] } +chrono = { version = "0.4.37", features = ["serde"] } color-eyre = "0.6.3" eyre = "0.6.12" hyper = "1.2.0" @@ -14,7 +15,7 @@ hyper-util = { version = "0.1.3", features = ["tokio"] } info_utils = "2.2.3" serde = "1.0.197" sqids = "0.4.1" -sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "macros", "migrate", "tls-rustls"] } +sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "macros", "migrate", "tls-rustls", "chrono"] } tokio = { version = "1.37.0", features = ["full"] } tower = "0.4.13" url = { version = "2.5.0", features = ["serde"] } diff --git a/README.md b/README.md index 736dde5..8709332 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Chela is a minimal URL shortener built in Rust. It is named after the small claw ## Usage You can create a redirect by navigating to the `/create` page and filling out the form. By default, every path passed to Chela will be treated as a redirect except `/` and `/create`. +Chela also supports basic analytics for shortened URLs. This page is available at `/tracking`, and `/tracking/`. + ## Install and Run ### With Docker #### CLI @@ -86,9 +88,9 @@ $ ./target/release/chela ``` ## Hosting -Chela uses the [axum](https://crates.io/crates/axum) to manage HTTP requests, so it is possible to expose it directly to the outer internet. However, there is no authentication for the `/create` endpoint so anyone will be able to create redirects. +Chela uses the [axum](https://crates.io/crates/axum) to manage HTTP requests, so it is possible to expose it directly to the outer internet. However, there is no authentication for the `/create` or `/tracking` endpoints so anyone will be able to create redirects and view analytics. -If you would prefer to be the only one able to create redirects, then you can proxy Chela through Nginx with http-basic-auth. Refer to [this](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/) documentation for more information. +If you would prefer to be the only one able to access these pages, then you can proxy Chela through Nginx with http-basic-auth. Refer to [this](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/) documentation for more information. ```nginx server { @@ -103,5 +105,13 @@ server { auth_basic_user_file /path/to/your/.htpasswd; } } + + location /tracking { + proxy_pass http://localhost:3000/; + proxy_set_header X-Real-IP $remote_addr; + + auth_basic 'Restricted'; + auth_basic_user_file /path/to/your/.htpasswd; + } } ``` diff --git a/src/get.rs b/src/get.rs index a00ac48..1e364be 100644 --- a/src/get.rs +++ b/src/get.rs @@ -1,3 +1,4 @@ +use std::collections::hash_map::HashMap; use std::net::SocketAddr; use axum::extract::{ConnectInfo, Path}; @@ -9,9 +10,16 @@ use axum::Extension; use info_utils::prelude::*; use crate::ServerState; +use crate::TrackingRow; use crate::UdsConnectInfo; use crate::UrlRow; +enum TrackingParameter { + Ip, + Referrer, + UserAgent, +} + pub async fn index(Extension(state): Extension) -> impl IntoResponse { if let Some(redirect) = state.main_page_redirect { return Redirect::temporary(redirect.as_str()).into_response(); @@ -212,3 +220,250 @@ pub async fn create_id(Extension(state): Extension) -> Html state.host )) } + +pub async fn tracking(Extension(state): Extension) -> impl IntoResponse { + let url_rows: Vec = sqlx::query_as("SELECT * FROM chela.urls") + .fetch_all(&state.db_pool) + .await + .unwrap(); + let html = format!( + r#" + + + + {} Tracking + + + + {} + + + "#, + state.host, + table_css(), + make_table_from_urls(&url_rows) + ); + + return Html(html).into_response(); +} + +pub async fn tracking_id( + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + let tracking_rows: Vec = + sqlx::query_as("SELECT * FROM chela.tracking WHERE id = $1") + .bind(id.clone()) + .fetch_all(&state.db_pool) + .await + .unwrap(); + let url: UrlRow = sqlx::query_as("SELECT * FROM chela.urls WHERE id = $1") + .bind(id.clone()) + .fetch_one(&state.db_pool) + .await + .unwrap(); + + let html = format!( + r#" + + + + {} Tracking {} + + + +

Tracking for {} from ID '{}'

+

Visited {} times

+ {} + +

By IP

+ {} +

By Referrer

+ {} +

By User Agent

+ {} + + + "#, + state.host, + id, + table_css(), + url.url, + url.url, + url.id, + tracking_rows.len(), + make_table_from_tracking(&tracking_rows), + make_grouped_table_from_tracking(&tracking_rows, TrackingParameter::Ip), + make_grouped_table_from_tracking(&tracking_rows, TrackingParameter::Referrer), + make_grouped_table_from_tracking(&tracking_rows, TrackingParameter::UserAgent) + ); + + return Html(html).into_response(); +} + +fn make_table_from_tracking(rows: &Vec) -> String { + let mut html = r#" + + + + + + + + + + + + + + + "# + .to_string(); + + for row in rows { + html += &format!( + r#" + + + + + + + + "#, + row.timestamp, + row.id, + row.ip.as_ref().unwrap_or(&String::default()), + row.referrer.as_ref().unwrap_or(&String::default()), + row.user_agent.as_ref().unwrap_or(&String::default()) + ); + } + + html += r#" +
TimestampIDIPReferrerUser Agent
{}{}{}{}{}
+ "#; + + html +} + +fn make_grouped_table_from_tracking(rows: &Vec, group: TrackingParameter) -> String { + let column_name = match group { + TrackingParameter::Ip => "IP", + TrackingParameter::Referrer => "Referrer", + TrackingParameter::UserAgent => "User Agent", + } + .to_string(); + + let mut html = format!( + r#" + + + + + + + + + + "#, + column_name + ); + + let mut aggregate: HashMap = HashMap::new(); + + for row in rows { + let tracker = match group { + TrackingParameter::Ip => { + let v = match &row.ip { + Some(val) => val, + None => continue, + }; + v + } + TrackingParameter::Referrer => { + let v = match &row.referrer { + Some(val) => val, + None => continue, + }; + v + } + TrackingParameter::UserAgent => { + let v = match &row.user_agent { + Some(val) => val, + None => continue, + }; + v + } + }; + let count = aggregate.get(tracker).unwrap_or(&0); + aggregate.insert(tracker.to_string(), count + 1); + } + + for (key, val) in aggregate { + html += &format!( + r#" + + + + + "#, + val, key + ); + } + + html += r#" +
Occurrences{}
{}{}
+ "#; + + html +} + +fn make_table_from_urls(urls: &Vec) -> String { + let mut html = r#" + + + + + + + + + + + + + "# + .to_string(); + + for url in urls { + html += &format!( + r#" + + + + + + + "#, + url.index, url.id, url.id, url.url, url.url, url.custom_id + ); + } + html += r#" +
IndexIDURLCustom ID
{}{}{}{}
+ "#; + + html +} + +fn table_css() -> String { + r#" + tr:nth-child(even) { + background: #f2f2f2; + } + + table, th, td { + border: 1px solid black; + } + "# + .to_string() +} diff --git a/src/main.rs b/src/main.rs index 21d29e0..83731fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,16 @@ pub struct UrlRow { pub index: i64, pub id: String, pub url: String, + pub custom_id: bool, +} + +#[derive(Debug, Clone, sqlx::FromRow, PartialEq, Eq)] +pub struct TrackingRow { + pub timestamp: chrono::DateTime, + pub id: String, + pub ip: Option, + pub referrer: Option, + pub user_agent: Option, } #[derive(Deserialize, Debug, Clone)] @@ -73,7 +83,7 @@ async fn main() -> eyre::Result<()> { .unwrap_or("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".to_string()); let sqids = Sqids::builder() .alphabet(alphabet.chars().collect()) - .blocklist(["create".to_string()].into()) + .blocklist(["create".to_string(), "tracking".to_string()].into()) .build()?; let main_page_redirect = env::var("CHELA_MAIN_PAGE_REDIRECT").unwrap_or_default(); let behind_proxy = env::var("CHELA_BEHIND_PROXY").is_ok(); @@ -94,8 +104,10 @@ async fn serve(state: ServerState) -> eyre::Result<()> { if unix_socket.is_empty() { let router = Router::new() .route("/", get(get::index)) - .route("/:id", get(get::id)) .route("/create", get(get::create_id)) + .route("/tracking", get(get::tracking)) + .route("/tracking/:id", get(get::tracking_id)) + .route("/:id", get(get::id)) .route("/", post(post::create_link)) .layer(axum::Extension(state)); let address = env::var("CHELA_LISTEN_ADDRESS").unwrap_or("0.0.0.0".to_string()); @@ -110,8 +122,10 @@ async fn serve(state: ServerState) -> eyre::Result<()> { } else { let router = Router::new() .route("/", get(get::index)) - .route("/:id", get(get::id_unix)) .route("/create", get(get::create_id)) + .route("/tracking", get(get::tracking)) + .route("/tracking/:id", get(get::tracking_id)) + .route("/:id", get(get::id_unix)) .route("/", post(post::create_link)) .layer(axum::Extension(state)); let unix_socket_path = std::path::Path::new(&unix_socket); @@ -184,7 +198,7 @@ CREATE TABLE IF NOT EXISTS chela.urls ( sqlx::query( " CREATE TABLE IF NOT EXISTS chela.tracking ( - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + timestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, id TEXT NOT NULL, ip TEXT, referrer TEXT,