backend works in theory, but I don't have a way to display the count, and no tests.

This commit is contained in:
Joe Ardent 2024-03-17 14:57:17 -07:00
commit 1ea0379411
14 changed files with 2316 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
.env

4
.rustfmt.toml Normal file
View file

@ -0,0 +1,4 @@
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
wrap_comments = true
edition = "2021"

1974
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

22
Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "hitman"
version = "0.0.1"
edition = "2021"
author = "code@ardent.nebcorp.com"
description = "A simple webpage hit counter service."
keywords = ["counter", "webpage", "web1.0", "90s"]
readme = "README.md"
license-file = "LICENSE.md"
repository = "https://git.kittencollective.com/nebkor/hitman"
[dependencies]
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "macros"] }
chrono = { version = "0.4", default-features = false, features = ["now"] }
clap = { version = "4.5", default-features = false, features = ["std", "derive", "unicode", "help", "usage"] }
dotenvy = { version = "0.15", default-features = false }
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
log = { version = "0.4", default-features = false, features = ["std"] }
sqlx = { version = "0.7", default-features = false, features = ["macros", "sqlite", "migrate", "tls-none", "runtime-tokio"] }
tokio = { version = "1", default-features = false, features = ["signal", "rt-multi-thread"] }
url = { version = "2.5.0", default-features = false }

5
LICENSE.md Normal file
View file

@ -0,0 +1,5 @@
# The Chaos License (GLP)
This software is released under the terms of the Chaos License. In cases where the terms of the
license are unclear, refer to the [Fuck Around and Find Out
License](https://git.sr.ht/~boringcactus/fafol/tree/master/LICENSE-v0.2.md).

19
README.md Normal file
View file

@ -0,0 +1,19 @@
# Hitman counts your hits, man.
This is a simple webpage hit/visit counter service. To run in development, copy the provided `env.example` file
to `.env`. By default, it will look for a database file in your home directory called
`.hitman.db`. You can let hitman create it for you, or you can use the `sqlx db create` command (get
by running `cargo install sqlx-cli`; see https://crates.io/crates/sqlx-cli ); if you do that, don't
forget to also run `sqlx migrate run`, to create the tables. This project uses SQLx's compile-time
SQL checking macros, so your DB needs to have the tables to allow the SQL checks to work.
## How does it work?
If you wish to use this on your webpage, just add the following lines to the page you want a counter
on:
TODO!!!
It uses the `referer` header to get the page it was called from, and uses that as the key for
counting the hit.

5
build.rs Normal file
View file

@ -0,0 +1,5 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

4
env.example Normal file
View file

@ -0,0 +1,4 @@
DATABASE_URL=sqlite:///${HOME}/.hitman.db
DATABASE_FILE=file:///${HOME}/.hitman.db
LISTENING_ADDR=127.0.0.1
LISTENING_PORT=5000

View file

@ -0,0 +1 @@
drop table if exists hits;

View file

@ -0,0 +1,8 @@
create table if not exists hits (
id integer primary key,
page text not null,
accessed text not null
);
create index if not exists hits_page_dex on hits(page);
create index if not exists hits_accessed_dex on hits(accessed);

215
src/main.rs Normal file
View file

@ -0,0 +1,215 @@
use std::{
env::VarError,
ffi::OsString,
io::Write,
net::{Ipv4Addr, SocketAddr},
};
use axum::{
extract::{Path, State},
http::{header::REFERER, HeaderMap},
routing::{get, MethodRouter},
Router,
};
use clap::Parser;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use tokio::net::TcpListener;
use url::Url;
#[tokio::main]
async fn main() {
init();
let pool = db().await;
// the core application, defining the routes and handlers
let app = Router::new()
.stripped_clone("/count/", get(get_count))
.route("/count/:period", get(get_count))
.with_state(pool.clone())
.into_make_service();
let listener = mklistener().await;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
pool.close().await;
}
#[axum::debug_handler]
async fn get_count(
State(db): State<SqlitePool>,
period: Option<Path<String>>,
headers: HeaderMap,
) -> String {
let now = chrono::Utc::now();
let referer = headers.get(REFERER);
let page = if let Some(referer) = referer {
let p = referer.to_str().unwrap_or("/").to_string();
if let Ok(path) = Url::parse(&p) {
path.path().to_string()
} else {
return "".to_string();
}
} else {
return "".to_string();
};
let page = &page;
let count = if let Some(Path(period)) = period {
// don't increment the counter, just fetch the requested period
match period.as_str() {
"day" => {
let then = now - chrono::Duration::try_hours(24).unwrap();
let then = then.to_rfc3339();
let then = &then;
sqlx::query_scalar!(
"select count(*) from hits where page = ? and accessed > ?",
page,
then
)
.fetch_one(&db)
.await
.unwrap_or(1)
}
"week" => {
let then = now - chrono::Duration::try_days(7).unwrap();
let then = then.to_rfc3339();
let then = &then;
sqlx::query_scalar!(
"select count(*) from hits where page = ? and accessed > ?",
page,
then
)
.fetch_one(&db)
.await
.unwrap_or(1)
}
_ => 2,
}
} else {
// increment the counter and return the all-time total
let now = now.to_rfc3339();
let now = &now;
sqlx::query!("insert into hits (page, accessed) values (?, ?)", page, now)
.execute(&db)
.await
.unwrap_or_default();
sqlx::query_scalar!("select count(*) from hits where page = ?", page)
.fetch_one(&db)
.await
.unwrap_or(1)
};
format!("{count}")
}
//-************************************************************************
// li'l helpers
//-************************************************************************
#[derive(Debug, Parser)]
#[clap(version, about)]
struct Cli {
#[clap(
long,
short,
help = "Path to environment file.",
default_value = ".env"
)]
pub env: OsString,
}
fn init() {
let cli = Cli::parse();
dotenvy::from_path_override(cli.env).expect("Could not read .env file.");
env_logger::builder()
.format(|buf, record| {
let ts = buf.timestamp();
writeln!(buf, "{}: {}", ts, record.args())
})
.init();
}
async fn db() -> SqlitePool {
let dbfile = std::env::var("DATABASE_FILE").unwrap();
let opts = SqliteConnectOptions::new()
.foreign_keys(true)
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.filename(&dbfile)
.optimize_on_close(true, None);
let pool = SqlitePoolOptions::new()
.connect_with(opts.clone())
.await
.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let count = sqlx::query_scalar!("select count(*) from hits")
.fetch_one(&pool)
.await
.expect("could not get customer count from DB");
log::info!("Connected to DB, found {count} customers.");
pool
}
async fn mklistener() -> TcpListener {
let ip =
std::env::var("LISTENING_ADDR").expect("Could not find $LISTENING_ADDR in environment");
let ip: Ipv4Addr = ip
.parse()
.unwrap_or_else(|_| panic!("Could not parse {ip} as an IP address"));
let port: u16 = std::env::var("LISTENING_PORT")
.and_then(|p| p.parse().map_err(|_| VarError::NotPresent))
.unwrap_or_else(|_| {
panic!("Could not find LISTENING_PORT in env or parse if present");
});
let addr = SocketAddr::from((ip, port));
TcpListener::bind(&addr).await.unwrap()
}
/// Adds both routes, with and without a trailing slash.
trait RouterPathStrip<S>
where
S: Clone + Send + Sync + 'static,
{
fn stripped_clone(self, path: &str, method_router: MethodRouter<S>) -> Self;
}
impl<S> RouterPathStrip<S> for Router<S>
where
S: Clone + Send + Sync + 'static,
{
fn stripped_clone(self, path: &str, method_router: MethodRouter<S>) -> Self {
assert!(path.ends_with('/'));
self.route(path, method_router.clone())
.route(path.trim_end_matches('/'), method_router)
}
}
async fn shutdown_signal() {
use tokio::signal;
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {log::info!("shutting down")},
_ = terminate => {},
}
}

27
templates/base.html Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title }}{% endblock %}</title>
<link rel="stylesheet" href="/assets/css/index.css">
<link rel="stylesheet" href="/assets/css/theme-forgejo-auto.css">
{% block head %}{% endblock %}
</head>
<body>
<div id="header">
{% block header %}{% endblock %}
</div>
<div id="content">
{% block content %}{% endblock %}
</div>
<div id="footer">
{% block footer %}{% endblock %}
</div>
</body>
</html>

10
templates/macros.html Normal file
View file

@ -0,0 +1,10 @@
{% macro get_or_default(val, def) %}
{% match val %}
{% when Some with (v) %}
{{v}}
{% else %}
{{def}}
{% endmatch %}
{% endmacro %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Thanks for Signing up for the Kitten Collective!{% endblock %}
{% block content %}
{% block header %}{% endblock %}
<h1>You did it!</h1>
<div id="signup_success">
<p>
{{ self.0 }}
</p>
</div>
<p>Now, head on over to <a href="/user/login?redirect_to=%2F{{ self.0.username|escape }}">the login page</a> and git
going!
</p>
{% endblock %}