aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShav Kinderlehrer <[email protected]>2024-04-08 06:55:05 -0400
committerShav Kinderlehrer <[email protected]>2024-04-08 06:55:05 -0400
commita4193f830fe39a6aceec0c361d6d88447022ac63 (patch)
tree739be230130648b94f5577cf646a1c6a1245c585
parent938be8949fbb9735953309611c73589be04a2ccb (diff)
downloadchela-a4193f830fe39a6aceec0c361d6d88447022ac63.tar.gz
chela-a4193f830fe39a6aceec0c361d6d88447022ac63.zip
Add basic analyticsv1.3.0
-rw-r--r--Cargo.lock144
-rw-r--r--Cargo.toml5
-rw-r--r--README.md14
-rw-r--r--src/get.rs255
-rw-r--r--src/main.rs22
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
@@ -37,6 +37,21 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -169,6 +184,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -194,9 +215,10 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chela"
-version = "1.2.0"
+version = "1.3.0"
dependencies = [
"axum",
+ "chrono",
"color-eyre",
"eyre",
"hyper",
@@ -211,6 +233,21 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -244,6 +281,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -725,6 +768,29 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -778,6 +844,15 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
@@ -1955,6 +2034,60 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1971,6 +2104,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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/<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;
+ }
}
```
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<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()
+}
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<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,