static_web_server/
directory_listing_download.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! Compress content of a directory into a tarball
7//!
8
9use async_compression::tokio::write::GzipEncoder;
10use async_tar::Builder;
11use bytes::BytesMut;
12use clap::ValueEnum;
13use headers::{ContentType, HeaderMapExt};
14use http::{HeaderValue, Method, Response};
15use hyper::{Body, body::Sender};
16use mime_guess::Mime;
17use std::fmt::Display;
18use std::path::Path;
19use std::path::PathBuf;
20use std::str::FromStr;
21use std::task::Poll::{Pending, Ready};
22use tokio::fs;
23use tokio::io;
24use tokio::io::AsyncWriteExt;
25use tokio_util::compat::TokioAsyncWriteCompatExt;
26
27use crate::Result;
28use crate::handler::RequestHandlerOpts;
29use crate::http_ext::MethodExt;
30
31/// query parameter key to download directory as tar.gz
32pub const DOWNLOAD_PARAM_KEY: &str = "download";
33
34/// Download format for directory
35#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum, Eq, Hash, PartialEq)]
36#[serde(rename_all = "lowercase")]
37pub enum DirDownloadFmt {
38    /// Gunzip-compressed tarball (.tar.gz)
39    Targz,
40}
41
42impl Display for DirDownloadFmt {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        std::fmt::Debug::fmt(self, f)
45    }
46}
47
48/// Directory download options.
49pub struct DirDownloadOpts<'a> {
50    /// Request method.
51    pub method: &'a Method,
52    /// Prevent following symlinks for files and directories.
53    pub disable_symlinks: bool,
54    /// Ignore hidden files (dotfiles).
55    pub ignore_hidden_files: bool,
56}
57
58/// Initializes directory listing download
59pub fn init(formats: &Vec<DirDownloadFmt>, handler_opts: &mut RequestHandlerOpts) {
60    for fmt in formats {
61        // Use naive implementation since the list is not expected to be long
62        if !handler_opts.dir_listing_download.contains(fmt) {
63            tracing::info!("directory listing download: enabled format {}", &fmt);
64            handler_opts.dir_listing_download.push(fmt.to_owned());
65        }
66    }
67    tracing::info!(
68        "directory listing download: enabled={}",
69        !handler_opts.dir_listing_download.is_empty()
70    );
71}
72
73/// impl AsyncWrite for hyper::Body::Sender
74pub struct ChannelBuffer {
75    s: Sender,
76}
77
78impl tokio::io::AsyncWrite for ChannelBuffer {
79    fn poll_write(
80        self: std::pin::Pin<&mut Self>,
81        cx: &mut std::task::Context<'_>,
82        buf: &[u8],
83    ) -> std::task::Poll<Result<usize, std::io::Error>> {
84        let this = self.get_mut();
85        let b = BytesMut::from(buf);
86        match this.s.poll_ready(cx) {
87            Ready(r) => match r {
88                Ok(()) => match this.s.try_send_data(b.freeze()) {
89                    Ok(_) => Ready(Ok(buf.len())),
90                    Err(_) => Pending,
91                },
92                Err(e) => Ready(Err(io::Error::new(io::ErrorKind::BrokenPipe, e))),
93            },
94            Pending => Pending,
95        }
96    }
97
98    fn poll_flush(
99        self: std::pin::Pin<&mut Self>,
100        _cx: &mut std::task::Context<'_>,
101    ) -> std::task::Poll<Result<(), std::io::Error>> {
102        std::task::Poll::Ready(Ok(()))
103    }
104
105    fn poll_shutdown(
106        self: std::pin::Pin<&mut Self>,
107        _cx: &mut std::task::Context<'_>,
108    ) -> std::task::Poll<Result<(), std::io::Error>> {
109        std::task::Poll::Ready(Ok(()))
110    }
111}
112
113async fn archive(
114    path: PathBuf,
115    src_path: PathBuf,
116    cb: ChannelBuffer,
117    follow_symlinks: bool,
118    ignore_hidden: bool,
119) -> Result {
120    let gz = GzipEncoder::with_quality(cb, async_compression::Level::Default);
121    let mut a = Builder::new(gz.compat_write());
122    a.follow_symlinks(follow_symlinks);
123
124    // NOTE: Since it is not possible to handle error gracefully, we will
125    // just stop writing when error occurs. It is also not possible to call
126    // sender.abort() as it is protected behind the Builder to ensure
127    // finish() is successfully called.
128
129    // adapted from async_tar::Builder::append_dir_all
130    let mut stack = vec![(src_path.to_path_buf(), true, false)];
131    while let Some((src, is_dir, is_symlink)) = stack.pop() {
132        let dest = path.join(src.strip_prefix(&src_path)?);
133
134        // In case of a symlink pointing to a directory, is_dir is false, but src.is_dir() will return true
135        if is_dir || (is_symlink && follow_symlinks && src.is_dir()) {
136            let mut entries = fs::read_dir(&src).await?;
137            while let Some(entry) = entries.next_entry().await? {
138                // Check and ignore the current hidden file/directory (dotfile) if feature enabled
139                let name = entry.file_name();
140                if ignore_hidden && name.as_encoded_bytes().first().is_some_and(|c| *c == b'.') {
141                    continue;
142                }
143
144                let file_type = entry.file_type().await?;
145                stack.push((entry.path(), file_type.is_dir(), file_type.is_symlink()));
146            }
147            if dest != Path::new("") {
148                a.append_dir(&dest, &src).await?;
149            }
150        } else {
151            // use append_path_with_name to handle symlink
152            a.append_path_with_name(src, &dest).await?;
153        }
154    }
155
156    a.finish().await?;
157    // this is required to emit gzip CRC trailer
158    a.into_inner().await?.into_inner().shutdown().await?;
159
160    Ok(())
161}
162
163/// Reply with archived directory content in compressed tarball format.
164/// The content from `src_path` on server filesystem will be stored to `path`
165/// within the tarball.
166/// An async task will be spawned to asynchronously write compressed data to the
167/// response body.
168pub fn archive_reply<P, Q>(path: P, src_path: Q, opts: DirDownloadOpts<'_>) -> Response<Body>
169where
170    P: AsRef<Path>,
171    Q: AsRef<Path>,
172{
173    let archive_name = path.as_ref().with_extension("tar.gz");
174    let mut resp = Response::new(Body::empty());
175
176    resp.headers_mut().typed_insert(ContentType::from(
177        // since this satisfies the required format: `*/*`, it should not fail
178        Mime::from_str("application/gzip").unwrap(),
179    ));
180    let hvals = format!(
181        "attachment; filename=\"{}\"",
182        archive_name.to_string_lossy()
183    );
184    match HeaderValue::from_str(hvals.as_str()) {
185        Ok(hval) => {
186            resp.headers_mut()
187                .insert(hyper::header::CONTENT_DISPOSITION, hval);
188        }
189        Err(err) => {
190            // not fatal, most browser is able to handle the download since
191            // content-type is set
192            tracing::error!("can't make content disposition from {}: {:?}", hvals, err);
193        }
194    }
195
196    // We skip the body for HEAD requests
197    if opts.method.is_head() {
198        return resp;
199    }
200
201    let (tx, body) = Body::channel();
202    tokio::task::spawn(archive(
203        path.as_ref().into(),
204        src_path.as_ref().into(),
205        ChannelBuffer { s: tx },
206        !opts.disable_symlinks,
207        opts.ignore_hidden_files,
208    ));
209    *resp.body_mut() = body;
210
211    resp
212}