Add basic analytics

This commit is contained in:
Shav Kinderlehrer 2024-04-08 06:55:05 -04:00
parent 938be8949f
commit a4193f830f
5 changed files with 431 additions and 9 deletions

144
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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/<URL ID>`.
## 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;
}
}
```

View File

@ -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<ServerState>) -> 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<ServerState>) -> Html<String>
state.host
))
}
pub async fn tracking(Extension(state): Extension<ServerState>) -> impl IntoResponse {
let url_rows: Vec<UrlRow> = sqlx::query_as("SELECT * FROM chela.urls")
.fetch_all(&state.db_pool)
.await
.unwrap();
let html = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<title>{} Tracking</title>
</head>
<style>{}</style>
<body>
{}
</body>
</html>
"#,
state.host,
table_css(),
make_table_from_urls(&url_rows)
);
return Html(html).into_response();
}
pub async fn tracking_id(
Extension(state): Extension<ServerState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let tracking_rows: Vec<TrackingRow> =
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#"
<!DOCTYPE html>
<html>
<head>
<title>{} Tracking {}</title>
</head>
<style>{}</style>
<body>
<h1>Tracking for <a href="{}">{}</a> from ID '{}'</h1>
<h2>Visited {} times</h2>
{}
<h2>By IP</h2>
{}
<h2>By Referrer</h2>
{}
<h2>By User Agent</h2>
{}
</body>
</html>
"#,
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<TrackingRow>) -> String {
let mut html = r#"<table>
<colgroup>
<col>
<col>
<col>
<col>
<col>
</colgroup>
<tr>
<th>Timestamp</th>
<th>ID</th>
<th>IP</th>
<th>Referrer</th>
<th>User Agent</th>
</tr>
"#
.to_string();
for row in rows {
html += &format!(
r#"
<tr>
<td>{}</td>
<td>{}</td>
<td>{}</td>
<td>{}</td>
<td>{}</td>
</tr>
"#,
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#"
</table>
"#;
html
}
fn make_grouped_table_from_tracking(rows: &Vec<TrackingRow>, 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#"
<table>
<colgroup>
<col>
<col>
</colgroup>
<tr>
<th>Occurrences</th>
<th>{}</th>
</tr>
"#,
column_name
);
let mut aggregate: HashMap<String, u32> = 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#"
<tr>
<td>{}</td>
<td>{}</td>
</tr>
"#,
val, key
);
}
html += r#"
</table>
"#;
html
}
fn make_table_from_urls(urls: &Vec<UrlRow>) -> String {
let mut html = r#"<table>
<colgroup>
<col>
<col>
<col>
<col>
</colgroup>
<tr>
<th>Index</th>
<th>ID</th>
<th>URL</th>
<th>Custom ID</th>
</tr>
"#
.to_string();
for url in urls {
html += &format!(
r#"
<tr>
<td>{}</td>
<td><a href="/tracking/{}">{}</a></td>
<td><a href="{}">{}</a></td>
<td>{}</td>
</tr>
"#,
url.index, url.id, url.id, url.url, url.url, url.custom_id
);
}
html += r#"
</table>
"#;
html
}
fn table_css() -> String {
r#"
tr:nth-child(even) {
background: #f2f2f2;
}
table, th, td {
border: 1px solid black;
}
"#
.to_string()
}

View File

@ -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<chrono::Utc>,
pub id: String,
pub ip: Option<String>,
pub referrer: Option<String>,
pub user_agent: Option<String>,
}
#[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,