Images Back End

Cover Page

DUE Wed, 11/06, 2 pm

We’ll start by learning how to configure the Chatter backend to store images and videos. We will not be storing these in the PostgreSQL database itself, but rather each image/video will be stored in its own file. In the PostgreSQL database, we will store the URLs pointing to these files.

Install updates

Every time you ssh to your server, you will see something like:

N updates can be applied immediately.

if N is not 0, run the following:

server$ sudo apt update
server$ sudo apt upgrade

If you see *** System restart required *** when you ssh to your server, please run:

server$ sync
server$ sudo reboot

Your ssh session will be ended at the server. Wait a few minutes for the system to reboot before you ssh to your server again.

Failure to update your packages could lead to your lab back end **not performing correctly** and also make you vulnerable to security hacks.

Modified Chatter API data formats

In this lab, we allow users to post an image and/or a video clip with their chatt. To that end, we will modify our back-end APIs and database to hold the new data.

As in the previous lab, the chatts retrieval API will send back all accumulated chatts in the form of a JSON object consisting of a dictionary entry with key "chatts" and value being an array of string arrays. In addition to the three elements "username", "message", and "timestamp", each string array now carries two additional elements which are the URLs where the image and video data are stored on the server:

{ 
    "chatts": [["username0", "message0", "id0", "timestamp0", "imageurl0", "videourl0"],
               ["username1", "message1", "id1", "timestamp1", "imageurl1", "videourl1"], 
               ... 
              ]
}

Each element of the array may have a value of JSON null or the empty string ("").

To post a chatt, the client correspondingly sends a JSON object with keys "username", "message", "image", and "video". The value of each key is a string (not urls!). If a chatt carries no image and/or video, since we’re using multipart/form-data encoding instead of JSON, the key "image" and/or "video" may be omitted or their values may be set to the empty string ("") or null. For example:

{	
   "username": "YOUR_UNIQNAME",	
   "message": "Hello world!",	
   "image": "",
   "video": ""
}

Database table

Add two new columns named imageurl of type text and videourl also of type text to the chatts table in your chatterdb database:

  ALTER TABLE chatts ADD COLUMN imageurl TEXT, ADD COLUMN videourl TEXT;

If you’re uncertain as to how to work with PostgreSQL, please review the Audio and Chatter labs’ back-end specs.

Chatterd back end mods

Due to the potentially large sizes of image and video files and to enable direct download of these files using image/video download libraries on the front end, both image and video data posted alongside chatt is not stored in the PostgreSQL database. Instead the data is stored in the back-end filesystem. The image/video files are considered static files. We store them in a designated media directory in the back end, served by a static-file server. The filename of the image/video file is turned into a URL, and the URL is then stored in the PostgreSQL database alongside the chatt’s username and message.

So as not to run up a bill on your cloud-based back end server, and for a bearable wait time when uploading, we will be limiting each image or video upload to 10 MB. On both Android and iOS front ends, the Google Photos app is the easiest way to determine the size of your image and video files.

:point_right:Create the media directory and set its access permissions:

server$ mkdir ~/441/chatterd/media
server$ chmod a+rx ~ ~/441 ~/441/chatterd ~/441/chatterd/media

Go with atreugo

Go with atreugo

Editing views.go

Add the following libraries to the import block at the top of the file:

import (
    //...
    "path"
    //...
    "github.com/valyala/fasthttp"
    //...
)

Now add two new properties to the Chatt struct in views/views.go:

type Chatt struct {
    // . . .
    ImageUrl  string    `json:"imageUrl"`
    VideoUrl  string    `json:"videoUrl"`
}

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

const MEDIA_ROOT = "/home/ubuntu/441/chatterd/media/"

func saveFormFile(c *atreugo.RequestCtx, username string, media string, ext string) string {
    content, err := c.FormFile(media)
    if err != nil { return "" }

	securename := path.Base(username)
	filename := securename + "-" + strconv.FormatInt(time.Now().Unix(), 10) + ext
    fasthttp.SaveMultipartFile(content, MEDIA_ROOT+filename)
    return "https://" + string(c.Host()) + "/media/" + filename
}

func PostImages(c *atreugo.RequestCtx) error {
    var chatt Chatt

    chatt.Username = string(c.FormValue("username"))
    chatt.Message = string(c.FormValue("message"))
    chatt.ImageUrl = saveFormFile(c, chatt.Username, "image", ".jpeg")
    chatt.VideoUrl = saveFormFile(c, chatt.Username, "video", ".mp4")

    _, err := chatterDB.Exec(ctx, `INSERT INTO chatts (username, message, id, imageurl, videourl) VALUES ($1, $2, gen_random_uuid(), $3, $4)`, chatt.
        Username, chatt.Message, chatt.ImageUrl, chatt.VideoUrl)
    if err != nil {
        LogHTTP(http.StatusInternalServerError, c)
        return c.JSONResponse([]byte(err.Error()), http.StatusInternalServerError)
    }

    LogHTTP(http.StatusOK, c)
    return c.JSONResponse(emptyJSON, http.StatusOK)
}

Make a copy of your GetChatts() function inside your views.go file and name the copy GetImages(). In GetImages(), replace the SELECT statement with the following: `SELECT username, message, id, time, COALESCE(imageurl, ''), COALESCE(videourl, '') FROM chatts ORDER BY time DESC`. This statement will retrieve all data, including our new image and video URLs from the PostgreSQL database. If the image or video URL is SQL NULL, it will be replaced with an empty string. Go’s PostgreSQL library doesn’t automatically translate SQL NULL to an empty string.

Still in GetImages(), replace the two lines in the for rows.Next() {} block with these:

        rows.Scan(&chatt.Username, &chatt.Message, &chatt.Id, &chatt.Timestamp, &chatt.ImageUrl, &chatt.VideoUrl)
        chattArr = append(chattArr, []any{chatt.Username, chatt.Message, chatt.Id, chatt.Timestamp, chatt.ImageUrl, chatt.VideoUrl})

In addition to the original three columns, we added reading the imageurl and videourl columns and included them in the chatt data returned to the front end.

Save and exit views.go.

Routing for new URLs

So that atreugo knows how to forward the new APIs to our the newly added GetImages() and PostImages() functions, add the following new routes to the routes array in router/router.go:

var routes = []Route {
    // . . .
    {"GET", "/getimages/", views.GetImages},
    {"POST", "/postimages/", views.PostImages},
}

To serve image/video static files, in the New() function add to atreugo.Config{, after Reuseport: true,, before the closing }):

        MaxRequestBodySize: 10485760,

to allow up to 10 MB per upload.

Also add in the New() function, before return router:

        router.Static("/media", views.MEDIA_ROOT)

This tells atreugo to redirect URL path /media to its static-file server and to serve media files from our designated media directory, whose value is stored in chatter.MEDIA_ROOT.

Save and exit router.go.

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

Rebuild chatterd:

server$ go get
server$ go build

and restart it:

server$ sudo systemctl restart chatterd

References

Python with Django

Python with Django

File size limit

By default, Django sets a maximum data upload size of 2.5 MB. To reconfigure Django to allow up to 10 MB per upload, and to specify where to store the media files, add the following lines to the end of your ~/441/chatterd/routing/settings.py:

MEDIA_URL = 'https://YOUR_SERVER_IP/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760

Replace YOUR_SERVER_IP with your server’s IP address.

We next tell Nginx where to look for the media file when presented with https://YOUR_SERVER_IP/media/. Since Nginx also limits maximum client upload size to 1 MB, we will raise this limit at the same time. Edit your Nginx website configuration file:

server$ sudo vi /etc/nginx/sites-enabled/chatterd

and add the following lines to the first server block (not the second, redirection block):

server {
    ...
    client_max_body_size 10M;
    location ^~ /media {
        alias /home/ubuntu/441/chatterd/media;
    }
}

Editing views.py

Now edit views.py to handle image and video uploads. First, add the following import, saveFormFile(), and postimages() functions to your views.py:

from django.core.files.storage import FileSystemStorage

def saveFormFile(file, username, ext):
    filename = username+str(time.time())+ext
    fs = FileSystemStorage()
    filename = fs.save(filename, file)
    return fs.url(filename)

@csrf_exempt
def postimages(request):
    if request.method != 'POST':
        return HttpResponse(status=400)

    # loading multipart/form-data
    username = request.POST.get("username")
    message = request.POST.get("message")

    imageurl = saveFormFile(request.FILES['image'], username, '.jpeg')\
        if 'image' in request.FILES else None
    videourl = saveFormFile(request.FILES['video'], username, '.mp4')\
        if 'video' in request.FILES else None
        
    with connection.cursor() as cursor:
        cursor.execute('INSERT INTO chatts (username, message, id, imageurl, videourl) VALUES '
                       '(%s, %s, gen_random_uuid(), %s, %s);', (username, message, imageurl, videourl))

    return JsonResponse({})

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

Save and exit views.py.

Routing for new URLs

So that Django knows how to forward the new APIs to the newly added getimages() and postimages() functions, add the following new routes to the urlpatterns array in urls.py:

    path('getimages/', views.getimages, name='getimages'),
    path('postimages/', views.postimages, name='postimages'),

Save and exit urls.py and restart both Nginx and Gunicorn:

server$ sudo nginx -t
server$ sudo systemctl restart nginx gunicorn

References

Rust with axum

Rust with axum

Editing handlers.rs

We edit handlers.rs to handle image and video uploads. First, add the following crates/modules in the use axum::{} block:

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::{Host, Multipart},
    // ...
};

and add the following as additional imported crates/modules:

use chrono::SecondsFormat;
use futures::{Stream, TryStreamExt};
use std::{io, path::Path };
use tokio::{fs::File, io::BufWriter};
use tokio_util::io::StreamReader;
use tower_http::BoxError;

As with the axum::extract module, you can “merge” the chrono and std modules to their respective existing imports, or leave them as separate lines.

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/441/chatterd/media";

pub async fn postimages(
    State(pgpool): State<PGPool>,
    ConnectInfo(clientIP): ConnectInfo<SocketAddr>,
    Host(host): Host,
    method: Method,
    uri: Uri,
    mut multipart: Multipart,
) -> (StatusCode, Json<Value>) {
    let mut username = String::new();
    let mut message = String::new();
    let mut imageurl: Option<String> = None;
    let mut videourl: Option<String> = None;

    let chatterDB = pgpool
        .get()
        .await
        .unwrap();

    while let Some(field) = multipart.next_field().await.unwrap_or(None) {
        let ext = if let Some(mimeType) = field.content_type() {
            Some(mimeType[6..].to_string())
        } else {
            None
        };

        match &*field.name().unwrap().to_string() {
            "username" => username.push_str(
                str::from_utf8(&field.bytes().await.map_or(Bytes::from(""), |s| s)).unwrap(),
            ),
            "message" => message.push_str(
                str::from_utf8(&field.bytes().await.map_or(Bytes::from(""), |s| s)).unwrap(),
            ),
            "image" => imageurl = saveFormFile(field, &username, &host, &ext.unwrap()).await,
            "video" => videourl = saveFormFile(field, &username, &host, &ext.unwrap()).await,
            &_ => unreachable!(),
        }
    }

    let dbStatus = chatterDB.execute(
        "INSERT INTO chatts (username, message, id, imageurl, videourl) VALUES ($1, $2, gen_random_uuid(), $3, $4)",
        &[&username, &message, &imageurl, &videourl])
        .await
        .map_or_else(|err| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!(err.to_string()))), |_| (StatusCode::OK, Json(json!({}))));

    tracing::debug!(
        "{:?} | {:?} | {:?} {:?}",
        dbStatus.0,
        clientIP,
        method,
        uri.path()
    );
    dbStatus
}

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

    if let Ok(_) = stream_to_file(filename, stream).await {
        Some(format!("https://{host}/media/{filename}"))
    } else {
        None
    }
}

// Save a `Stream` to a file
// from https://github.com/tokio-rs/axum/blob/main/examples/stream-to-file/src/main.rs
async fn stream_to_file<S, E>(path: &str, stream: S) -> Result<(), (StatusCode, String)>
where
    S: Stream<Item = Result<Bytes, E>>,
    E: Into<BoxError>,
{
    async {
        // Convert the stream into an `AsyncRead`.
        let body_with_io_error = stream.map_err(|err| io::Error::new(io::ErrorKind::Other, err));
        let body_reader = StreamReader::new(body_with_io_error);
        futures::pin_mut!(body_reader);

        // Create the file. `File` implements `AsyncWrite`.
        let path = Path::new(MEDIA_ROOT).join(path);
        let mut file = BufWriter::new(File::create(path).await?);

        // Copy the body into the file.
        tokio::io::copy(&mut body_reader, &mut file).await?;

        Ok::<_, io::Error>(())
    }
    .await
    .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
}

Make a copy of your getchatts() function inside your handlers.rs file and name the copy getimages(). In getimages(),

  1. replace the SELECT statement with the following: "SELECT username, message, id, time, imageurl, videourl FROM chatts ORDER BY time DESC". This statement will retrieve all data, including our new image and video URLs from the PostgreSQL database.

  2. replace the chattArr.push(/*...*/); line with:

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

    In addition to the original three 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.

Save and exit handlers.rs.

Routing for new URLs

First, add the following crates/modules in the use block:

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, 
    // ...
};

then add the following crate:

use tower_http::{
    limit::RequestBodyLimitLayer,
    services::ServeDir,
};

So that axum knows how to forward the new APIs to the newly added getimages() and postimages() functions, add the following to the Router instantiation of the router variable in main.rs, before the .with_state(pgpool); line:

    .route("/getimages/", get(handlers::getimages))
    .route("/postimages/", post(handlers::postimages))
    .nest_service("/media",
        ServeDir::new(handlers::MEDIA_ROOT)
            .not_found_service(handle_404.into_service()))
    .layer(DefaultBodyLimit::disable())  // first disable default 2 MB limit
    .layer(RequestBodyLimitLayer::new(10485760)) // then add 10 MB limit            

In addition to routing the two new APIs, the next line 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. We then 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.

The struct ServeDir() uses the handle_404() to handle file not found cases. We now provide this function:

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

Save and exit main.rs.

Finally, edit Cargo.toml to replace the axum line in the dependencies block with:

axum = { version="0.7.3", features = ["multipart"] }

and add the following dependencies:

futures = "0.3.30"
tokio-util = { version="0.7.10", features = ["io"] }
tower-http = { version = "0.5.0", features = ["fs", "limit", "trace"] }

Save and exit Cargo.toml. Rebuild and restart chatterd.

server$ cargo build --release
server$ sudo systemctl restart chatterd

References

Testing image and video upload

To test your backend with Insomnia, configure the header and body of your POST request like the following:

  1. Create POST request in Insomnia.

    If you have the Python/Django back end, be sure to include the trailing / in postimages/. Django cannot carry POSTed data when redirecting from postimages to postimages/.

  2. In the dropdown menu from the chatter lab that you previously used to select JSON as the data format (under the POST dropdown), select Multipart instead. Enter a text value for username and message as was done in the chatter lab. Then add files for image and video. First add “image” or “video” as key. Then click the arrow to the right of the value field. Choose “File” and upload your .jpeg or .mp4 files (screenshot).

    If you want to send a chatt with no image and/or video, you can simply omit adding the key "image" and/or "video". Or you may add these keys but set their values to either the empty string ("") or null. In Insomnia, to set the value to the empty string, set the field type to Text in the drop-down arrow, to the right of the value field, which is then left empty. To set it to null, set the field type to File but don’t select any file.

  3. Click Send.

  4. Next do a getimages/ request within Insomnia as you did in the chatter lab. You should see the image and video URLs associated with your chatt above in the returned JSON.

    Stream error in HTTP/2 framing layer

    Both the getchatts/ and getimages/ Chatter API specify an empty payload. If you send your GET request in Insomnia with a non-empty Multipart body and you’re running the Rust/axum server, the server will report, Error: Stream error in the HTTP/2 framing layer.

    To correct this error, set the GET request body to empty in Insomia. Or you can ignore this error when you know it’s due to malformed GET requests in Insomnia. Rust/axum server is more strict in reporting non-conformance.

  5. You can copy the URLs returned and do another GET request on these URLs, allowing you to see the image/video in Insomnia.

And you’re done with the back end!

Submission guideline

Once you are done with the back end, we’ll move on to the front end.


Prepared for EECS 441 by Wendan Jiang, Tianyi Zhao, Ollie Elmgren, Benjamin Brengman, Mark Wassink, Alexander Wu, Yibo Pi, and Sugih Jamin Last updated: September 9th, 2024