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