instant_akismet/
lib.rs

1//! Akismet spam detection client.
2
3#![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/// A client for the Akismet spam detection service
14///
15/// Create an [`AkismetClient`] with [`AkismetClient::new()`].
16#[derive(Debug)]
17pub struct AkismetClient {
18    /// The front page or home URL of the instance making the request
19    ///
20    /// For a blog, site, or wiki this would be the front page.
21    /// Note: must be a full URI, including http://.
22    pub blog: String,
23    /// Akismet API key
24    pub api_key: String,
25    /// Instance of `reqwest::Client` to use for requests to Akismet
26    pub client: Client,
27    /// Akismet client configuration options
28    pub options: AkismetOptions,
29}
30
31impl AkismetClient {
32    /// Create a new [`AkismetClient`] for a given `blog` with an [`AkismetOptions`] configuration
33    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    /// Verify the validity of your Akismet API key
43    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    /// Check a [`Comment`] for spam
64    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    /// Submit a [`Comment`] as spam
84    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    /// Submit a [`Comment`] as not spam
94    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/// A set of configuration options for an [`AkismetClient`]
154#[derive(Debug)]
155pub struct AkismetOptions {
156    /// Host for Akismet API endpoint
157    pub host: String,
158    /// Protocol for Akismet API endpoint
159    pub protocol: String,
160    /// Akismet version
161    pub version: String,
162    /// User agent of Akismet library
163    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/// An Akismet comment
182///
183/// <https://akismet.com/development/api/#comment-check>
184#[derive(Debug, Serialize)]
185pub struct Comment<'a> {
186    /// The front page or home URL of the instance making the request
187    ///
188    /// For a blog or wiki this would be the front page.
189    /// Note: must be a full URI, including http://.
190    pub blog: &'a str,
191    /// IP address of the comment submitter
192    pub user_ip: &'a str,
193    /// User agent string of the web browser submitting the comment
194    ///
195    /// Note: not to be confused with the user agent of your Akismet library.
196    pub user_agent: Option<&'a str>,
197    /// The content of the HTTP_REFERER header (note spelling)
198    pub referrer: Option<&'a str>,
199    /// The full permanent URL of the entry the comment was submitted to
200    pub permalink: Option<&'a str>,
201    /// A string that describes the type of content being sent
202    ///
203    /// Serialized from [`CommentType`] enum.
204    pub comment_type: Option<CommentType>,
205    /// Name submitted with the comment
206    pub comment_author: Option<&'a str>,
207    /// Email address submitted with the comment
208    pub comment_author_email: Option<&'a str>,
209    /// URL submitted with comment
210    ///
211    /// Only send a URL that was manually entered by the user, not an automatically generated URL.
212    pub comment_author_url: Option<&'a str>,
213    /// The content that was submitted
214    pub comment_content: Option<&'a str>,
215    /// The UTC timestamp of the creation of the comment, in ISO 8601 format
216    ///
217    /// May be omitted for comment-check requests if the comment is sent to the API on creation.
218    #[serde(rename = "comment_date_gmt", with = "ts_seconds_option")]
219    pub comment_date: Option<DateTime<Utc>>,
220    /// The UTC timestamp of the publication time for the content on which the comment was posted
221    #[serde(rename = "comment_post_modified_gmt", with = "ts_seconds_option")]
222    pub comment_post_modified: Option<DateTime<Utc>>,
223    /// Indicates the language(s) in use on the blog or site, in ISO 639-1 format, comma-separated
224    ///
225    /// A site with articles in English and French might use “en, fr_ca”.
226    pub blog_lang: Option<&'a str>,
227    /// Character encoding for the values included in `comment_*` parameters
228    ///
229    /// eg: “UTF-8” or “ISO-8859-1”
230    pub blog_charset: Option<&'a str>,
231    /// The user role of the user who submitted the comment
232    ///
233    /// If you set it to “administrator”, Akismet will always return false.
234    pub user_role: Option<&'a str>,
235    /// Use when submitting test queries to Akismet
236    pub is_test: Option<bool>,
237    /// Reason for sending content to Akismet to be rechecked
238    ///
239    /// Include `recheck_reason` with a string describing why the content is being rechecked.
240    /// For example, `recheck_reason=edit`.
241    pub recheck_reason: Option<&'a str>,
242    /// Name of a honeypot field
243    ///
244    /// For example, if you have a honeypot field like `<input name="hidden_honeypot_field"/>`,
245    /// you should set this to `hidden_honeypot_field`.
246    pub honeypot_field_name: Option<&'a str>,
247    /// If `honeypot_field_name` is defined, you should include that input field's value here.
248    pub hidden_honeypot_field: Option<&'a str>,
249}
250
251impl<'a> Comment<'a> {
252    /// Create a minimal [`Comment`] with the required `blog` and `user_ip`
253    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    /// Set the comment's `user_agent`
278    pub fn user_agent(mut self, user_agent: &'a str) -> Self {
279        self.user_agent = Some(user_agent);
280        self
281    }
282
283    /// Set the comment's `referrer`
284    pub fn referrer(mut self, referrer: &'a str) -> Self {
285        self.referrer = Some(referrer);
286        self
287    }
288
289    /// Set the comment's `permalink`
290    pub fn permalink(mut self, permalink: &'a str) -> Self {
291        self.permalink = Some(permalink);
292        self
293    }
294
295    /// Set the comment's `comment_type`
296    pub fn comment_type(mut self, comment_type: CommentType) -> Self {
297        self.comment_type = Some(comment_type);
298        self
299    }
300
301    /// Set the comment's `comment_author`
302    pub fn comment_author(mut self, comment_author: &'a str) -> Self {
303        self.comment_author = Some(comment_author);
304        self
305    }
306
307    /// Set the comment's `comment_author_email`
308    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    /// Set the comment's `comment_author_url`
314    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    /// Set the comment's `comment_content`
320    pub fn comment_content(mut self, comment_content: &'a str) -> Self {
321        self.comment_content = Some(comment_content);
322        self
323    }
324
325    /// Set the comment's `comment_date`
326    pub fn comment_date(mut self, comment_date: DateTime<Utc>) -> Self {
327        self.comment_date = Some(comment_date);
328        self
329    }
330
331    /// Set the comment's `comment_post_modified`
332    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    /// Set the comment's `blog_lang`
338    pub fn blog_lang(mut self, blog_lang: &'a str) -> Self {
339        self.blog_lang = Some(blog_lang);
340        self
341    }
342
343    /// Set the comment's `blog_charset`
344    pub fn blog_charset(mut self, blog_charset: &'a str) -> Self {
345        self.blog_charset = Some(blog_charset);
346        self
347    }
348
349    /// Set the comment's `user_role`
350    pub fn user_role(mut self, user_role: &'a str) -> Self {
351        self.user_role = Some(user_role);
352        self
353    }
354
355    /// Set the comment's `is_test`
356    pub fn is_test(mut self, is_test: bool) -> Self {
357        self.is_test = Some(is_test);
358        self
359    }
360
361    /// Set the comment's `recheck_reason`
362    pub fn recheck_reason(mut self, recheck_reason: &'a str) -> Self {
363        self.recheck_reason = Some(recheck_reason);
364        self
365    }
366
367    /// Set the comment's `honeypot_field_name`
368    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    /// Set the comment's `hidden_honeypot_field`
374    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/// Type of content to be checked
381#[derive(Debug, Serialize)]
382#[serde(rename_all = "kebab-case")]
383pub enum CommentType {
384    /// A blog comment
385    Comment,
386    /// A top-level forum post
387    ForumPost,
388    /// A reply to a top-level forum post
389    Reply,
390    /// A blog post
391    BlogPost,
392    /// A contact form or feedback form submission
393    ContactForm,
394    /// A new user account
395    Signup,
396    /// A message sent between users
397    Message,
398}
399
400/// Result of an [`AkismetClient::check_comment()`]
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum CheckResult {
403    /// Not spam
404    Ham,
405    /// Spam
406    Spam,
407    /// Guaranteed spam (via pro-tip header)
408    ///
409    /// "If the X-akismet-pro-tip header is set to discard, then Akismet has determined that the
410    /// comment is blatant spam, and you can safely discard it without saving it in any spam
411    /// queue."
412    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/// Error type for instant-akismet
428#[derive(Debug, Error)]
429pub enum Error {
430    /// Akismet responded with `invalid`
431    #[error("Akismet request invalid: {0}")]
432    Invalid(Cow<'static, str>),
433    /// Akismet returned an unexpected response
434    #[error("Unexpected response from Akismet: {0}")]
435    UnexpectedResponse(String),
436    /// Error in request to Akismet
437    #[error("Akismet error: {0}")]
438    AkismetError(String),
439    /// Failed to serialize request
440    #[error("{0}")]
441    Serialize(#[from] serde_qs::Error),
442    /// Reqwest client error
443    #[error("{0}")]
444    Reqwest(#[from] reqwest::Error),
445    /// Failed to convert `HeaderValue` to string
446    #[error("{0}")]
447    ToStrError(#[from] reqwest::header::ToStrError),
448    /// Miscellaneous errors
449    #[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}