Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,204 changes: 635 additions & 569 deletions Cargo.lock

Large diffs are not rendered by default.

39 changes: 29 additions & 10 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ path="src/main.rs"
[dependencies]
# async/concurrency
arc-swap="1.8"
tokio={version="1.48", default-features=false, features=["macros", "rt-multi-thread", "signal"]}
tokio={version="1.49", default-features=false, features=["macros", "rt-multi-thread", "signal"]}
tokio-util={version="0.7", features=["io"]}
futures-lite={version="2.6", default-features=false, features=["alloc"]}
quick_cache={version="0.6", features=["ahash"]}
Expand All @@ -32,28 +32,45 @@ async-compression={version="0.4", default-features=false, features=["gzip", "tok
tokio-tar={package="astral-tokio-tar", version="0.5"}
sha3={version="0.10"}
argon2={version="0.5", features=["rand"]}
password-hash={version="0.5", features=["rand_core", "getrandom"]}
zstd={version="0.13", default-features=false}
uuid={version="1.19", features=["v4"]}

# general
argh={version="0.1", default-features=false, features=["help"]}
anyhow={version="1.0"}
rand={version="0.9", default-features=false, features=["std", "thread_rng"]}
chrono={version="0.4", default-features=false, features=["std", "now"]}
chrono={version="0.4", default-features=false, features=["std", "now", "serde"]}
figment={version="0.10", features=["toml", "env"]}
tracing={version="0.1", default-features=false, features=["std"]}
tracing-subscriber={version="0.3", features=["env-filter"]}
ahash="0.8"

# web
poem={version="3.1", default-features=false, features=[
"embed",
"cookie",
"compression",
"tower-compat",
axum={version="0.8", features=["macros"]}
axum-extra={version="0.12", default-features=false, features=["cookie", "typed-header"]}
http="1.4"
headers="0.4"
tower={version="0.5", default-features=false}
tower-http={version="0.6", default-features=false, features=[
"cors",
"compression-zstd",
"set-header",
]}
poem-openapi={version="5.1", default-features=false, features=["chrono"]}
tower={version="0.5", default-features=false, features=["limit"]}
tower_governor={version="0.8", default-features=false, features=["axum"]}
aide={version="0.16.0-alpha.1", default-features=false, features=[
"axum",
"axum-json",
"axum-query",
"axum-matched-path",
"axum-tokio",
"axum-extra",
"axum-extra-cookie",
"axum-extra-headers",
"macros",
]}
schemars={version="1.2", features=["derive", "chrono04"]}

ua-parser="0.2"
rust-embed={version="8.9", features=["mime-guess"]}
reqwest={version="0.13", default-features=false, features=["json", "stream", "charset", "rustls"]}
Expand All @@ -71,8 +88,10 @@ tikv-jemallocator="0.6"

[dev-dependencies]
figment={version="*", features=["test"]}
poem={version="*", features=["test"]}
cookie={version="*", default-features=false}
tower={version="*", features=["util"]}
hyper={version="*", features=["full"]}
axum-test={version="18"}

[features]
default=["geoip"]
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/explodingcamera/liwan/test.yaml?style=flat-square)
![GitHub Release](https://img.shields.io/github/v/release/explodingcamera/liwan?style=flat-square)
[![Container](https://img.shields.io/badge/Container-ghcr.io%2Fexplodingcamera%2Fliwan%3Aedge-blue?style=flat-square)](https://github.com/explodingcamera/liwan/pkgs/container/liwan)
[![brainmade.org](https://img.shields.io/badge/brainmade.org-FFFFFF?style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iNzkiIHZpZXdCb3g9IjAgMCA2NyA3OSIgZmlsbD0ibm9uZSI%2BPHBhdGggZmlsbD0iIzAwMCIgZD0iTTUyLjYxMiA3OC43ODJIMjMuMzNhMi41NTkgMi41NTkgMCAwIDEtMi41Ni0yLjU1OHYtNy42NzdoLTcuOTczYTIuNTYgMi41NiAwIDAgMS0yLjU2LTIuNTZWNTUuMzE1bC04LjgyLTQuMzk3YTIuNTU5IDIuNTU5IDAgMCAxLS45ODYtMy43MWw5LjgwNy0xNC43MTR2LTQuMzVDMTAuMjQgMTIuNTk5IDIyLjg0MyAwIDM4LjM4OCAwIDUzLjkzMiAwIDY2LjUzNCAxMi42IDY2LjUzOCAyOC4xNDNjLS42MzIgMjcuODI0LTEwLjc2IDIzLjUxNi0xMS4xOCAzNC4wNDVsLS4xODcgMTQuMDM1YTIuNTkgMi41OSAwIDAgMS0uNzUgMS44MSAyLjU1IDIuNTUgMCAwIDEtMS44MDkuNzVabS0yNi43MjMtNS4xMTdoMjQuMTY0bC4yODYtMTQuNTQyYy0uMjYzLTYuNjU2IDExLjcxNi04LjI0MyAxMS4wOC0zMC43MzQtLjM1OC0xMi43MTMtMTAuMzEzLTIzLjI3MS0yMy4wMzEtMjMuMjcxLTEyLjcxOCAwLTIzLjAyOSAxMC4zMDctMjMuMDMyIDIzLjAyNXY1LjExN2MwIC41MDYtLjE1IDEtLjQzIDEuNDJsLTguNjMgMTIuOTQxIDcuNjQ1IDMuODJhMi41NTkgMi41NTkgMCAwIDEgMS40MTUgMi4yOTF2OS42OTdoNy45NzRhMi41NTkgMi41NTkgMCAwIDEgMi41NiAyLjU1OXY3LjY3N1oiLz48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNDAuMzcyIDU4LjIyMlYzOC45MzRjLjExOCAwIC4yMzcuMDE4LjM1NS4wMTggOS43NjktLjAxMiAxNy4wNS05LjAxMiAxNS4wMjItMTguNTY3YTIuMzY2IDIuMzY2IDAgMCAwLTEuODIxLTEuODIyYy04LjEwNi0xLjczLTE2LjEyMSAzLjI5Mi0xOC4wOTggMTEuMzQxLS4wMjQtLjAyNC0uMDQzLS4wNS0uMDY2LS4wNzNhMTUuMzIzIDE1LjMyMyAwIDAgMC0xNC4wNi00LjE3IDIuMzY1IDIuMzY1IDAgMCAwLTEuODIxIDEuODJjLTIuMDI4IDkuNTU1IDUuMjUyIDE4LjU1NCAxNS4wMiAxOC41NjguMjM2IDAgLjQ5Mi0uMDI4LjczOC0uMDR2MTIuMjEzaDQuNzMxWm0yLjgzOS0zMi4xNDNhMTAuNjQ2IDEwLjY0NiAwIDAgMSA4LjEyNC0zLjEwNmMuMzUgNi4zNC00Ljg4OCAxMS41NzctMTEuMjI4IDExLjIzYTEwLjU4IDEwLjU4IDAgMCAxIDMuMTA0LTguMTI0Wk0yNy40MDMgMzguMTkzYTEwLjYwNyAxMC42MDcgMCAwIDEtMy4xMTgtOC4xMjNjNi4zNDQtLjM1OCAxMS41ODcgNC44ODYgMTEuMjI4IDExLjIzLTMuMDIzLjE2OS01Ljk3My0uOTYxLTguMTEtMy4xMDdaIi8%2BPC9zdmc%2B)](https://brainmade.org/)

</div>

Expand Down
2 changes: 1 addition & 1 deletion data/licenses-cargo.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion data/licenses-npm.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/app/core/geoip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ fn get_file_meta(path: &PathBuf) -> Option<(u64, u64, u64, i64)> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
return Some((md.dev(), md.ino(), md.size(), md.mtime()));
Some((md.dev(), md.ino(), md.size(), md.mtime()))
}

#[cfg(windows)]
Expand Down
25 changes: 13 additions & 12 deletions src/app/core/reports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ use crate::utils::duckdb::{ParamVec, repeat_vars};
use anyhow::{Result, bail};
use chrono::{DateTime, Utc};
use duckdb::params_from_iter;
use poem_openapi::{Enum, Object};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

pub use super::reports_cached::*;

#[derive(Object, Debug, Clone, Hash, PartialEq, Eq)]
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Hash, PartialEq, Eq)]
pub struct DateRange {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
Expand All @@ -37,17 +38,17 @@ impl Display for DateRange {
}
}

#[derive(Debug, Enum, Clone, Copy, PartialEq, Eq, Hash)]
#[oai(rename_all = "snake_case")]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Metric {
Views,
UniqueVisitors,
BounceRate,
AvgTimeOnSite,
}

#[derive(Debug, Enum, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)]
#[oai(rename_all = "snake_case")]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Dimension {
Url,
Fqdn,
Expand All @@ -65,8 +66,8 @@ pub enum Dimension {
UtmTerm,
}

#[derive(Enum, Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)]
#[oai(rename_all = "snake_case")]
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum FilterType {
// Generic filters
IsNull,
Expand All @@ -85,17 +86,17 @@ pub enum FilterType {
pub type ReportGraph = Vec<f64>;
pub type ReportTable = BTreeMap<String, f64>;

#[derive(Object, Clone, Debug, Default)]
#[oai(rename_all = "camelCase")]
#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct ReportStats {
pub total_views: u64,
pub unique_visitors: u64,
pub bounce_rate: f64,
pub avg_time_on_site: f64,
}

#[derive(Object, Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord)]
#[oai(rename_all = "camelCase")]
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
pub struct DimensionFilter {
/// The dimension to filter by
dimension: Dimension,
Expand Down
6 changes: 3 additions & 3 deletions src/app/models.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::fmt::Display;

use chrono::{DateTime, Utc};
use poem_openapi::Enum;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -46,8 +46,8 @@ pub struct User {
pub projects: Vec<String>,
}

#[derive(Debug, Enum, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default)]
#[oai(rename_all = "snake_case")]
#[derive(Debug, JsonSchema, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default)]
#[serde(rename_all = "snake_case")]
pub enum UserRole {
#[serde(rename = "admin")]
Admin,
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::{Context, Result, bail};
use figment::Figment;
use figment::providers::{Env, Format, Toml};
use poem::http::Uri;
use http::Uri;
use serde::{Deserialize, Serialize};
use std::num::NonZeroU16;
use std::str::FromStr;
Expand Down
5 changes: 1 addition & 4 deletions src/utils/r2d2_sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ impl SqliteConnectionManager {
}

pub fn memory() -> Self {
Self {
source: format!("file:{}?mode=memory&cache=shared", Uuid::new_v4().to_string()).into(),
flags: OpenFlags::default(),
}
Self { source: format!("file:{}?mode=memory&cache=shared", Uuid::new_v4()).into(), flags: OpenFlags::default() }
}

pub fn with_flags(self, flags: OpenFlags) -> Self {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/referrer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub enum Referrer {
}

pub fn process_referer(referer: Option<&str>) -> Referrer {
match referer.map(poem::http::Uri::from_str) {
match referer.map(http::Uri::from_str) {
// valid referer are stripped to the FQDN
Some(Ok(referer_uri)) => {
// ignore localhost / IP addresses
Expand Down
149 changes: 76 additions & 73 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,97 +2,97 @@ pub mod routes;
pub mod session;
pub mod webext;

use std::sync::Arc;

use crate::app::Liwan;
use crate::app::models::Event;
use routes::{dashboard_service, event_service};
use std::sync::mpsc::Sender;
use webext::{EmbeddedFilesEndpoint, PoemErrExt, catch_error};

pub use session::SessionUser;
use std::net::SocketAddr;
use std::ops::Deref;
use std::sync::{Arc, mpsc::Sender};

use anyhow::{Context, Result};
use axum::handler::{Handler, HandlerWithoutStateExt};
use rust_embed::RustEmbed;

use poem::endpoint::EmbeddedFileEndpoint;
use poem::listener::TcpListener;
use poem::middleware::{AddData, Compression, CookieJarManager, Cors, SetHeader};
use poem::web::CompressionAlgo;
use poem::{EndpointExt, IntoEndpoint, Route, Server};
use aide::{axum::ApiRouter, openapi};
use http::{HeaderValue, Method, header};
use tower_http::{
compression::CompressionLayer,
cors::{Any, CorsLayer},
set_header::SetResponseHeaderLayer,
};

use crate::app::{Liwan, models::Event};
use crate::web::webext::serve;

pub use session::{MaybeExtract, SessionId, SessionUser};
use webext::StaticFile;

#[derive(RustEmbed, Clone)]
#[folder = "./web/dist"]
struct Files;
pub struct Files;

#[derive(RustEmbed, Clone)]
#[folder = "./tracker"]
struct Script;

#[cfg(debug_assertions)]
fn save_spec() -> Result<()> {
use std::path::Path;

let path = Path::new("./web/src/api/dashboard.ts");
if path.exists() {
let spec = serde_json::to_string(&serde_json::from_str::<serde_json::Value>(&dashboard_service().spec())?)?
.replace(r#""servers":[],"#, "") // fets doesn't work with an empty servers array
.replace("; charset=utf-8", "") // fets doesn't detect the json content type correctly
.replace(r#""format":"int64","#, ""); // fets uses bigint for int64

// check if the spec has changed
let old_spec = std::fs::read_to_string(path)?;
if old_spec == format!("export default {spec} as const;\n") {
return Ok(());
}
#[derive(Clone)]
pub struct RouterState {
pub app: Arc<Liwan>,
pub events: Sender<Event>,
}

impl Deref for RouterState {
type Target = Arc<Liwan>;

tracing::info!("API has changed, updating the openapi spec...");
std::fs::write(path, format!("export default {spec} as const;\n"))?;
fn deref(&self) -> &Self::Target {
&self.app
}
Ok(())
}

pub fn create_router(app: Arc<Liwan>, events: Sender<Event>) -> impl IntoEndpoint {
let handle_events = event_service().with(Cors::new().allow_method("POST").allow_credentials(false));

let serve_script = EmbeddedFileEndpoint::<Script>::new("script.min.js")
.with(Cors::new().allow_method("GET").allow_credentials(false))
.with(SetHeader::new().appending("Content-Type", "application/javascript"));

let headers = SetHeader::new()
.appending("X-Frame-Options", "DENY")
.appending("X-Content-Type-Options", "nosniff")
.appending("X-XSS-Protection", "1; mode=block")
.appending(
"Content-Security-Policy",
"default-src 'self' data: 'unsafe-inline'; img-src 'self' data: https://*",
)
.appending("Referrer-Policy", "same-origin")
.appending("Permissions-Policy", "geolocation=(), microphone=(), camera=()");

let api_router = Route::new()
.nest_no_strip("/event", handle_events)
.nest("/dashboard", dashboard_service().with(CookieJarManager::new()))
.catch_all_error(catch_error);

Route::new()
.nest("/api", api_router)
.at("/script.js", serve_script)
.nest("/", EmbeddedFilesEndpoint::<Files>::new())
.with(AddData::new(app))
.with(AddData::new(events))
.with(CookieJarManager::new())
.with(Compression::new().algorithms([CompressionAlgo::BR, CompressionAlgo::GZIP]))
.with(headers)
pub fn router(app: Arc<Liwan>, events: Sender<Event>) -> Result<axum::Router<()>> {
let mut api = openapi::OpenApi {
info: openapi::Info { title: "Liwan API".to_string(), ..Default::default() },
..openapi::OpenApi::default()
};

let event_cors = CorsLayer::new().allow_methods([Method::POST]).allow_origin(Any).allow_credentials(false);
let script_cors = CorsLayer::new().allow_methods([Method::GET]).allow_origin(Any).allow_credentials(false);

let set_headers = tower::ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")))
.layer(SetResponseHeaderLayer::if_not_present(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::X_XSS_PROTECTION,
HeaderValue::from_static("1; mode=block"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self' data: 'unsafe-inline'; img-src 'self' data: https://*"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::REFERRER_POLICY,
HeaderValue::from_static("same-origin"),
));

let dashboard = ApiRouter::new()
.merge(routes::admin::router())
.merge(routes::auth::router())
.merge(routes::dashboard::router());

let router = ApiRouter::new()
.nest("/api", routes::event::router().layer(event_cors))
.nest("/api/dashboard", dashboard)
.route_service("/script.js", StaticFile::<Script>::new("script.min.js").layer(script_cors).into_service())
.fallback(axum::routing::get(serve))
.layer(CompressionLayer::new())
.layer(set_headers)
.with_state(RouterState { app: app.clone(), events })
.finish_api(&mut api);

Ok(router)
}

pub async fn start_webserver(app: Arc<Liwan>, events: Sender<Event>) -> Result<()> {
#[cfg(debug_assertions)]
save_spec()?;

let router = create_router(app.clone(), events.clone());
let listener = TcpListener::bind(("0.0.0.0", app.config.port));

match app.onboarding.token()? {
Some(onboarding) => {
let get_started = format!("{}/setup?t={}", app.config.base_url, onboarding);
Expand All @@ -105,5 +105,8 @@ pub async fn start_webserver(app: Arc<Liwan>, events: Sender<Event>) -> Result<(
}
}

Server::new(listener).run(router).await.context("server exited unexpectedly")
let router = router(app.clone(), events)?;
let listener = tokio::net::TcpListener::bind(("0.0.0.0", app.config.port)).await.unwrap();
let service = router.into_make_service_with_connect_info::<SocketAddr>();
axum::serve(listener, service).await.context("server exited unexpectedly")
}
Loading