Rust: simple actix-web email-password login and request authentication using middleware.

For our learning actix-web application, we are now adding two new features. ⓵ A simple email-password login with no session expiry. ⓶ A new middleware that manages request authentication using an access token “generated” by the login process. All five existing routes are now protected by this middleware: they can only be accessed if the request has a valid access token. With these two new features added, this application acts as both an application server and an API-like server or a service.

🦀 Index of the Complete Series.

🚀 Please note, complete code for this post can be downloaded from GitHub with:

 git clone -b v0.6.0 https://github.com/behai-nguyen/rust_web_01.git 

The actix-web learning application mentioned above has been discussed in the following five previous posts:

  1. Rust web application: MySQL server, sqlx, actix-web and tera.
  2. Rust: learning actix-web middleware 01.
  3. Rust: retrofit integration tests to an existing actix-web application.
  4. Rust: adding actix-session and actix-identity to an existing actix-web application.
  5. Rust: actix-web endpoints which accept both application/x-www-form-urlencoded and application/json content types.

The code we’re developing in this post is a continuation of the code from the fifth post above. 🚀 To get the code of this fifth post, please use the following command:

 git clone -b v0.5.0 https://github.com/behai-nguyen/rust_web_01.git 

— Note the tag v0.5.0.

Table of contents

Some Terms and Phrases Definition

Let’s clarify the meanings of some glossary terms to facilitate the understanding of this post.

● An application server — the application functions as a website server, serving interactive HTML pages and managing states associated with client web sessions.

● An API-like server or a service — the application operates as a data provider, verifying the validity of client requests. Specifically, it checks for a valid access token included in the request authorization header. If the requests are valid, it proceeds to serve them.

● An access tokenin this revision of the code, any non-blank string is considered a valid access token! Please note that this is a work in progress, and currently, login emails are used as access tokens.

As such, we acknowledge that this so-called access token is relatively ineffective as a security measure. The primary focus of this post is on the login and request authentication processes. Consider it a placeholder, as we plan to refactor it into a more formal authentication method.

The response from the login process always includes the access token in the authorization header implictly, and explictly in JSON responses. Clients should store this access token for future use.

To utilise this application as an API-like server or a service, client requests must include the previously provided access token in the authorization header.

● An authenticated session — a client web session who has previously logged in or authenticated. That is, having been given an access token by the login process.

Request authentication — the process of verifying that the access token is present and valid. If a request passes the request authentication process, it indicates that the request comes from an authenticated session.

Request authentication middleware — this is the new middleware mentioned in the introduction, fully responsible for the request authentication process.

● An authenticated request — a request which has passed the request authentication process.

Project Layout

This post introduces several new modules and a new HTML home page, with some modules receiving updates. The updated directory layout for the project is listed below.

— Please note, those marked with are updated, and those marked with are new.

.
├── Cargo.toml ★
├── .env
├── migrations
│ ├── mysql
│ │ └── migrations
│ │ ├── 20231128234321_emp_email_pwd.down.sql
│ │ └── 20231128234321_emp_email_pwd.up.sql
│ └── postgres
│ └── migrations
│ ├── 20231130023147_emp_email_pwd.down.sql
│ └── 20231130023147_emp_email_pwd.up.sql
├── README.md ★
├── src
│ ├── auth_handlers.rs ★
│ ├── auth_middleware.rs ☆
│ ├── bh_libs
│ │ └── api_status.rs ★
│ ├── bh_libs.rs ★
│ ├── config.rs
│ ├── database.rs
│ ├── handlers.rs
│ ├── helper
│ │ ├── app_utils.rs ☆
│ │ ├── constants.rs ☆
│ │ ├── endpoint.rs ★
│ │ └── messages.rs ★
│ ├── helper.rs ★
│ ├── lib.rs ★
│ ├── main.rs
│ ├── middleware.rs
│ ├── models.rs ★
│ └── utils.rs
├── templates
│ ├── auth
│ │ ├── home.html ☆
│ │ └── login.html
│ └── employees.html ★
└── tests
├── common.rs ★
├── test_auth_handlers.rs ☆
└── test_handlers.rs ★

Code Documentation

The code has extensive documentation. It probably has more detail than in this post, as documentation is specific to functionalities and implementation.

To view the code documentation, change to the project directory (where Cargo.toml is located) and run the following command:

 ▶️Windows 10: cargo doc --open ▶️Ubuntu 22.10: $ cargo doc --open 

Issues Covered In This Post

❶ “Complete” the login function.

In the fifth post, we introduced two new login-related routes, /ui/login and /api/login, used them to demonstrate accepting request data in both application/x-www-form-urlencoded and application/json formats.

In this post, we’ll fully implement a simple email and password login process with no session expiry. In other words, if we can identify an employee by email, and the submitted password matches the database password, then the session is considered logged in or authenticated. The session remains valid indefinitely, until the browser is shut down.

🚀 The handlers for /ui/login and /api/login have the capability of conditionally return either HTML or JSON depending on the content type of the original request.

❷ Protect all existing and new /data/xxx and /ui/xxx routes (except /ui/login) using the new request authentication middleware as mentioned in the introduction.

This means only authenticated requests can access these routes. Recall that we have the following five routes, which query the database and return data in some form:

  1. JSON response route http://0.0.0.0:5000/data/employees — method: POST; content type: application/json; request body: {"last_name": "%chi", "first_name": "%ak"}.
  2. JSON response route http://0.0.0.0:5000/data/employees/%chi/%ak — method GET.
  3. HTML response route http://0.0.0.0:5000/ui/employees — method: POST; content type: application/x-www-form-urlencoded; charset=UTF-8; request body: last_name=%chi&first_name=%ak.
  4. HTML response route http://0.0.0.0:5000/ui/employees/%chi/%ak — method: GET.
  5. HTML response route http://0.0.0.0:5000/helloemployee/%chi/%ak — method: GET.

We implement protection, or request authentication, around these routes, allowing only authenticated sessions to access them. When a request is not authenticated, it gets redirected to the /ui/login route. The handler for this route uses the content type of the original request to determine whether it returns the HTML login page with a user-friendly error message or an appropriate JSON error response.

The new middleware we’re using to manage the request authentication process is based on the official redirect example. We rename it to src/auth_middleware.rs.

❸ We implement two additional authentication-related routes: /ui/home and /api/logout.

The /ui/home route is protected, and if requests are successful, its handler always returns the HTML home page.

The /api/logout handler always returns the HTML login page.

To recap, we have the following four new authentication-related routes:

  1. HTML/JSON response route http://0.0.0.0:5000/ui/login — method: GET.
  2. HTML/JSON response route http://0.0.0.0:5000/api/login — method: POST.
    content type: application/x-www-form-urlencoded; charset=UTF-8; request body: email=chirstian.koblick.10004@gmail.com&password=password.
    content type: application/json; request body: {"email": "chirstian.koblick.10004@gmail.com", "password": "password"}.
  3. HTML response route http://0.0.0.0:5000/ui/home — method: GET.
  4. HTML response route http://0.0.0.0:5000/api/logout — method: POST.

❹ Updating existing integration tests and creating new ones for new functionalities.

On the Hard-Coded Value for employees.password

In the section Add new fields email and password to the employees table of the fifth post, in the migration script, we hard-coded the string $argon2id$v=19$m=16,t=2,p=1$cTJhazRqRWRHR3NYbEJ2Zg$z7pMnKzV0eU5eJkdq+hycQ for all passwords. It is the hashed version of password.

It was generated using Argon2 Online by Esse.Tools, which is compatible with the argon2 crate. Thus, we can use this crate to de-hash a hashed password to compare it to a plain text one.

Notes On Cookies

❶ In the fourth post, Rust: adding actix-session and actix-identity to an existing actix-web application, we introduced the crate actix-identity, which requires the crate actix-session. However we didn’t make use of them. Now, they are used in the code of this post.

The crate actix-session will create a secured cookie named id. However, since we’re only testing the application with HTTP (not HTTPS), some browsers reject such secured cookie.

Since this is only a learning application, we’ll make all cookies non-secured. Module src/lib.rs gets updated as follows:

 ... .wrap(SessionMiddleware::builder( redis_store.clone(), secret_key.clone() ) .cookie_secure(false) .build(), ) ... 

We call the builder(…) method to access the cookie_secure(…) method and set the cookie id to non-secured.

❷ To handle potential request redirections during the login and the request authentication processes, the application utilises the following server-side per-request cookies: redirect-message and original-content-type.

💥 Request redirection occurs when a request is redirected to /ui/login due to some failure condition. When a request gets redirected elsewhere, request redirection does not apply.

These cookies help persisting necessary information between requests. Between requests refers to the original request that gets redirected, resulting in the second and final independent request. Hence, per-request pertains to the original request.

We implement a helper function to create these cookies in the module src/helper/app_utils.rs:

 pub fn build_cookie<'a>( ... let mut cookie = Cookie::build(name, value) .domain(String::from(parts.collect::<Vec<&str>>()[0])) .path("/") .secure(false) .http_only(server_only) .same_site(SameSite::Strict) .finish(); if removal { cookie.make_removal(); } ... 

Refer to the following Mdm Web Docs Set-Cookie for explanations of the settings used in the above function.

Take note of the call to the method make_removal(…) — it’s necessary to remove the server-side per-request cookies when the request completes.

In addition to the aforementioned temporary cookies, the application also maintains an application-wide publicly available cookie named authorization. This cookie stores the access token after a successful login.

To recap, the application maintains three cookies. In the module src/helper/app_utils.rs, we also implement three pairs of helper methods, build_xxx_cookie(...) and remove_xxx_cookie(...), to help manage the lifetime of these cookies.

HTTP Response Status Code

All HTTP responses — successful and failure, HTML and JSON — have their HTTP response status code set to an appropriate code. In addition, if a response is in JSON format, the field ApiStatus.code also has its value sets to the value of the HTTP response status code.

— We’ve introduced ApiStatus in the fifth post. Basically, it’s a generic API status response that gets included in all JSON responses.

We set the HTTP response status code base on “The OAuth 2.0 Authorization Framework”: https://datatracker.ietf.org/doc/html/rfc6749; sections Successful Response and Error Response.

How the Email-Password Login Process Works

👎 This is the area where I encountered the most difficulties while learning actix-web and actix-web middleware. Initially, I thought both the login and the request authentication processes should be in the same middleware. I attempted that approach, but it was unsuccessful. Eventually, I realised that login should be handled by an endpoint handler function. And request authentication should be managed by the middleware. In this context, the middleware is much like a Python decorator.

The email-password login process exclusively occurs in module src/auth_handlers.rs. In broad terms, this process involves two routes /api/login and /ui/login.

❶ The login process, /api/login handler.

The login process handler is pub async fn login(request: HttpRequest, app_state: web::Data<super::AppState>, body: Bytes) -> Either<impl Responder, HttpResponse>. It works as follows:

⓵ Attempt to extract the submitted log in information, a step discussed the fifth post above. If the extraction fails, it always returns a JSON response of ApiStatus with a code of 400 for BAD REQUEST. And that’s the end of the request.

⓶ Next, we use the submitted email to retrieve the target employee from the database. If there is no match, we call the helper function fn first_stage_login_error_response(request: &HttpRequest, message: &str) -> HttpResponse to handle the failure:

● If the request content type is application/json, we return a JSON response of ApiStatus with a code of 401 for UNAUTHORIZED. The value for the message field is set to the value of the parameter message.

● For the application/x-www-form-urlencoded content type, we set the server-side per-request cookie redirect-message and redirect to route /ui/login:

 ... HttpResponse::Ok() .status(StatusCode::SEE_OTHER) .append_header((header::LOCATION, "/ui/login")) // Note this per-request server-side only cookie. .cookie(build_login_redirect_cookie(&request, message)) .finish() ... 

We’ve previously described redirect-message. In the following section, we’ll cover the /ui/login handler.

● An appropriate failure response has been provided, and the request is completed.

⓷ An employee’s been found using an exact email match. The next step is to compare password.

The function fn match_password_response(request: &HttpRequest, submitted_login: &EmployeeLogin, selected_login: &EmployeeLogin) -> Result<(), HttpResponse> handles password comparison. It uses the argon2 crate to de-hash the database password and compare it to the submitted password. We’ve briefly discussed this process in the section On the Hard-Coded Value for employees.password.

● If the passwords don’t match, similar to step ⓶ above, we call the function fn first_stage_login_error_response(request: &HttpRequest, message: &str) -> HttpResponse to return an appropriate HTTP response.

● The passwords don’t match. An appropriate failure response has been provided, and the request is completed.

⓸ Email-password login has been successful. Now, we’re back in the endpoint handler for /api/login, pub async fn login(request: HttpRequest, app_state: web::Data<super::AppState>, body: Bytes) -> Either<impl Responder, HttpResponse>.

 ... // TO_DO: Work in progress -- future implementations will formalise access token. let access_token = &selected_login.email; // https://docs.rs/actix-identity/latest/actix_identity/ // Attach a verified user identity to the active session Identity::login(&request.extensions(), String::from(access_token)).unwrap(); // The request content type is "application/x-www-form-urlencoded", returns the home page. if request.content_type() == ContentType::form_url_encoded().to_string() { Either::Right( HttpResponse::Ok() // Note this header. .append_header((header::AUTHORIZATION, String::from(access_token))) // Note this client-side cookie. .cookie(build_authorization_cookie(&request, access_token)) .content_type(ContentType::html()) .body(render_home_page(&request)) ) } else { // The request content type is "application/json", returns a JSON content of // LoginSuccessResponse. // // Token field is the access token which the users need to include in the future // requests to get authenticated and hence access to protected resources. Either::Right( HttpResponse::Ok() // Note this header. .append_header((header::AUTHORIZATION, String::from(access_token))) // Note this client-side cookie. .cookie(build_authorization_cookie(&request, access_token)) .content_type(ContentType::json()) .body(login_success_json_response(&selected_login.email, &access_token)) ) } ... 

● The access_token is a work in progress. The main focus of this post is on the login and the request authentication processes. Setting the access_token to just the email is sufficient to get the entire process working, helping us understand how everything comes together better. We’ll refactor this to a more formal type of authentication later.

● The line Identity::login(&request.extensions(), String::from(access_token)).unwrap(); is taken directly from the actix-identity crate. I believe this allows the application to operate as an application server.

● 🚀 Note that for all responses, the access_token is set in both the authorization header and the authorization cookie. This is intended for clients usage, for example, in JavaScript. Clients have the option to extract and store this access_token for later use.

● 💥 Take note of this authorization header. It is only available to clients, for example, in JavaScript. The request authentication middleware also attempts to extract the access_token from this header, as explained earlier. This header is set explicitly by clients when making requests. While, at this point, it is a response header, and therefore, it will not be available again in later requests unless explicitly set.

And, finally:

● If the content type is application/x-www-form-urlencoded, we return the HTML home page as is.

● If the content type is application/json, we return a JSON serialisation of LoginSuccessResponse.

❷ The login page, /ui/login handler.

The login page handler is pub async fn login_page(request: HttpRequest) -> Either<impl Responder, HttpResponse>.

This route can be accessed in the following three ways:

⓵ Direct access from the browser address bar, the login page HTML gets served as is. This is a common use case. The request content type is blank.

⓶ Redirected to by the login process handler as already discussed. It should be apparent that when this handler is called, the server-side per-request cookie redirect-message has already been set. The presence of this cookie signifies that this handler is called after a fail login attempt. The value of the redirect-message cookie is included in the final response, and the HTTP response code is set to 401 for UNAUTHORIZED.

In this scenario, the request content type is available throughout the call stack.

⓷ Redirected to by src/auth_middleware.rs. This middleware is discussed in its own section titled How the Request Authentication Process Works.

At this point, we need to understand that, within the middleware, the closure redirect_to_route = |req: ServiceRequest, route: &str| -> Self::Future:

  • Always creates the server-side per-request original-content-type cookie, with its value being the original request content type.
  • If it redirects to /ui/login, then creates the server-side per-request redirect-message cookie with a value set to the constant UNAUTHORISED_ACCESS_MSG.

⓸ Back to the login page handler pub async fn login_page(request: HttpRequest) -> Either<impl Responder, HttpResponse>:

 ... let mut content_type: String = String::from(request.content_type()); let mut status_code = StatusCode::OK; let mut message = String::from(""); // Always checks for cookie REDIRECT_MESSAGE. if let Some(cookie) = request.cookie(REDIRECT_MESSAGE) { message = String::from(cookie.value()); status_code = StatusCode::UNAUTHORIZED; if let Some(cookie) = request.cookie(ORIGINAL_CONTENT_TYPE) { if content_type.len() == 0 { content_type = String::from(cookie.value()); } } } ... 

From section ⓶ and section ⓷, it should be clear that the presence of the server-side per-request redirect-message cookie indicates a redirect access. If the request content type is not available, we attempt to retrieve it from the server-side per-request original-content-type cookie.

Finally, it delivers the response based on the content type and removes both the redirect-message and original-content-type cookies. Note on the following code:

 ... else { Either::Left( ApiStatus::new(http_status_code(status_code)).set_message(&message) ) } ... 

We implement Responder trait for ApiStatus as described in the Response with custom type section of the official documentation.

How the Request Authentication Process Works

Now, let’s delve into the discussion of the request authentication middleware. Recall the definition of request authentication

The essential logic of this new middleware is to determine if a request is from an authenticated session, and then either pass the request through or redirect to an appropriate route.

This logic can be described by the following pseudocode:

Requests to “/favicon.ico” should proceed.

When Logged In
--------------

1. Requests to the routes “/ui/login” and “/api/login”
are redirected to the route “/ui/home”.

2. Requests to any other routes should proceed.

When Not Logged In
------------------

1. Requests to the routes “/ui/login” and “/api/login”
should proceed.

2. Requests to any other route are redirected to
the route “/ui/login”.
When Logged In
--------------

1. Requests to the routes “/ui/login” and “/api/login”
are redirected to the route “/ui/home”.

2. Requests to any other routes should proceed.

When Not Logged In
------------------

1. Requests to the routes “/ui/login” and “/api/login”
should proceed.

2. Requests to any other route are redirected to
the route “/ui/login”.

This logic should cover all future routes. Since this middleware is registered last, it means that all existing routes and potential future routes are protected by this middleware.

A pair of helper functions discribed below is responsible for managing the request authentication process.

The helper function fn extract_access_token(request: &ServiceRequest) -> Option<String> looks for the access token in:

  • The authorization header, which is set explicitly by clients when making requests.
  • If it isn’t in the header, we look for it in the identity managed by the actix-identity crate as described previously.
  • Note: we could also look in the authorization cookie, but this code has been commented out to focus on testing the identity functionality.

Function fn verify_valid_access_token(request: &ServiceRequest) -> bool is a work in progress. It calls the extract_access_token(...) function to extract the access token. If none is found, the request is not authenticated. If something is found, and it has a non-zero length, the request is considered authenticated. For the time being, this suffices to demonstrate the login and the request authentication processes. As mentioned previously, this will be refactored later on.

The next essential piece of functionality is the closure redirect_to_route = |req: ServiceRequest, route: &str| -> Self::Future, which must be described in an earlier section.

As discussed earlier, this closure also creates the server-side per-request original-content-type cookie. This cookie is so obscured. To help addressing the obscurities, the helper method that creates this cookie comes with extensive documentation explaining all scenarios where this cookie is required.

The Home Page and the Logout Routes

❶ The home page handler pub async fn home_page(request: HttpRequest) -> impl Responder is simple; it just delivers the HTML home page as is.

The home page HTML itself is also simple, without any CSS. It features a Logout button and other buttons whose event handler methods simply call the existing routes using AJAX, displaying responses in plain JavaScript dialogs.

The AJAX function, runAjaxEx(...), used by the home page, is also available on GitHub. It makes references to some Bootstrap CSS classes, but that should not be a problem for this example.

❷ There is also not much in the logout process handler, async fn logout(request: HttpRequest, user: Identity) -> impl Responder.

The code, especially user.logout(), is taken directly from the actix-identity crate.

The handler removes the application wide authorization cookie and redirects to the login page, delivering the HTML login page as is.

Integration Tests

Test and tests in this section mean integration test and integration tests, respectively.

Code has changed. Existing tests and some common test code must be updated. New tests are added to test new functionalities.

The application now uses cookies, all tests must enable cookie usage. We’ll also cover this in a later section.

❶ Common test code.

Now that an access_token is required to access protected routes. To log in every time to test is not always appropriate. We want to ensure that the code can extract the access_token from the authorization header.

I did look into the setup and tear down test setup in Rust. The intention is, in setup we’ll do a login, remember the access_token and use it in proper tests. In tear down, we log out. But this seems troublesome in Rust. I gave up on this idea.

Recall from this discussion that currently, anything that is non-blank is considered a valid access_token!

💥 We’ve settled on a compromise for this code revision: we will implement a method that returns a hard-coded access_token. As we proceed with the authentication refactoring, we’ll also update this method accordingly.

In the third post, we’ve incorporated tests following the approach outlined by Luca Palmieri in the 59-page sample extract of his book ZERO TO PRODUCTION IN RUST. Continuing with this approach, we’ll define a simple TestApp in tests/common.rs:

 pub struct TestApp { pub app_url: String, } impl TestApp { pub fn mock_access_token(&self) -> String { String::from("chirstian.koblick.10004@gmail.com") } } 

And spawn_app() now returns an instance of TestApp. We can then call the method mock_access_token() on this instance to use the hard-coded access_token.

❷ Enble cookies in tests.

We use the reqwest crate to send requests to the application. To enable cookies, we create a client using the builder method and chain to cookie_store(true):

 let client = reqwest::Client::builder() .cookie_store(true) .build() .unwrap(); 

❸ Existing tests.

All existing tests in tests/test_handlers.rs must be updated as outlined above, for example:

 async fn get_helloemployee_has_data() { let test_app = &spawn_app().await; let client = reqwest::Client::builder() .cookie_store(true) .build() .unwrap(); let response = client .get(make_full_url(&test_app.app_url, "/helloemployee/%chi/%ak")) .header(header::AUTHORIZATION, &test_app.mock_access_token()) .send() .await .expect("Failed to execute request."); assert_eq!(response.status(), StatusCode::OK); let res = response.text().await; assert!(res.is_ok(), "Should have a HTML response."); // This should now always succeed. if let Ok(html) = res { assert!(html.contains("Hi first employee found"), "HTML response error."); } } 

❹ New tests.

⓵ We have a new test module, tests/test_auth_handlers.rs, exclusively for testing the newly added authentication routes. There are a total of eleven tests, with eight dedicated to login and six focused on accessing existing protected routes without the authorization header set.

⓶ In the existing test module, tests/test_handlers.rs, we’ve added six more tests. These tests focused on accessing existing protected routes without the authorization header set. These test functions ended with _no_access_token.

These new tests should be self-explanatory. We will not go into detail.

Some Manual Tests

❶ Home page: demonstrating the project as an application server.

The gallery below shows the home page, and responses from some of the routes:

❷ While logged in, enter http://192.168.0.16:5000/data/employees/%chi/%ak in the browser address bar, we get the JSON response as expected:

Next, enter http://192.168.0.16:5000/ui/login directly in the browser address bar. This should bring us back to the home page.

❸ While not logged in, enter http://192.168.0.16:5000/data/employees/%chi/%ak directly in the browser address bar. This redirects us to the login page with an appropriate message:

❹ Attempt to log in with an incorrect email and/or password:

❺ Access the JSON response route http://192.168.0.16:5000/data/employees with the authorization header. This usage demonstrate the application as an API-like server or a service:

❻ Access http://192.168.0.16:5000/data/employees/%chi/%ak without the authorization header. While the successful response is in JSON, the request lacks a content type. Request authentication fails, the response is the HTML login page

❼ Access the same http://192.168.0.16:5000/data/employees/%chi/%ak with the authorization header. This should result in a successful JSON response as expected:

Rust Users Forum Helps

I received a lot of help from the Rust Users Forum while learning actix-web and Rust:

Some Current Issues

println! should be replaced with proper logging. I plan to implement logging to files later on.

❷ The fn first_stage_login_error_response(request: &HttpRequest, message: &str) -> HttpResponse helper function, discussed in this section, redirects requests to the route /ui/login; whose handler is capable of handling both application/x-www-form-urlencoded and application/json. And for that reason, this helper function could be refactored to:

 fn first_stage_login_error_response( request: &HttpRequest, message: &str ) -> HttpResponse {	HttpResponse::Ok()	.status(StatusCode::SEE_OTHER)	.append_header((header::LOCATION, "/ui/login"))	// Note this per-request server-side only cookie.	.cookie(build_login_redirect_cookie(&request, message))	.finish() } 

It seems logical, but it does not work when we log in using JSON with either an invalid email or password. The client tools simply report that the request could not be completed. I haven’t been able to work out why yet.

Concluding Remarks

I do apologise that this post is a bit too long. I can’t help it. I include all the details which I think are relevant. It has taken nearly two months for me to arrive at this point in the code. It is a significant learning progress for me.

We haven’t completed this project yet. I have several other objectives in mind. While I’m unsure about the content of the next post for this project, there will be one.

Thank you for reading. I hope you find this post useful. Stay safe, as always.

✿✿✿

Feature image source:

🦀 Index of the Complete Series.

Design a site like this with WordPress.com
Get started