352 lines
9.9 KiB
Rust
352 lines
9.9 KiB
Rust
// tricaptcha
|
|
// Copyright (C) 2023 trimill <trimill012@gmail.com>
|
|
//
|
|
// This program is licensed under the GNU GPLv3 free
|
|
// software license. You are free to modify and distribute
|
|
// it under the terms of the license. If you did not recieve
|
|
// a copy of the license with this program, it can be found
|
|
// at https://www.gnu.org/licenses/gpl-3.0.html.
|
|
|
|
use std::{fmt::Write, collections::HashMap, sync::Arc, process::Stdio, time::{SystemTime, Duration}, net::ToSocketAddrs};
|
|
|
|
use axum::{
|
|
routing::{get, post},
|
|
response::{Html, IntoResponse, Response},
|
|
Router, extract::{Path, Query, State}, Form, http::{header, StatusCode}, Json,
|
|
};
|
|
use log::{info, debug, trace};
|
|
use rand::{seq::SliceRandom, Rng, distributions::{Alphanumeric, DistString}};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::{sync::Mutex, process::Command};
|
|
|
|
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref INDEX: String = std::fs::read_to_string("resources/index.html").unwrap();
|
|
static ref SUBMIT_OK: String = std::fs::read_to_string("resources/submit_ok.html").unwrap();
|
|
static ref SUBMIT_ERR: String = std::fs::read_to_string("resources/submit_err.html").unwrap();
|
|
|
|
static ref IMAGES: [Vec<String>; 10] = (0u8..=9).map(|i| {
|
|
let mut path = "resources/images/".to_owned();
|
|
path.push((i + b'0') as char);
|
|
std::fs::read_dir(path.clone()).unwrap()
|
|
.map(|x| path.clone() + "/" + x.unwrap().file_name().to_str().unwrap()).collect()
|
|
}).collect::<Vec<Vec<String>>>().try_into().unwrap();
|
|
|
|
static ref AUDIO: [Vec<String>; 10] = (0u8..=9).map(|i| {
|
|
let mut path = "resources/audio/".to_owned();
|
|
path.push((i + b'0') as char);
|
|
std::fs::read_dir(path.clone()).unwrap()
|
|
.map(|x| path.clone() + "/" + x.unwrap().file_name().to_str().unwrap()).collect()
|
|
}).collect::<Vec<Vec<String>>>().try_into().unwrap();
|
|
}
|
|
|
|
struct IncompleteCaptcha {
|
|
digits: String,
|
|
issued: u64,
|
|
got_image: bool,
|
|
got_audio: bool,
|
|
userdata: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CompleteCaptcha {
|
|
count: usize,
|
|
issued: u64,
|
|
completed: u64,
|
|
userdata: String,
|
|
}
|
|
|
|
struct AppState {
|
|
incomplete: Mutex<HashMap<u64, IncompleteCaptcha>>,
|
|
complete: Mutex<HashMap<String, CompleteCaptcha>>,
|
|
}
|
|
|
|
type AState = Arc<AppState>;
|
|
|
|
const fn default_count() -> usize { 7 }
|
|
|
|
async fn next_id(state: &AppState) -> u64 {
|
|
loop {
|
|
let id = rand::thread_rng().gen();
|
|
if !state.incomplete.lock().await.contains_key(&id) {
|
|
debug!("generated id {id}");
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn mk_captcha_img(digits: &str) -> Result<Vec<u8>> {
|
|
let images: Vec<String> = digits.bytes().map(|b|
|
|
IMAGES[(b - b'0') as usize].choose(&mut rand::thread_rng()).unwrap().clone()
|
|
).collect();
|
|
|
|
let cmd = Command::new("convert")
|
|
.arg("+append")
|
|
.args(images)
|
|
.args([
|
|
"-attenuate", "55.0", "+noise", "Uniform",
|
|
"-resize", "x84",
|
|
"-attenuate", "105.0", "+noise", "Uniform",
|
|
"PNG:-"
|
|
]).stdout(Stdio::piped())
|
|
.spawn()?;
|
|
|
|
let output = cmd.wait_with_output().await?;
|
|
|
|
if !output.status.success() {
|
|
let msg = String::from_utf8(output.stderr)?;
|
|
return Err(format!("imagemagick error: {msg}").into())
|
|
}
|
|
|
|
Ok(output.stdout)
|
|
}
|
|
|
|
async fn mk_captcha_audio(digits: &str) -> Result<Vec<u8>> {
|
|
let audio_files: Vec<&str> = digits.bytes().map(|b|
|
|
AUDIO[(b - b'0') as usize].choose(&mut rand::thread_rng()).unwrap().as_str()
|
|
).collect();
|
|
|
|
let mut input_args: Vec<&str> = Vec::with_capacity(audio_files.len() * 2);
|
|
for file in &audio_files {
|
|
input_args.push("-i");
|
|
input_args.push(file);
|
|
}
|
|
|
|
let mut filter = String::new();
|
|
write!(filter, "aevalsrc=0:d=0.2[x];aevalsrc=0:d=0.4,asplit={}", audio_files.len()).unwrap();
|
|
for i in 0..audio_files.len() {
|
|
write!(filter, "[s{i}]").unwrap();
|
|
}
|
|
write!(filter, ";[x]").unwrap();
|
|
for i in 0..audio_files.len() {
|
|
write!(filter, "[{i}:a][s{i}]").unwrap();
|
|
}
|
|
write!(filter, "concat=n={}:v=0:a=1", audio_files.len()*2 + 1).unwrap();
|
|
|
|
let cmd = Command::new("ffmpeg")
|
|
.args(["-loglevel", "16"])
|
|
.args(input_args)
|
|
.args(["-filter_complex", &filter, "-f", "wav", "-"])
|
|
.stdout(Stdio::piped())
|
|
.spawn()?;
|
|
|
|
let output = cmd.wait_with_output().await?;
|
|
|
|
if !output.status.success() {
|
|
let msg = String::from_utf8(output.stderr)?;
|
|
return Err(format!("ffmpeg error: {msg}").into())
|
|
}
|
|
|
|
Ok(output.stdout)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct IndexQuery {
|
|
#[serde(default="default_count")]
|
|
count: usize,
|
|
#[serde(default)]
|
|
userdata: String,
|
|
}
|
|
|
|
async fn page_index(State(state): State<AState>, Query(query): Query<IndexQuery>) -> Html<String> {
|
|
debug!("loading index page, count {} userdata {:?}", query.count, query.userdata);
|
|
let id = next_id(&state).await;
|
|
let id_string = id.to_string();
|
|
let id_str: &str = id_string.as_ref();
|
|
|
|
let count = query.count.clamp(1, 16);
|
|
|
|
let digits: String = (0..count)
|
|
.map(|_| (rand::thread_rng().gen_range(0..=9) + b'0') as char)
|
|
.collect();
|
|
|
|
let time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
|
|
|
|
let cap = IncompleteCaptcha {
|
|
issued: time,
|
|
digits,
|
|
userdata: query.userdata,
|
|
got_image: false,
|
|
got_audio: false,
|
|
};
|
|
|
|
state.incomplete.lock().await.insert(id, cap);
|
|
|
|
let body = INDEX.replace("{{id}}", id_str);
|
|
|
|
Html(body)
|
|
}
|
|
|
|
async fn page_image(State(state): State<AState>, Path(id): Path<u64>) -> Response {
|
|
debug!("loading image {id}");
|
|
let mut incomplete = state.incomplete.lock().await;
|
|
let Some(cap) = incomplete.get_mut(&id) else {
|
|
debug!("invalid image {id}");
|
|
return (StatusCode::BAD_REQUEST, "Invalid ID").into_response()
|
|
};
|
|
|
|
if cap.got_image {
|
|
debug!("image {id} already loaded");
|
|
return (StatusCode::BAD_REQUEST, "Image already viewed").into_response()
|
|
}
|
|
cap.got_image = true;
|
|
|
|
let digits = cap.digits.clone();
|
|
drop(incomplete);
|
|
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
debug!("generating image");
|
|
let img = match mk_captcha_img(&digits).await {
|
|
Ok(img) => img,
|
|
Err(e) => {
|
|
debug!("image generation failed: {e}");
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
|
}
|
|
};
|
|
debug!("image generated");
|
|
|
|
let mut res = img.into_response();
|
|
res.headers_mut().insert(
|
|
header::CONTENT_TYPE, "image/png".parse().unwrap()
|
|
);
|
|
res.into_response()
|
|
}
|
|
|
|
async fn page_audio(State(state): State<AState>, Path(id): Path<u64>) -> Response {
|
|
debug!("loading audio {id}");
|
|
let mut incomplete = state.incomplete.lock().await;
|
|
let Some(cap) = incomplete.get_mut(&id) else {
|
|
debug!("invalid audio {id}");
|
|
return (StatusCode::BAD_REQUEST, "Invalid ID").into_response()
|
|
};
|
|
|
|
if cap.got_audio {
|
|
debug!("audio {id} already loaded");
|
|
return (StatusCode::BAD_REQUEST, "Audio already accessed").into_response()
|
|
}
|
|
cap.got_audio = true;
|
|
|
|
let digits = cap.digits.clone();
|
|
drop(incomplete);
|
|
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
debug!("generating audio");
|
|
let audio = match mk_captcha_audio(&digits).await {
|
|
Ok(audio) => audio,
|
|
Err(e) => {
|
|
debug!("audio generation failed: {e}");
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
|
}
|
|
};
|
|
debug!("audio generated");
|
|
|
|
let mut res = audio.into_response();
|
|
res.headers_mut().insert(
|
|
header::CONTENT_TYPE, "audio/wav".parse().unwrap()
|
|
);
|
|
res.into_response()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SubmitForm {
|
|
id: u64,
|
|
digits: String,
|
|
}
|
|
|
|
async fn page_submit(State(state): State<AState>, Form(form): Form<SubmitForm>) -> impl IntoResponse {
|
|
debug!("submitting id {} digits {}", form.id, form.digits);
|
|
let Some(cap) = state.incomplete.lock().await.remove(&form.id) else {
|
|
debug!("id {} expired", form.id);
|
|
let body = SUBMIT_ERR.replace("{{msg}}", "CAPTCHA expired or ID invalid. Consider trying again.");
|
|
return Html(body)
|
|
};
|
|
|
|
if form.digits != cap.digits {
|
|
debug!("id {} failed: expected {}, got {}", form.id, cap.digits, form.digits);
|
|
let body = SUBMIT_ERR.replace("{{msg}}", "CAPTCHA failed. Consider trying again.");
|
|
return Html(body)
|
|
}
|
|
|
|
|
|
let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 24);
|
|
|
|
let count = cap.digits.len();
|
|
let completed = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
|
|
|
|
debug!("id {} succeeded, generated token {}", form.id, token);
|
|
|
|
let body = SUBMIT_OK.replace("{{token}}", &token);
|
|
|
|
state.complete.lock().await.insert(token, CompleteCaptcha { count, issued: cap.issued, completed, userdata: cap.userdata });
|
|
|
|
Html(body)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct VerifyQuery {
|
|
token: String,
|
|
}
|
|
|
|
async fn page_verify(State(state): State<AState>, Query(query): Query<VerifyQuery>) -> Response {
|
|
debug!("verifying token {}", query.token);
|
|
let Some(info) = state.complete.lock().await.remove(&query.token) else {
|
|
debug!("verification failed for {}", query.token);
|
|
return (StatusCode::BAD_REQUEST, "Invalid or expired token").into_response()
|
|
};
|
|
debug!("verification succeeded for {}", query.token);
|
|
|
|
Json(info).into_response()
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
env_logger::init();
|
|
|
|
let addr = std::env::var("TCAP_ADDR")
|
|
.unwrap_or_else(|_| "localhost:8000".to_owned())
|
|
.to_socket_addrs().unwrap().next().unwrap();
|
|
|
|
let state = Arc::new(AppState {
|
|
incomplete: Mutex::new(HashMap::new()),
|
|
complete: Mutex::new(HashMap::new()),
|
|
});
|
|
|
|
let app = Router::new()
|
|
.route("/", get(page_index))
|
|
.route("/image/:id", get(page_image))
|
|
.route("/audio/:id", get(page_audio))
|
|
.route("/submit", post(page_submit))
|
|
.route("/verify", get(page_verify))
|
|
.with_state(state.clone());
|
|
|
|
let server = tokio::spawn(async move {
|
|
info!("Listening on http://{addr}");
|
|
axum::Server::bind(&addr)
|
|
.serve(app.into_make_service())
|
|
.await
|
|
.unwrap();
|
|
});
|
|
|
|
let timeout = tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
|
loop {
|
|
interval.tick().await;
|
|
trace!("cleaning up");
|
|
|
|
let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
|
|
|
|
state.incomplete.lock().await
|
|
.retain(|_, v| now - v.issued < 60*3);
|
|
state.complete.lock().await
|
|
.retain(|_, v| now - v.completed < 60*3);
|
|
}
|
|
});
|
|
|
|
let (a, b) = tokio::join!(server, timeout);
|
|
a.unwrap();
|
|
b.unwrap();
|
|
}
|