Cover Page

Backend Page

Rust with axum

Change to your chatterd folder and edit the file Cargo.toml:

server$ cd ~/reactive/chatterd 
server$ vi Cargo.toml

to replace the axum line in the dependencies block with:

axum = { version="0.8.4", features = ["macros", "multipart"] }

and add the following dependencies, replacing the tower-http line with the one here that imports additional features:

futures = "0.3.31"
tokio-util = { version="0.7.14", features = ["io"] }
tower-http = { version = "0.6.2", features = ["fs", "limit", "normalize-path","trace"] }

Save and exit Cargo.toml.

handlers.rs

Edit the file src/handlers.rs:

server$ vi src/handlers.rs

Add the following crates/modules in their respective existing blocks at the top of the file:

use axum::{
    body::Bytes, 
    // you can choose to "merge" the following with the existing
    // `extract` module or add it as a separate line. Both work.
    extract::{ Multipart, },
    // ...
};
use axum_extra::extract::Host;
use chrono::{ SecondsFormat, };
use futures::{ pin_mut, TryStreamExt, };
use std::{
    io, 
    io::{Error, ErrorKind}, 
    path::{Path, PathBuf},
    str,
};
use tokio::{ fs::File, io::{ copy, BufWriter, }, };

and add the following as additional imported crates/modules:

use tokio_util::io::StreamReader;
use tower_http::BoxError;

Next add the postimages() function along with the saveFormFile() and stream_to_file() helper functions. We also specify our designated media directory to store image/video files.

pub const MEDIA_ROOT: &str = "/home/ubuntu/reactive/chatterd/media";

pub async fn postimages(
    State(appState): State<AppState>,
    ConnectInfo(clientIP): ConnectInfo<SocketAddr>,
    Host(host): Host,
    mut form: Multipart,
) -> Result<Json<Value>, (StatusCode, String)> {
    let mut username = String::new();
    let mut message = String::new();
    let mut imageurl: Option<String> = None;
    let mut videourl: Option<String> = None;

    let chatterDB = appState
        .pgpool
        .get()
        .await
        .map_err(|err| logServerErr(clientIP, err.to_string()))?;

    while let Some(field) = form.next_field().await.unwrap_or(None) {
        let ext = field
            .content_type()
            .map(|mimeType| mimeType[6..].to_string());

        match &*field.name().unwrap_or_default() {
            "username" => username.push_str(
                str::from_utf8(&field.bytes().await.map_or(Bytes::from("unknown"), |s| s)).unwrap(),
            ),
            "message" => message.push_str(
                str::from_utf8(&field.bytes().await.map_or(Bytes::from(""), |s| s)).unwrap(),
            ),
            "image" => {
                imageurl = Some(
                    saveFormFile(field, &username, &host, &ext.unwrap_or("jpeg".to_string()))
                        .await
                        .map_err(|err| {
                            logClientErr(
                                clientIP,
                                StatusCode::UNPROCESSABLE_ENTITY,
                                err.to_string(),
                            )
                        })?,
                )
            }
            "video" => {
                videourl = Some(
                    saveFormFile(field, &username, &host, &ext.unwrap_or("mp4".to_string()))
                        .await
                        .map_err(|err| {
                            logClientErr(
                                clientIP,
                                StatusCode::UNPROCESSABLE_ENTITY,
                                err.to_string(),
                            )
                        })?,
                )
            }
            &_ => unreachable!(),
        }
    }

    chatterDB
        .execute(
            "INSERT INTO chatts (username, message, id, imageurl, videourl) VALUES ($1, $2, gen_random_uuid(), $3, $4)",
            &[&username, &message, &imageurl, &videourl],
        )
        .await
        .map_err(|err| logClientErr(clientIP, StatusCode::NOT_ACCEPTABLE, err.to_string()))?;

    logOk(clientIP);
    Ok(Json(json!({})))
}

async fn saveFormFile<S, E>(stream: S, username: &str, host: &str, ext: &str) -> io::Result<String>
where
    S: Stream<Item = Result<Bytes, E>>,
    E: Into<BoxError>,
{
    let postTime = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
    let securename = Path::new(username)
        .file_stem()
        .and_then(|stem| { stem.to_str() })
        .ok_or_else(|| Error::from(ErrorKind::InvalidData))?;
    let filename = &*format!("{securename}-{postTime}.{ext}");

    stream_to_file(filename, stream)
        .await
        .map(|filename| format!("https://{host}/media/{filename}"))
}

async fn stream_to_file<S, E>(filename: &str, stream: S) -> io::Result<&str>
where
    S: Stream<Item = Result<Bytes, E>>,
    E: Into<BoxError>,
{
    let body_with_io_error = stream.map_err(|err| Error::new(ErrorKind::Other, err));
    let body_reader = StreamReader::new(body_with_io_error);
    pin_mut!(body_reader);

    let mut filepath = PathBuf::from(MEDIA_ROOT);
    filepath.push(filename);
    let mut file = BufWriter::new(
        File::create(
            filepath
                .to_str()
                .ok_or_else(|| Error::from(ErrorKind::PermissionDenied))?,
        )
        .await?,
    );

    copy(&mut body_reader, &mut file).await?;

    Ok(filename)
}

Next, make a copy of your getchatts() function inside your handlers.rs file and name the copy getimages(). In getimages(), replace the SELECT statement with the following: "SELECT username, message, id, time, imageurl, videourl FROM chatts". This statement will retrieve all data, including our new image and video URLs from the PostgreSQL database.

Still in getimages(), replace the chattArr.push(/*...*/); line with:

            chattArr.push(vec![
                row.get("username"),
                row.get("message"),
                Some(row.get::<&str, Uuid>("id").to_string()),                
                Some(row.get::<&str, DateTime<Local>>("time").to_string()),
                row.get("imageurl"),
                row.get("videourl")
            ]);   

In addition to the original columns, which we now access by column name instead of by index, we added reading the imageurl and videourl columns and included them in the chatt data returned to the front end.

We’re done with handlers.rs. Save and exit the file.

main.rs

Edit the file src/main.rs:

server$ vi src/main.rs

Add the following modules at the top of your src/main.rs to their respective blocks:

use axum::{
    // you can choose to "merge" the following with the existing
    // `extract` module or add it as a separate line. Both work.
    // If your merge list has more than one elements, put both inside a pair of parantheses.
    extract::{ DefaultBodyLimit, },
    handler::HandlerWithoutStateExt,
    http::StatusCode,
    // ...
};
use tower_http::{
    limit::RequestBodyLimitLayer,
    services::ServeDir,
};

Find the router = Router::new() instantiation statement and add the following new routes for the new APIs /getimages and /postimages, right after the call to Router::new():

let router = Router::new() // look for this line and add the following right below the line
    .route("/postimages", post(handlers::postimages))
    .layer(DefaultBodyLimit::disable())          // first disable default 2 MB limit
    .layer(RequestBodyLimitLayer::new(10485760)) // then add 10 MB limit       
    .route("/getimages", get(handlers::getimages))
    .nest_service("/media",
        ServeDir::new(handlers::MEDIA_ROOT)
            .not_found_service(handle_not_found.into_service()))

For postimages(), we limit the size of each uploaded media file to 10 MB. To set this limit, we must first disable the default limit of 2MB/upload.

In addition to routing the two new APIs, the .nest_service line tells axum to redirect URL path /media to serve media files from our designated media directory, whose value is stored in handlers::MEDIA_ROOT. If the requested file is not found at the MEDIA_ROOT path, we call handle_not_found() to inform the user. Add the following outside your main() function:

async fn handle_not_found() -> (StatusCode, &'static str) {
    (StatusCode::NOT_FOUND, "Not found")
}

We’re done with main.rs. Save and exit the file.

Build and test run

To build your server:

server$ cargo build --release

:point_right:Rust is a compiled language, like C/C++ and unlike Python, which is an interpreted language. This means you must run cargo build each and every time you made changes to your code, for the changes to show up in your executable.

To run your server:

server$ sudo ./chatterd
# Hit ^C to end the test

The cover backend spec provides instructions for Testing audio upload.

References


Prepared by Sugih Jamin Last updated August 31st, 2025