18 KiB
Pastebin Tutorial
This section of the guide is a tutorial intended to demonstrate how real-world
Rocket applications are crafted. We'll build a simple pastebin service that
allows users to upload a file from any HTTP client, including curl
. The
service will respond back with a URL to the uploaded file.
! note: What's a pastebin?
A pastebin is a simple web application that allows users to upload a document and later retrieve it via a special URL. They're often used to share code snippets, configuration files, and error logs.
Finished Product
A souped-up, completed version of the application you're about to build is
deployed live at paste.rs. Feel free to play with the
application to get a feel for how it works. For example, to upload a text
document named test.txt
, you can run:
curl --data-binary @test.txt https://paste.rs/
# => https://paste.rs/IYu
The finished product is composed of the following routes:
-
index
-#[get("/")]
returns a simple HTML page with instructions about how to use the service
-
upload
-#[post("/")]
accepts raw data in the body of the request and responds with a URL of a page containing the body's content
-
retrieve
-#[get("/<id>")]
retrieves the content for the paste with id
<id>
Getting Started
Let's get started! First, create a fresh Cargo binary project named
rocket-pastebin
:
cargo new --bin rocket-pastebin
cd rocket-pastebin
Then add the usual Rocket dependencies to the Cargo.toml
file:
[dependencies]
rocket = "=0.5.0-rc.3"
And finally, create a skeleton Rocket application to work off of in
src/main.rs
:
#[macro_use] extern crate rocket;
#[launch]
fn rocket() -> _ {
rocket::build()
}
Ensure everything works by running the application:
cargo run
At this point, we haven't declared any routes or handlers, so visiting any page will result in Rocket returning a 404 error. Throughout the rest of the tutorial, we'll create the three routes and accompanying handlers.
Index
The first route we'll create is index
. This is the page users will see when
they first visit the service. As such, the route should handle GET /
. We
declare the route and its handler by adding the index
function below to
src/main.rs
:
# #[macro_use] extern crate rocket;
#[get("/")]
fn index() -> &'static str {
"
USAGE
POST /
accepts raw data in the body of the request and responds with a URL of
a page containing the body's content
GET /<id>
retrieves the content for the paste with id `<id>`
"
}
This declares the index
route for requests to GET /
as returning a static
string with the specified contents. Rocket will take the string and return it as
the body of a fully formed HTTP response with Content-Type: text/plain
. You
can read more about how Rocket formulates responses in the responses section
of the guide or at the API documentation for the Responder
trait.
Remember that routes first need to be mounted before Rocket dispatches requests
to them. To mount the index
route, modify the main function so that it reads:
# #[macro_use] extern crate rocket;
# #[get("/")] fn index() { }
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![index])
}
You should now be able to cargo run
the application and visit the root path
(/
) to see the text.
Design
Before we continue, we'll need to make a few design decisions.
-
Where should pastes be stored?
To keep things simple, we'll store uploaded pastes on the file system inside of an
upload/
directory. Let's create that directory next tosrc/
in our project now:mkdir upload
Our project tree now looks like:
. ├── Cargo.toml ├── src │ └── main.rs └── upload
-
What should we name the uploaded paste files?
Similarly, we'll keep things simple by naming paste files a string of random but readable characters. We'll call this random string the paste's "ID". To represent, generate, and store the ID, we'll create a
PasteId
structure in a new module file namedpaste_id.rs
with the following contents:use std::borrow::Cow; use std::path::{Path, PathBuf}; use rand::{self, Rng}; /// A _probably_ unique paste ID. pub struct PasteId<'a>(Cow<'a, str>); impl PasteId<'_> { /// Generate a _probably_ unique ID with `size` characters. For readability, /// the characters used are from the sets [0-9], [A-Z], [a-z]. The /// probability of a collision depends on the value of `size` and the number /// of IDs generated thus far. pub fn new(size: usize) -> PasteId<'static> { const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; let mut id = String::with_capacity(size); let mut rng = rand::thread_rng(); for _ in 0..size { id.push(BASE62[rng.gen::<usize>() % 62] as char); } PasteId(Cow::Owned(id)) } /// Returns the path to the paste in `upload/` corresponding to this ID. pub fn file_path(&self) -> PathBuf { let root = concat!(env!("CARGO_MANIFEST_DIR"), "/", "upload"); Path::new(root).join(self.0.as_ref()) } }
We've given you the ID and path generation code for free. Our project tree now looks like:
. ├── Cargo.toml ├── src │ ├── main.rs │ └── paste_id.rs # new! contains `PasteId` └── upload
We'll import the new module and struct in
src/main.rs
, after theextern crate rocket
:# /* mod paste_id; # */ mod paste_id { pub struct PasteId; } use paste_id::PasteId;
You'll notice that our code to generate paste IDs uses the
rand
crate, so we'll need to add it as a dependency in ourCargo.toml
file:[dependencies] ## existing Rocket dependencies... rand = "0.8"
Ensure that your application builds with the new code:
cargo build
You'll likely see many "unused" warnings for the new code we've added: that's okay and expected. We'll be using the new code soon.
With these design decisions made, we're ready to continue writing our application.
Retrieving Pastes
We'll proceed with a retrieve
route which, given an <id>
, will return the
corresponding paste if it exists or otherwise 404. As we now know, that
means we'll be reading the contents of the file corresponding to <id>
in the
upload/
directory and return them to the user.
Here's a first take at implementing the retrieve
route. The route below takes
in an <id>
as a dynamic path element. The handler uses the id
to construct a
path to the paste inside upload/
, and then attempts to open the file at that
path, optionally returning the File
if it exists. Rocket treats a None
Responder
as a 404 error, which is exactly what we want to return when the requested
paste doesn't exist.
# #[macro_use] extern crate rocket;
use std::path::Path;
use rocket::tokio::fs::File;
#[get("/<id>")]
async fn retrieve(id: &str) -> Option<File> {
let upload_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/", "upload");
let filename = Path::new(upload_dir).join(id);
File::open(&filename).await.ok()
}
Make sure that the route is mounted at the root path:
# #[macro_use] extern crate rocket;
# #[get("/")] fn index() {}
# #[get("/<id>")] fn retrieve(id: String) {}
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![index, retrieve])
}
Give it a try! Create some fake pastes in the upload/
directory, run the
application, and try to retrieve them by visiting the corresponding URL.
A Problem
Unfortunately, there's a problem with this code. Can you spot the issue? The
&str
type in retrieve
should tip you off! We've crafted a wonderful type to
represent paste IDs but have ignored it!
The issue is that the user controls the value of id
, and as a result, can
coerce the service into opening files inside upload/
that aren't meant to be
opened. For instance, imagine that you later decide that a special file
upload/_credentials.txt
will store some important, private information. If the
user issues a GET
request to /_credentials.txt
, the server will read and
return the upload/_credentials.txt
file, leaking the sensitive information.
This is a big problem; it's known as the full path disclosure
attack, and Rocket
provides the tools to prevent this and other kinds of attacks from happening.
The Solution
To prevent the attack, we need to validate id
before we use it. We do so by
using a type more specific than &str
to represent IDs and then asking Rocket
to validate the untrusted id
input as that type. If validation fails, Rocket
will take care to not call our routes with bad input.
Typed validation for dynamic parameters like id
is implemented via the
FromParam
trait. Rocket uses FromParam
to automatically validate and parse
dynamic path parameters like id
. We already have a type that represents valid
paste IDs, PasteId
, so we'll simply need to implement FromParam
for
PasteId
.
Here's the FromParam
implementation for PasteId
in src/paste_id.rs
:
use rocket::request::FromParam;
# use std::borrow::Cow;
# pub struct PasteId<'a>(Cow<'a, str>);
/// Returns an instance of `PasteId` if the path segment is a valid ID.
/// Otherwise returns the invalid ID as the `Err` value.
impl<'a> FromParam<'a> for PasteId<'a> {
type Error = &'a str;
fn from_param(param: &'a str) -> Result<Self, Self::Error> {
param.chars().all(|c| c.is_ascii_alphanumeric())
.then(|| PasteId(param.into()))
.ok_or(param)
}
}
! note: This implementation, while secure, could be improved.
Our from_param
function is simplistic and could be improved by, for example,
checking that the length of the id
is within some known bound, introducing
stricter character checks, checking for the existing of a paste file, and/or
potentially blacklisting sensitive files as needed.
Given this implementation, we can change the type of id
in retrieve
to
PasteId
. Rocket will then ensure that <id>
represents a valid PasteId
before calling the retrieve
route, preventing the previous attack entirely:
# #[macro_use] extern crate rocket;
use rocket::tokio::fs::File;
# use std::borrow::Cow;
# use std::path::PathBuf;
# use rocket::request::FromParam;
# pub struct PasteId<'a>(Cow<'a, str>);
# impl PasteId<'_> {
# pub fn new(size: usize) -> PasteId<'static> { todo!() }
# pub fn file_path(&self) -> PathBuf { todo!() }
# }
# impl<'a> FromParam<'a> for PasteId<'a> {
# type Error = &'a str;
# fn from_param(param: &'a str) -> Result<Self, Self::Error> { todo!() }
# }
#[get("/<id>")]
async fn retrieve(id: PasteId<'_>) -> Option<File> {
File::open(id.file_path()).await.ok()
}
Notice how much nicer this implementation is! And this time, it's secure.
The wonderful thing about using FromParam
and other Rocket traits is that they
centralize policies. For instance, here, we've centralized the policy for valid
PasteId
s in dynamic parameters. At any point in the future, if other routes
are added that require a PasteId
, no further work has to be done: simply use
the type in the signature and Rocket takes care of the rest.
Uploading
Now that we can retrieve pastes safely, it's time to actually store them. We'll
write an upload
route that, according to our design, takes a paste's contents
and writes them to a file with a randomly generated ID inside of the upload/
directory. It'll return a URL to the client for the paste corresponding to the
retrieve
route we just wrote.
Streaming Data
To stream the incoming paste data to a file, we'll make use of Data
, a data
guard that represents an unopened stream to the incoming request body data.
Before we show you the code, you should attempt to write the route yourself.
Here's a hint: one possible route and handler signature look like this:
# #[macro_use] extern crate rocket;
use rocket::Data;
#[post("/", data = "<paste>")]
async fn upload(paste: Data<'_>) -> std::io::Result<String> {
/* .. */
# Ok("".into())
}
Your code should:
- Create a new
PasteId
of a length of your choosing. - Construct a path to the
PasteId
inside ofupload/
. - Stream the
Data
to the file at the constructed path. - Construct a URL for the
PasteId
. - Return the URL to the client.
Solution
Here's our version:
# #[macro_use] extern crate rocket;
// We derive `UriDisplayPath` for `PasteId` in `paste_id.rs`:
# use std::borrow::Cow;
# use std::path::{Path, PathBuf};
# use rocket::request::FromParam;
#[derive(UriDisplayPath)]
pub struct PasteId<'a>(Cow<'a, str>);
# impl PasteId<'_> {
# pub fn new(size: usize) -> PasteId<'static> { todo!() }
# pub fn file_path(&self) -> PathBuf { todo!() }
# }
#
# impl<'a> FromParam<'a> for PasteId<'a> {
# type Error = &'a str;
# fn from_param(param: &'a str) -> Result<Self, Self::Error> { todo!() }
# }
// We implement the `upload` route in `main.rs`:
use rocket::data::{Data, ToByteUnit};
use rocket::http::uri::Absolute;
# use rocket::tokio::fs::File;
// In a real application, these would be retrieved dynamically from a config.
const ID_LENGTH: usize = 3;
const HOST: Absolute<'static> = uri!("http://localhost:8000");
# #[get("/")] fn index() -> &'static str { "" }
# #[get("/<id>")] fn retrieve(id: PasteId<'_>) -> Option<File> { todo!() }
#[post("/", data = "<paste>")]
async fn upload(paste: Data<'_>) -> std::io::Result<String> {
let id = PasteId::new(ID_LENGTH);
paste.open(128.kibibytes()).into_file(id.file_path()).await?;
Ok(uri!(HOST, retrieve(id)).to_string())
}
We note the following Rocket APIs being used in our implementation:
- The
kibibytes()
method, which comes from theToByteUnit
trait. Data::open()
to openData
as aDataStream
.DataStream::into_file()
for writing the data stream into a file.- The
UriDisplayPath
derive, allowingPasteId
to be used inuri!
. - The
uri!
macro to crate type-safe, URL-safe URIs.
Ensure that the route is mounted at the root path:
# #[macro_use] extern crate rocket;
# #[get("/")] fn index() {}
# #[get("/<id>")] fn retrieve(id: &str) {}
# #[post("/")] fn upload() {}
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![index, retrieve, upload])
}
Test that your route works via cargo run
. From a separate terminal, upload a
file using curl
then retrieve the paste using the returned URL.
## in the project root
cargo run
## in a separate terminal
echo "Hello, Rocket!" | curl --data-binary @- http://localhost:8000
## => http://localhost:8000/eGs
## confirm we can retrieve the paste (replace with URL from above)
curl http://localhost:8000/eGs
## we can check the contents of `upload/` as well
<ctrl-c> # kill running process
ls upload # ensure the upload is there
cat upload/* # ensure that contents are correct
Conclusion
That's it! Ensure that all of your routes are mounted and test your application. You've now written a simple (~75 line!) pastebin in Rocket! There are many potential improvements to this small application, and we encourage you to work through some of them to get a better feel for Rocket. Here are some ideas:
- Add a web form to the
index
where users can manually input new pastes. Accept the form atPOST /
. Useformat
and/orrank
to specify which of the twoPOST /
routes should be called. - Support deletion of pastes by adding a new
DELETE /<id>
route. UsePasteId
to validate<id>
. - Indicate partial uploads with a 206 partial status code. If the user uploads a paste that meets or exceeds the allowed limit, return a 206 partial status code. Otherwise, return a 201 created status code.
- Set the
Content-Type
of the return value inupload
andretrieve
totext/plain
. - Return a unique "key" after each upload and require that the key is present and matches when doing deletion. Use one of Rocket's core traits to do the key validation.
- Add a
PUT /<id>
route that allows a user with the key for<id>
to replace the existing paste, if any. - Add a new route,
GET /<id>/<lang>
that syntax highlights the paste with ID<id>
for language<lang>
. If<lang>
is not a known language, do no highlighting. Possibly validate<lang>
withFromParam
. - Use the
local
module to write unit tests for your pastebin. - Dispatch a thread before
launch
ing Rocket inmain
that periodically cleans up idling old pastes inupload/
.
You can find the full source code for the completed pastebin tutorial on GitHub.