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.
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 chatt
s 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.
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
.
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()
,
-
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. -
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
andvideourl
columns and included them in thechatt
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:
- Create
POST
request in Insomnia.If you have the Python/Django back end, be sure to include the trailing
/
inpostimages/
. Django cannot carry POSTed data when redirecting frompostimages
topostimages/
. -
In the dropdown menu from the
chatter
lab that you previously used to select JSON as the data format (under thePOST
dropdown), selectMultipart
instead. Enter a text value forusername
andmessage
as was done in thechatter
lab. Then add files forimage
andvideo
. 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 (""
) ornull
. In Insomnia, to set the value to the empty string, set the field type toText
in the drop-down arrow, to the right of the value field, which is then left empty. To set it tonull
, set the field type toFile
but don’t select any file. -
Click
Send
. -
Next do a
getimages/
request within Insomnia as you did in thechatter
lab. You should see the image and video URLs associated with yourchatt
above in the returned JSON.Stream error in HTTP/2 framing layer
Both the
getchatts/
andgetimages/
Chatter API specify an empty payload. If you send yourGET
request in Insomnia with a non-emptyMultipart
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 malformedGET
requests in Insomnia. Rust/axum server is more strict in reporting non-conformance. - 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
- Update your repo with:
server$ cd ~/441/chatterd
If you have a Python-based back end, run:
server$ cp /etc/nginx/sites-enabled/chatterd etc-chatterd
In all cases, commit and push changes to your GitHub repo:
server$ git commit -am "images back end" server$ git push
- If
git push
fails due to new changes made to the remote repo, you will need to rungit pull
first. Then you may have to resolve any conflicts before you cangit push
again.
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 |