diff --git a/src/api/v1/mcaptcha/hooks.rs b/src/api/v1/mcaptcha/hooks.rs new file mode 100644 index 0000000..7d5f1cf --- /dev/null +++ b/src/api/v1/mcaptcha/hooks.rs @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2023 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use actix_web::web::ServiceConfig; +use actix_web::{web, HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::api::v1::ROUTES; +use crate::errors::*; +use crate::mcaptcha::Secret; +use crate::AppData; + +pub fn services(cfg: &mut ServiceConfig) { + cfg.service(register); + cfg.service(upload); + cfg.service(download); +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct MCaptchaInstance { + pub url: Url, +} + +#[actix_web_codegen_const_routes::post(path = "ROUTES.mcaptcha.register")] +async fn register( + data: AppData, + payload: web::Json, +) -> ServiceResult { + /* Summary + * 1. Check if secret exists + * 2. If not, add hostname and create secret + * 3. Post to mCaptcha + */ + + let url_str = payload.url.to_string(); + let secret = if data.mcaptcha_url_exists(&url_str).await? { + data.mcaptcha_update_secret(&url_str).await? + } else { + data.mcaptcha_register_instance(&url_str).await? + }; + + let secret = Secret { secret }; + data.mcaptcha + .share_secret(payload.into_inner().url, &secret) + .await?; + + Ok(HttpResponse::Ok()) +} + +#[actix_web_codegen_const_routes::post(path = "ROUTES.mcaptcha.upload")] +async fn upload( + data: AppData, + campaign: web::Path, + payload: web::Json, +) -> ServiceResult { + /* TODO + * 1. Authenticate: Get URL from secret + * 2. Check if campaign exists + * 3. If not: create campaign + * 4. Get last known sync point + * 5. Download results + * 6. Update sync point + */ + let url = data + .mcaptcha_authenticate_and_get_url(&payload.secret) + .await?; + let campaign_str = campaign.to_string(); + + if !data + .mcaptcha_campaign_is_registered(&campaign, &payload.secret) + .await? + { + data.mcaptcha_register_campaign(&campaign, &payload.secret) + .await?; + } + + let checkpoint = data + .mcaptcha_get_checkpoint(&campaign, &payload.secret) + .await?; + const LIMIT: usize = 50; + let mut page = 1 + (checkpoint / LIMIT); + loop { + let mut res = data + .mcaptcha + .download_benchmarks(url.clone(), &campaign_str, page) + .await?; + let skip = checkpoint - ((page - 1) * LIMIT); + let new_records = res.len() - skip as usize; + let mut skip = skip as isize; + for r in res.drain(0..) { + if skip > 0 { + skip -= 1; + continue; + } + data.mcaptcha_insert_analytics(&campaign, &payload.secret, &r) + .await?; + } + data.mcaptcha_set_checkpoint(&campaign, &payload.secret, new_records) + .await?; + + page += 1; + if res.len() < LIMIT { + break; + } + } + + Ok(HttpResponse::Ok()) +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct Page { + pub page: usize, +} + +#[actix_web_codegen_const_routes::get(path = "ROUTES.mcaptcha.download")] +async fn download( + data: AppData, + page: web::Query, + public_id: web::Path, +) -> ServiceResult { + const LIMIT: usize = 50; + let offset = LIMIT as isize * ((page.page as isize) - 1); + let offset = if offset < 0 { 0 } else { offset }; + let public_id = public_id.into_inner(); + let resp = data + .mcaptcha_analytics_fetch(&public_id, LIMIT, offset as usize) + .await?; + Ok(HttpResponse::Ok().json(resp)) +} + +#[cfg(test)] +mod tests { + use crate::api::v1::bench::Submission; + use crate::api::v1::bench::SubmissionType; + use crate::errors::*; + use crate::mcaptcha::PerformanceAnalytics; + use crate::mcaptcha::Secret; + use crate::tests::*; + use crate::*; + + use actix_web::{http::header, test}; + + #[actix_rt::test] + async fn mcaptcha_hooks_work() { + let mcaptcha_instance = + url::Url::parse("http://mcaptcha_hooks_work.example.org").unwrap(); + let mcaptcha_instance_str = mcaptcha_instance.to_string(); + let campaign_id = uuid::Uuid::new_v4(); + + let (data, client) = get_test_data_with_mcaptcha_client().await; + let app = get_app!(data).await; + + if data + .mcaptcha_url_exists(&mcaptcha_instance_str) + .await + .unwrap() + { + let secret = data + .mcaptcha_update_secret(&mcaptcha_instance_str) + .await + .unwrap(); + data.mcaptcha_delete_mcaptcha_instance(&mcaptcha_instance_str, &secret) + .await + .unwrap(); + } + + let payload = super::MCaptchaInstance { + url: mcaptcha_instance.clone(), + }; + + let resp = test::call_service( + &app, + post_request!(&payload, V1_API_ROUTES.mcaptcha.register).to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let secret = { + let mut mcaptcha = payload.url.clone(); + mcaptcha.set_path("/api/v1/survey/secret"); + let mut x = client.client.write().unwrap(); + x.remove(&mcaptcha.to_string()).unwrap() + }; + + let resp2 = test::call_service( + &app, + post_request!(&payload, V1_API_ROUTES.mcaptcha.register).to_request(), + ) + .await; + assert_eq!(resp2.status(), StatusCode::OK); + + let secret2 = { + let mut mcaptcha = payload.url.clone(); + mcaptcha.set_path("/api/v1/survey/secret"); + let mut x = client.client.write().unwrap(); + x.remove(&mcaptcha.to_string()).unwrap() + }; + + assert_ne!(secret, secret2); + let secret = secret2; + + let payload = Secret { + secret: secret.clone(), + }; + + if data + .mcaptcha_campaign_is_registered(&campaign_id, &secret) + .await + .unwrap() + { + data.mcaptcha_delete_mcaptcha_campaign(&campaign_id, &secret) + .await + .unwrap(); + } + + let resp = test::call_service( + &app, + post_request!( + &payload, + &V1_API_ROUTES + .mcaptcha + .get_upload_route(&campaign_id.to_string()) + ) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let public_id = data + .mcaptcha_get_campaign_public_id(&campaign_id, &secret) + .await + .unwrap(); + + let expected = crate::mcaptcha::tests::BENCHMARK.clone(); + + let got = data + .mcaptcha_analytics_fetch(&public_id, 50, 0) + .await + .unwrap(); + + for i in 0..2 { + assert_eq!(got[i].time, expected[i].time); + assert_eq!(got[i].difficulty_factor, expected[i].difficulty_factor); + assert_eq!(got[i].worker_type, expected[i].worker_type); + } + + let resp = get_request!( + &app, + &V1_API_ROUTES + .mcaptcha + .get_download_route(&public_id.to_string(), 0) + ); + assert_eq!(resp.status(), StatusCode::OK); + let resp: Vec = test::read_body_json(resp).await; + assert_eq!(resp.len(), 2); + assert_eq!(resp, got); + } +} diff --git a/src/api/v1/mcaptcha/mod.rs b/src/api/v1/mcaptcha/mod.rs new file mode 100644 index 0000000..87e79ee --- /dev/null +++ b/src/api/v1/mcaptcha/mod.rs @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2023 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use actix_web::web::ServiceConfig; + +pub mod db; +pub mod hooks; + +pub fn services(cfg: &mut ServiceConfig) { + hooks::services(cfg); +} + +pub mod routes { + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] + pub struct Mcaptcha { + pub upload: &'static str, + pub download: &'static str, + pub register: &'static str, + } + + impl Mcaptcha { + pub const fn new() -> Self { + Self { + register: "/mcaptcha/api/v1/register", + upload: "/mcaptcha/api/v1/{campaign_id}/upload", + download: "/mcapthca/api/v1/{campaign_id}/download", + } + } + + pub fn get_download_route(&self, campaign_id: &str, page: usize) -> String { + format!( + "{}?page={}", + self.download.replace("{campaign_id}", campaign_id), + page + ) + } + + pub fn get_upload_route(&self, campaign_id: &str) -> String { + self.upload.replace("{campaign_id}", campaign_id) + } + } +} diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index 4288ac8..39554fb 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -20,6 +20,7 @@ use sqlx::types::Uuid; pub mod admin; pub mod bench; +pub mod mcaptcha; mod meta; pub mod routes; pub use routes::ROUTES; @@ -28,6 +29,7 @@ pub fn services(cfg: &mut ServiceConfig) { meta::services(cfg); bench::services(cfg); admin::services(cfg); + mcaptcha::services(cfg); } pub fn get_random(len: usize) -> String { diff --git a/src/api/v1/routes.rs b/src/api/v1/routes.rs index 05a0037..eaf7425 100644 --- a/src/api/v1/routes.rs +++ b/src/api/v1/routes.rs @@ -18,6 +18,7 @@ use serde::Serialize; use super::admin::routes::Admin; use super::bench::routes::Benches; +use super::mcaptcha::routes::Mcaptcha; use super::meta::routes::Meta; pub const ROUTES: Routes = Routes::new(); @@ -27,6 +28,7 @@ pub struct Routes { pub admin: Admin, pub meta: Meta, pub benches: Benches, + pub mcaptcha: Mcaptcha, } impl Routes { @@ -35,6 +37,7 @@ impl Routes { admin: Admin::new(), meta: Meta::new(), benches: Benches::new(), + mcaptcha: Mcaptcha::new(), } } }