Search code examples
rustdownloadxlsxrust-rocket

Rust + rocket: Return file using `NamedFile` gives wrong file extension


Using xlsxwriter, I have created an .xlsx file ( fn create_workbook()) that I want to send to the client using rocket. Downloading works, but the extension is wrong. Ideally, the file should be deleted after the client has downloaded it.

Creating the file works (it's stored under target/simple1.xlsx), however, the file that gets downloaded upon visiting http://127.0.0.1:8000 is a .dms file rather than a .xlsx file. Apparently, the problem is only the file name (and hence, also the extension): If one changes the extension of the downloaded file to .xlsx, it is the desired document.

Also, I don't know how to delete the file after it has been downloaded from the server, since the function will be exited right after it has been downloaded. (I'm new to rust and also happy about any code recommendations)

use std::fs;
use std::path;
use rocket::{
    response::status::NotFound,
    fs::{FileServer,
         NamedFile}
};
use xlsxwriter::Workbook;

#[macro_use] extern crate rocket;

// create an xlsx workbook at "target/simple1.xlsx"
fn create_workbook()->Result<Vec<u8>,xlsxwriter::XlsxError>{
    let workbook = Workbook::new("target/simple1.xlsx");
    let mut sheet1 = workbook.add_worksheet(None)?;
    sheet1.write_string(2,1,"Hello, world!",None)?;
    workbook.close().expect("workbook can be closed");
    let result = fs::read("target/simple1.xlsx").expect("can read file");
    Ok(result)
}


#[get("/")]
async fn index() -> NamedFile  {
    let file = create_workbook().expect("file can be created");
    let path_to_file = path::Path::new("target/simple1.xlsx");
    let res = NamedFile::open(&path_to_file).await.map_err(|e| NotFound(e.to_string()));
    match res {
        Ok(file) => file,
        Err(error) => panic!("Problem with file {:?}", error),
    }
    // ideally, the file would be deleted after it has been sent to the client
    // fs::remove_file("target/simple1.xlsx").expect("can delete file");
}

#[launch]
fn rocket() -> _ {
    let rocket= rocket::build();
    rocket
        .mount("/", routes![index])
}

The Cargo.toml file is:

[package]
name = "demo_download_xlsx_rust"
version = "0.1.0"
edition = "2021"

[dependencies]
xlsxwriter = "0.4.0"

[dependencies.rocket]
version = "0.5.0-rc.2"

I followed the Response guide as closely as possible (doc). I also tried returning a Result<NamedFile, NotFound<String>>.


Solution

  • Looking in the documentation for how NamedFile implements Responder, we find:

    Streams the named file to the client. Sets or overrides the Content-Type in the response according to the file’s extension if the extension is recognized. See ContentType::from_extension() for more information. If you would like to stream a file with a different Content-Type than that implied by its extension, use a File directly.

    And looking at ContentType::from_extension() it does not recognize ".xlsx" as a known extension. So it does not set a proper content-type header and thus your computer tries its best to figure out the file type without it; I'm guessing ".dms" files have some structural similarity to ".xlsx" files.

    From this question, What is a correct MIME type for .docx, .pptx, etc.?, the content-type should be "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet". You can set a specific content-type using ContentType alongside your file like so:

    #[get("/")]
    async fn index() -> (ContentType, NamedFile) {
        // ... create the file ...
    
        let content_type = ContentType::new("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        let file = NamedFile::open("target/simple1.xlsx").await.unwrap();
    
        (content_type, file)
    }
    

    This will now indicate to your browser that it is an ".xlsx" file and should save it as such.


    I'll follow up by mentioning that while the extension now matches what is expected, the name of the file is still left up to the browser's discretion (often either a random name or one based on the route). If you want to be able to set the file name as well, you need to return a content-disposition header. Unfortunately, there's not a quick-and-handy Rocket type for that, so you'd have to implement it yourself.

    Here's an example of how to do that:

    use std::fs::File;
    use std::path::{Path, PathBuf};
    
    use rocket::response::{Responder, Result};
    use rocket::Request;
    
    pub struct NamedXlsxFile {
        name: PathBuf,
        file: File,
    }
    
    impl NamedXlsxFile {
        pub fn new(path: impl AsRef<Path>) -> NamedXlsxFile {
            // TODO: error propagation
    
            let path = path.as_ref();
            let name = path.file_name().unwrap().into();
            let file = File::open(path).unwrap();
    
            NamedXlsxFile { name, file }
        }
    }
    
    impl<'r> Responder<'r, 'static> for NamedXlsxFile {
        fn respond_to(self, request: &'r Request<'_>) -> Result<'static> {
            use rocket::http::hyper::header::*;
    
            let content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
            let content_disposition = format!("attachment; filename=\"{}\"", self.name.display());
    
            let mut response = self.file.respond_to(request)?;
            response.set_raw_header(CONTENT_TYPE.as_str(), content_type);
            response.set_raw_header(CONTENT_DISPOSITION.as_str(), content_disposition);
    
            Ok(response)
        }
    }
    
    #[get("/")]
    async fn index() -> NamedXlsxFile {
        // ... create the file ...
    
        let file = create_workbook().expect("file can be created");
        let file = NamedXlsxFile::new("target/simple1.xlsx");
    
        file
    }