backend works in theory, but I don't have a way to display the count, and no tests.
This commit is contained in:
commit
1ea0379411
14 changed files with 2316 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
.env
|
4
.rustfmt.toml
Normal file
4
.rustfmt.toml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
wrap_comments = true
|
||||||
|
edition = "2021"
|
1974
Cargo.lock
generated
Normal file
1974
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal 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
5
LICENSE.md
Normal 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
19
README.md
Normal 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
5
build.rs
Normal 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
4
env.example
Normal 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
|
1
migrations/20240317182405_hits.down.sql
Normal file
1
migrations/20240317182405_hits.down.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists hits;
|
8
migrations/20240317182405_hits.up.sql
Normal file
8
migrations/20240317182405_hits.up.sql
Normal 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
215
src/main.rs
Normal 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
27
templates/base.html
Normal 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
10
templates/macros.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{% macro get_or_default(val, def) %}
|
||||||
|
|
||||||
|
{% match val %}
|
||||||
|
{% when Some with (v) %}
|
||||||
|
{{v}}
|
||||||
|
{% else %}
|
||||||
|
{{def}}
|
||||||
|
{% endmatch %}
|
||||||
|
|
||||||
|
{% endmacro %}
|
20
templates/signup_success.html
Normal file
20
templates/signup_success.html
Normal 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 %}
|
Loading…
Reference in a new issue