carllerche / tower-web

A fast, boilerplate free, web framework for Rust
MIT License
980 stars 51 forks source link

Content-Disposition headers for File types #161

Open chastabor opened 5 years ago

chastabor commented 5 years ago

I need to have browsers automatically download some files. Is it currently possible to add a Content-Disposition: attachment; filename="--file--" header, where --file-- would dynamically be generated? I send this information along with file mime info for File types I wish to force the file to be downloaded instead of being displayed in the browser. If not what would be the best way to add that feature?

carllerche commented 5 years ago

Well, right now you can create a custom response type to handle this.

One question is if setting the Content-Disposition header should be default when responding with files.

Thoughts @shepmaster ?

chastabor commented 5 years ago

I don't believe that we would want to add a Content-Disposition header as browsers already default to inline if they recognize the file type.

On another note I'm still not sure how to create a custom response type for this. I've created a type and web impl as the following:

extern crate serde_derive;
#[macro_use]
extern crate tower_web;
extern crate tokio;

use tokio::fs::File;
use std::path::PathBuf;
use std::net::SocketAddr;
use tower_web::ServiceBuilder;
use tokio::net::TcpListener;
use std::env;

#[derive(Debug, Response)]
struct DownloadFile {
    #[web(header)]
    content_type: String,
    #[web(header)]
    content_disposition: String,
    file: File,
}

#[derive(Debug)]
pub struct StaticFile {
    root_dir: PathBuf,
}

impl_web! {
    impl StaticFile {
        #[get("/library/*relative_path")]
        fn m4v(&self, relative_path: PathBuf) -> DownloadFile {
            let mut path = self.root_dir.clone();
            path.push(relative_path);
            let filename = match relative_path.file_name() {
                Some(f) => match f.to_str() {
                    Some(f) => f,
                    None => "default.m4v",
                },
                None => "default.m4v",
            };
            DownloadFile {
                content_type: "video/x-m4v".to_string(),
                content_disposition: format!("attachment; filename=\"{}\"", filename),
                file: File::open(path).into(),
            }
        }
    }
}

pub fn main() {
    let addr = match env::var("ADDRESS") {
        Ok(a) => a.to_owned(),
        Err(_)  => "127.0.0.1:8443".to_owned(),
    };
    let addr: SocketAddr = addr.parse().unwrap();
    println!("Listening on http://{}", addr);
    tokio::run({
        ServiceBuilder::new()
        .resource(StaticFile{
            root_dir: "/var/lib/www".into(),
        })
        .serve(TcpListener::bind(&addr).unwrap().incoming())
    });
}

with following cargo file:

[package]
name = "sfile"
version = "0.1.0"
edition = "2018"

[dependencies]
tower-web = "0.3.3"
tokio = "0.1.10"
serde = "1.0.44"
serde_derive = "1.0.44"
futures = "0.1.18"

and then I get the following error:

error[E0277]: the trait bound `DownloadFile: tokio::prelude::Future` is not satisfied
  --> src/main.rs:28:1
   |
28 | / impl_web! {
29 | |         impl StaticFile {
30 | |
31 | |         #[get("/library/*relative_path")]
...  |
48 | |     }
49 | | }
   | |_^ the trait `tokio::prelude::Future` is not implemented for `DownloadFile`
   |
   = note: required because of the requirements on the impl of `tokio::prelude::IntoFuture` for `DownloadFile`
   = note: this error originates in a macro outside of the current crate (in Nightly builds, run with -Z external-macro-backtrace for more info)

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: Could not compile `sfile`.

To learn more, run the command again with --verbose.

I guess I need to implement a future trait for the DownloadFile type? or is there a better way to do this?

carllerche commented 5 years ago

You need to return a future of the DownloadFIle type, so something like:

-> impl Future<Item = DownloadFile>

You can then just return Ok(DownloadFile).into_future() (assuming you have the IntoFuture trait in scope)`... I agree this is not ideal though.

chastabor commented 5 years ago

Thanks for the response. I appreciate you looking at this. From what I can tell with the into_future() change the compiler now wants to serialize the file field instead of recognizing the field as a file type. I'm not sure how to prompt it to use that field as a File and not treat it as a serialized json response. So with the example code the error looks like the following:

error[E0277]: the trait bound `tokio_fs::file::File: __IMPL_RESPONSE_FOR_DownloadFile::_IMPL_SERIALIZE_FOR_ShadowDownloadFile::_serde::Serialize` is not satisfied
  --> src/main.rs:20:5
   |
20 |     file: File,
   |     ^^^^ the trait `__IMPL_RESPONSE_FOR_DownloadFile::_IMPL_SERIALIZE_FOR_ShadowDownloadFile::_serde::Serialize` is not implemented for `tokio_fs::file::File`
   |
   = note: required by `__IMPL_RESPONSE_FOR_DownloadFile::_IMPL_SERIALIZE_FOR_ShadowDownloadFile::_serde::ser::SerializeStruct::serialize_field`

With the larger code base for my service I get the following error:

error[E0277]: the trait bound `tokio::fs::File: serde::Serialize` is not satisfied
   --> src/main.rs:105:5
    |
105 |     file: File,
    |     ^^^^ the trait `serde::Serialize` is not implemented for `tokio::fs::File`
    |
    = note: required by `serde::ser::SerializeStruct::serialize_field`

Also if I can have it utilize the file response type, I'm thinking that the src/response/file.rs Response implementation will still not add any other headers other than the content_type_header saved in the context. Is that correct?

chastabor commented 5 years ago

So here is a working example using the IntoFuture step you introduced above, via a flat out non-DRY duplication of your File Response trait implemented for the DownloadFile structure. I'm hoping this may clarify what I'm trying to do, if it was not clear before.

---------- Cargo.toml 
[package]
name = "sfile"
version = "0.1.0"
edition = "2018"

[dependencies]
tower-web = { git = "https://github.com/carllerche/tower-web", rev = "2fee497a82a4", features = ["async-await-preview"] }
tokio = "0.1.10"
tokio-io = "0.1.7"
serde = "1.0.70"
serde_derive = "1.0.70"
futures = "0.1.21"
bytes = "0.4.7"
http = "0.1.7"

---------- src/main.rs 
#[macro_use]
extern crate futures;
#[macro_use]
extern crate tower_web;

use tokio::fs::File;
use std::path::PathBuf;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use std::env;
use std::fs::File as StdFile;
use futures::{ IntoFuture, Future, Async, Poll };
use std::io;
use bytes::BytesMut;
use http;
use tower_web::{
    ServiceBuilder,
    error,
    response::{
        Response,
        Serializer,
        Context,
    },
    util::BufStream,
};
use tokio_io::AsyncRead;

#[derive(Debug)]
struct DownloadFile {
    content_type: String,
    content_disposition: String,
    file: File,
}

impl Response for DownloadFile {
    type Buf = io::Cursor<BytesMut>;
    type Body = error::Map<File>;

    fn into_http<S: Serializer>(self, _context: &Context<S>) -> Result<http::Response<Self::Body>, tower_web::Error> {
        let content_type = http::header::HeaderValue::from_str(&self.content_type).unwrap();
        let content_disposition = http::header::HeaderValue::from_str(&self.content_disposition).unwrap(); 
        Ok(http::Response::builder()
           .status(200)
           .header(http::header::CONTENT_TYPE, content_type)
           .header(http::header::CONTENT_DISPOSITION, content_disposition)
           .body(error::Map::new(self.file))
           .unwrap())
    }
}

impl BufStream for DownloadFile {
    type Item = io::Cursor<BytesMut>;
    type Error = io::Error;

    fn poll(&mut self) -> Poll<Option<Self::Item>, Self::Error> {
        let mut v = BytesMut::with_capacity(8 * 1024);

        let len = try_ready!(self.file.read_buf(&mut v));

        if len == 0 {
            Ok(Async::Ready(None))
        } else {
            Ok(Async::Ready(Some(io::Cursor::new(v))))
        }
    }
}

#[derive(Debug)]
pub struct StaticFile {
    root_dir: PathBuf,
}

impl_web! {
    impl StaticFile {
        #[get("/library/*relative_path")]
        fn m4v(&self, relative_path: PathBuf) -> impl Future<Item = DownloadFile, Error = io::Error> {
            let mut path = self.root_dir.clone();
            path.push(relative_path.clone());
            let filename = match relative_path.file_name() {
                Some(f) => match f.to_str() {
                    Some(f) => f,
                    None => "default.m4v",
                },
                None => "default.m4v",
            };
            match StdFile::open(path) {
                Ok(file) => Ok(DownloadFile {
                    content_type: "video/x-m4v".to_string(),
                    content_disposition: format!("attachment; filename=\"{}\"", filename),
                    file: File::from_std(file),
                }).into_future(),
                Err(e) => Err(e).into_future(),
            }
        }
    }
}

pub fn main() {
    let addr = match env::var("ADDRESS") {
        Ok(a) => a.to_owned(),
        Err(_)  => "127.0.0.1:8080".to_owned(),
    };
    let addr: SocketAddr = addr.parse().unwrap();
    println!("Listening on http://{}", addr);
    tokio::run({
        ServiceBuilder::new()
        .resource(StaticFile{
            root_dir: "/var/lib/www".into(),
        })
        .serve(TcpListener::bind(&addr).unwrap().incoming())
    });
}
carllerche commented 5 years ago

Ok, I think the derive response macro may need to have an annotation to indicate that the file should be the body and not serialized w/ serde.