1#![warn(unreachable_pub)]
4#![warn(missing_docs)]
5
6use std::borrow::Cow;
7
8use chrono::{serde::ts_seconds_option, DateTime, Utc};
9use reqwest::Client;
10use serde::Serialize;
11use thiserror::Error;
12
13#[derive(Debug)]
17pub struct AkismetClient {
18 pub blog: String,
23 pub api_key: String,
25 pub client: Client,
27 pub options: AkismetOptions,
29}
30
31impl AkismetClient {
32 pub fn new(blog: String, api_key: String, client: Client, options: AkismetOptions) -> Self {
34 Self {
35 blog,
36 api_key,
37 client,
38 options,
39 }
40 }
41
42 pub async fn verify_key(&self) -> Result<(), Error> {
44 let url = self.root_endpoint("verify-key");
45
46 let verify_key = VerifyKey {
47 key: &self.api_key,
48 blog: &self.blog,
49 };
50
51 let res = self.post(&verify_key, url).await?;
52
53 match res.text.as_str() {
54 "valid" => Ok(()),
55 "invalid" => match res.debug {
56 Some(debug_text) => Err(Error::Invalid(debug_text.into())),
57 None => Err(Error::Invalid("Unexpected invalid".into())),
58 },
59 text => Err(Error::UnexpectedResponse(text.into())),
60 }
61 }
62
63 pub async fn check_comment(&self, comment: Comment<'_>) -> Result<CheckResult, Error> {
65 let url = self.api_endpoint("comment-check");
66
67 let res = self.post(&comment, url).await?;
68
69 match res.text.as_str() {
70 "true" => match res.pro_tip.as_deref() {
71 Some("discard") => Ok(CheckResult::Discard),
72 Some(_) | None => Ok(CheckResult::Spam),
73 },
74 "false" => Ok(CheckResult::Ham),
75 "invalid" => match res.debug {
76 Some(debug_text) => Err(Error::Invalid(debug_text.into())),
77 None => Err(Error::Invalid("Unexpected invalid".into())),
78 },
79 text => Err(Error::UnexpectedResponse(text.into())),
80 }
81 }
82
83 pub async fn submit_spam(&self, comment: Comment<'_>) -> Result<(), Error> {
85 let url = self.api_endpoint("submit-spam");
86
87 match self.post(&comment, url).await?.text.as_str() {
88 "Thanks for making the web a better place." => Ok(()),
89 text => Err(Error::UnexpectedResponse(text.into())),
90 }
91 }
92
93 pub async fn submit_ham(&self, comment: Comment<'_>) -> Result<(), Error> {
95 let url = self.api_endpoint("submit-ham");
96
97 match self.post(&comment, url).await?.text.as_str() {
98 "Thanks for making the web a better place." => Ok(()),
99 text => Err(Error::UnexpectedResponse(text.into())),
100 }
101 }
102
103 async fn post(&self, req: &impl Serialize, url: String) -> Result<AkismetResponse, Error> {
104 let req = self
105 .client
106 .post(url)
107 .body(serde_qs::to_string(&req)?)
108 .header(
109 reqwest::header::CONTENT_TYPE,
110 "application/x-www-form-urlencoded",
111 )
112 .header(reqwest::header::USER_AGENT, &self.options.user_agent);
113
114 let rsp = req.send().await?;
115
116 match rsp.status().is_success() {
117 true => Ok(AkismetResponse {
118 pro_tip: match rsp.headers().get(AKISMET_PRO_TIP_HEADER) {
119 Some(header) => Some(header.to_str()?.to_string()),
120 None => None,
121 },
122 debug: match rsp.headers().get(AKISMET_DEBUG_HEADER) {
123 Some(header) => Some(header.to_str()?.to_string()),
124 None => None,
125 },
126 text: rsp.text().await?,
127 }),
128 false => match rsp.headers().get(AKISMET_ERROR_HEADER) {
129 Some(header) => Err(Error::AkismetError(header.to_str()?.into())),
130 None => {
131 let error_text = rsp.text().await?;
132 Err(Error::AkismetError(error_text))
133 }
134 },
135 }
136 }
137
138 fn root_endpoint(&self, path: &str) -> String {
139 format!(
140 "{}://{}/{}/{}",
141 &self.options.protocol, &self.options.host, &self.options.version, path
142 )
143 }
144
145 fn api_endpoint(&self, path: &str) -> String {
146 format!(
147 "{}://{}.{}/{}/{}",
148 &self.options.protocol, &self.api_key, &self.options.host, &self.options.version, path
149 )
150 }
151}
152
153#[derive(Debug)]
155pub struct AkismetOptions {
156 pub host: String,
158 pub protocol: String,
160 pub version: String,
162 pub user_agent: String,
164}
165
166impl Default for AkismetOptions {
167 fn default() -> Self {
168 Self {
169 host: AKISMET_HOST.to_string(),
170 protocol: AKISMET_PROTOCOL.to_string(),
171 version: AKISMET_VERSION.to_string(),
172 user_agent: format!(
173 "Instant-Akismet/{} | Akismet/{}",
174 env!("CARGO_PKG_VERSION"),
175 AKISMET_VERSION
176 ),
177 }
178 }
179}
180
181#[derive(Debug, Serialize)]
185pub struct Comment<'a> {
186 pub blog: &'a str,
191 pub user_ip: &'a str,
193 pub user_agent: Option<&'a str>,
197 pub referrer: Option<&'a str>,
199 pub permalink: Option<&'a str>,
201 pub comment_type: Option<CommentType>,
205 pub comment_author: Option<&'a str>,
207 pub comment_author_email: Option<&'a str>,
209 pub comment_author_url: Option<&'a str>,
213 pub comment_content: Option<&'a str>,
215 #[serde(rename = "comment_date_gmt", with = "ts_seconds_option")]
219 pub comment_date: Option<DateTime<Utc>>,
220 #[serde(rename = "comment_post_modified_gmt", with = "ts_seconds_option")]
222 pub comment_post_modified: Option<DateTime<Utc>>,
223 pub blog_lang: Option<&'a str>,
227 pub blog_charset: Option<&'a str>,
231 pub user_role: Option<&'a str>,
235 pub is_test: Option<bool>,
237 pub recheck_reason: Option<&'a str>,
242 pub honeypot_field_name: Option<&'a str>,
247 pub hidden_honeypot_field: Option<&'a str>,
249}
250
251impl<'a> Comment<'a> {
252 pub fn new(blog: &'a str, user_ip: &'a str) -> Self {
254 Self {
255 blog,
256 user_ip,
257 user_agent: None,
258 referrer: None,
259 permalink: None,
260 comment_type: None,
261 comment_author: None,
262 comment_author_email: None,
263 comment_author_url: None,
264 comment_content: None,
265 comment_date: None,
266 comment_post_modified: None,
267 blog_lang: None,
268 blog_charset: None,
269 user_role: None,
270 is_test: None,
271 recheck_reason: None,
272 honeypot_field_name: None,
273 hidden_honeypot_field: None,
274 }
275 }
276
277 pub fn user_agent(mut self, user_agent: &'a str) -> Self {
279 self.user_agent = Some(user_agent);
280 self
281 }
282
283 pub fn referrer(mut self, referrer: &'a str) -> Self {
285 self.referrer = Some(referrer);
286 self
287 }
288
289 pub fn permalink(mut self, permalink: &'a str) -> Self {
291 self.permalink = Some(permalink);
292 self
293 }
294
295 pub fn comment_type(mut self, comment_type: CommentType) -> Self {
297 self.comment_type = Some(comment_type);
298 self
299 }
300
301 pub fn comment_author(mut self, comment_author: &'a str) -> Self {
303 self.comment_author = Some(comment_author);
304 self
305 }
306
307 pub fn comment_author_email(mut self, comment_author_email: &'a str) -> Self {
309 self.comment_author_email = Some(comment_author_email);
310 self
311 }
312
313 pub fn comment_author_url(mut self, comment_author_url: &'a str) -> Self {
315 self.comment_author_url = Some(comment_author_url);
316 self
317 }
318
319 pub fn comment_content(mut self, comment_content: &'a str) -> Self {
321 self.comment_content = Some(comment_content);
322 self
323 }
324
325 pub fn comment_date(mut self, comment_date: DateTime<Utc>) -> Self {
327 self.comment_date = Some(comment_date);
328 self
329 }
330
331 pub fn comment_post_modified(mut self, comment_post_modified: DateTime<Utc>) -> Self {
333 self.comment_post_modified = Some(comment_post_modified);
334 self
335 }
336
337 pub fn blog_lang(mut self, blog_lang: &'a str) -> Self {
339 self.blog_lang = Some(blog_lang);
340 self
341 }
342
343 pub fn blog_charset(mut self, blog_charset: &'a str) -> Self {
345 self.blog_charset = Some(blog_charset);
346 self
347 }
348
349 pub fn user_role(mut self, user_role: &'a str) -> Self {
351 self.user_role = Some(user_role);
352 self
353 }
354
355 pub fn is_test(mut self, is_test: bool) -> Self {
357 self.is_test = Some(is_test);
358 self
359 }
360
361 pub fn recheck_reason(mut self, recheck_reason: &'a str) -> Self {
363 self.recheck_reason = Some(recheck_reason);
364 self
365 }
366
367 pub fn honeypot_field_name(mut self, honeypot_field_name: &'a str) -> Self {
369 self.honeypot_field_name = Some(honeypot_field_name);
370 self
371 }
372
373 pub fn hidden_honeypot_field(mut self, hidden_honeypot_field: &'a str) -> Self {
375 self.hidden_honeypot_field = Some(hidden_honeypot_field);
376 self
377 }
378}
379
380#[derive(Debug, Serialize)]
382#[serde(rename_all = "kebab-case")]
383pub enum CommentType {
384 Comment,
386 ForumPost,
388 Reply,
390 BlogPost,
392 ContactForm,
394 Signup,
396 Message,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum CheckResult {
403 Ham,
405 Spam,
407 Discard,
413}
414
415struct AkismetResponse {
416 text: String,
417 pro_tip: Option<String>,
418 debug: Option<String>,
419}
420
421#[derive(Debug, Serialize)]
422struct VerifyKey<'a> {
423 key: &'a str,
424 blog: &'a str,
425}
426
427#[derive(Debug, Error)]
429pub enum Error {
430 #[error("Akismet request invalid: {0}")]
432 Invalid(Cow<'static, str>),
433 #[error("Unexpected response from Akismet: {0}")]
435 UnexpectedResponse(String),
436 #[error("Akismet error: {0}")]
438 AkismetError(String),
439 #[error("{0}")]
441 Serialize(#[from] serde_qs::Error),
442 #[error("{0}")]
444 Reqwest(#[from] reqwest::Error),
445 #[error("{0}")]
447 ToStrError(#[from] reqwest::header::ToStrError),
448 #[error("{0}")]
450 String(String),
451}
452
453const AKISMET_HOST: &str = "rest.akismet.com";
454const AKISMET_PROTOCOL: &str = "https";
455const AKISMET_VERSION: &str = "1.1";
456const AKISMET_DEBUG_HEADER: &str = "x-akismet-debug-help";
457const AKISMET_PRO_TIP_HEADER: &str = "x-akismet-pro-tip";
458const AKISMET_ERROR_HEADER: &str = "x-akismet-alert-msg";
459
460#[cfg(test)]
461mod tests {
462 use std::env;
463 use std::error::Error;
464
465 use crate::{AkismetClient, AkismetOptions, CheckResult, Comment};
466 use reqwest::Client;
467
468 #[tokio::test]
469 async fn verify_client_key() -> Result<(), Box<dyn Error>> {
470 let akismet_key = match env::var("AKISMET_KEY") {
471 Ok(value) => value,
472 Err(_) => panic!("AKISMET_KEY environment variable is not set."),
473 };
474
475 let akismet_client = AkismetClient::new(
476 String::from("https://instantdomains.com"),
477 akismet_key,
478 Client::new(),
479 AkismetOptions::default(),
480 );
481
482 akismet_client.verify_key().await?;
483
484 Ok(())
485 }
486
487 #[tokio::test]
488 async fn check_known_spam() -> Result<(), Box<dyn Error>> {
489 let akismet_key = match env::var("AKISMET_KEY") {
490 Ok(value) => value,
491 Err(_) => panic!("AKISMET_KEY environment variable is not set."),
492 };
493
494 let akismet_client = AkismetClient::new(
495 String::from("https://instantdomains.com"),
496 akismet_key,
497 Client::new(),
498 AkismetOptions::default(),
499 );
500
501 let known_spam = Comment::new(akismet_client.blog.as_ref(), "8.8.8.8")
502 .comment_author("viagra-test-123")
503 .comment_author_email("akismet-guaranteed-spam@example.com")
504 .comment_content("akismet-guaranteed-spam");
505
506 let is_spam = akismet_client.check_comment(known_spam).await?;
507
508 assert_ne!(is_spam, CheckResult::Ham);
509
510 Ok(())
511 }
512
513 #[tokio::test]
514 async fn check_known_ham() -> Result<(), Box<dyn Error>> {
515 let akismet_key = match env::var("AKISMET_KEY") {
516 Ok(value) => value,
517 Err(_) => panic!("AKISMET_KEY environment variable is not set."),
518 };
519
520 let akismet_client = AkismetClient::new(
521 String::from("https://instantdomains.com"),
522 akismet_key,
523 Client::new(),
524 AkismetOptions::default(),
525 );
526
527 let known_ham = Comment::new(akismet_client.blog.as_ref(), "8.8.8.8")
528 .comment_author("testUser1")
529 .comment_author_email("test-user@example.com")
530 .is_test(true);
531
532 let is_spam = akismet_client.check_comment(known_ham).await.unwrap();
533
534 assert_eq!(is_spam, CheckResult::Ham);
535
536 Ok(())
537 }
538
539 #[tokio::test]
540 async fn submit_spam() -> Result<(), Box<dyn Error>> {
541 let akismet_key = match env::var("AKISMET_KEY") {
542 Ok(value) => value,
543 Err(_) => panic!("AKISMET_KEY environment variable is not set."),
544 };
545
546 let akismet_client = AkismetClient::new(
547 String::from("https://instantdomains.com"),
548 akismet_key,
549 Client::new(),
550 AkismetOptions::default(),
551 );
552
553 let spam = Comment::new(akismet_client.blog.as_ref(), "8.8.8.8")
554 .comment_author("viagra-test-123")
555 .comment_author_email("akismet-guaranteed-spam@example.com")
556 .comment_content("akismet-guaranteed-spam");
557
558 akismet_client.submit_spam(spam).await.unwrap();
559
560 Ok(())
561 }
562
563 #[tokio::test]
564 async fn submit_ham() -> Result<(), Box<dyn Error>> {
565 let akismet_key = match env::var("AKISMET_KEY") {
566 Ok(value) => value,
567 Err(_) => panic!("AKISMET_KEY environment variable is not set."),
568 };
569
570 let akismet_client = AkismetClient::new(
571 String::from("https://instantdomains.com"),
572 akismet_key,
573 Client::new(),
574 AkismetOptions::default(),
575 );
576
577 let ham = Comment::new(akismet_client.blog.as_ref(), "8.8.8.8")
578 .comment_author("testUser1")
579 .comment_author_email("test-user@example.com");
580
581 akismet_client.submit_ham(ham).await.unwrap();
582
583 Ok(())
584 }
585}