sys/sysmod/http/
upload.rs

1//! Uploader.
2//!
3//! ファイル名のルールは [[check_file_name]] を参照。
4
5use super::{ActixError, WebResult};
6use crate::taskserver::Control;
7use actix_multipart::{Multipart, MultipartError};
8use actix_web::{HttpResponse, Responder, http::header::ContentType, web};
9use anyhow::{Context, Result, anyhow, ensure};
10use log::{error, info, trace, warn};
11use std::path::Path;
12use tokio::{fs::File, io::AsyncWriteExt, process::Command};
13use tokio_stream::StreamExt;
14
15const FILE_NAME_MAX_LEN: usize = 32;
16const TMP_FILE_NAME: &str = "upload.tmp~";
17// TODO: config file
18const UPLOAD_FILE_LIMIT_MB: usize = 4 << 30;
19const UPLOAD_TOTAL_LIMIT_MB: usize = 32 << 30;
20
21/// テンポラリファイルに書き込めるのは一度に一人だけ。
22///
23/// アップロード完了までには時間がかかるのでロック中に await 可能な Mutex とする。
24static FS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
25
26#[actix_web::get("/upload/")]
27async fn index_get() -> impl Responder {
28    info!("GET /upload/");
29    let body = include_str!(concat!(
30        env!("CARGO_MANIFEST_DIR"),
31        "/res/http/upload/index.html"
32    ));
33
34    HttpResponse::Ok()
35        .content_type(ContentType::html())
36        .body(body)
37}
38
39#[actix_web::post("/upload/")]
40async fn index_post(mut payload: Multipart, ctrl: web::Data<Control>) -> WebResult {
41    let res = index_post_main(&mut payload, ctrl).await;
42
43    // finally
44    loop {
45        match payload.try_next().await {
46            Ok(res) => {
47                // 読み捨てる、None で完了
48                if res.is_none() {
49                    break;
50                }
51            }
52            Err(e) => {
53                warn!("Upload: Error while drain: {e}");
54                break;
55            }
56        }
57    }
58
59    res
60}
61
62/// <https://github.com/actix/examples/tree/master/forms/multipart>
63async fn index_post_main(payload: &mut Multipart, ctrl: web::Data<Control>) -> WebResult {
64    info!("POST /upload/");
65
66    let (dir, _flimit, _tlimit) = {
67        let http = ctrl.sysmods().http.lock().await;
68        let config = &http.config;
69        (
70            config.upload_dir.clone(),
71            UPLOAD_FILE_LIMIT_MB,
72            UPLOAD_TOTAL_LIMIT_MB,
73        )
74    };
75    let dir = Path::new(&dir);
76    let tmppath = dir.join(TMP_FILE_NAME);
77
78    // tempfile の使用権を取得する
79    // 取れない場合は 503 System Unavailable
80    let fs_lock = match FS_LOCK.try_lock() {
81        Ok(lock) => lock,
82        Err(_) => return Err(ActixError::new("Upload is busy", 503)),
83    };
84
85    // ディレクトリが無ければ作成
86    info!("Upload: create all: {}", dir.to_string_lossy());
87    std::fs::create_dir_all(dir).context("Failed to create upload dir")?;
88
89    // multipart/form-data のパース
90    if let Some(mut field) = conv_mperror(payload.try_next().await)? {
91        info!("Upload: multipart/form-data entry");
92
93        // content_disposition からファイル名を取得、チェック
94        let cont_disp = field
95            .content_disposition()
96            .ok_or_else(|| ActixError::new("Content-Disposition required", 400))?;
97        let fname = cont_disp.get_filename();
98        let fname = check_file_name(fname)?;
99        info!("Upload: filename: {fname}");
100        let dstpath = dir.join(fname);
101
102        // tempfile 作成
103        info!("Upload: create: {}", tmppath.to_string_lossy());
104        let mut tmpf = File::create(&tmppath)
105            .await
106            .context("Failed to create temp file")?;
107
108        // ファイルデータ本体
109        let mut total = 0;
110        while let Some(chunk) = conv_mperror(field.try_next().await)? {
111            tmpf.write(&chunk).await.context("Write error")?;
112            total += chunk.len();
113            trace!("{total} B received");
114            if total > UPLOAD_FILE_LIMIT_MB << 20 {
115                return Err(ActixError::new("File size is too large", 413));
116            }
117        }
118        info!("{total} B received");
119
120        let dirsize = get_disk_usage(&dir.to_string_lossy()).await?;
121        info!("Upload: du: {dirsize}");
122
123        if dirsize <= UPLOAD_TOTAL_LIMIT_MB << 20 {
124            // リネーム
125            info!(
126                "Upload: rename from {} to {}",
127                tmppath.to_string_lossy(),
128                dstpath.to_string_lossy()
129            );
130            tokio::fs::rename(&tmppath, &dstpath)
131                .await
132                .context("Rename failed")?;
133        } else {
134            // トータルサイズオーバーなので削除
135            error!("Upload: total size over");
136            info!("Upload: remove {}", tmppath.to_string_lossy());
137            tokio::fs::remove_file(&tmppath)
138                .await
139                .context("Remove failed")?;
140            return Err(ActixError::new("Insufficient storage", 507));
141        }
142
143        // close
144    } else {
145        return Err(ActixError::new("File data required", 400));
146    }
147
148    // ファイルシステムアンロック
149    drop(fs_lock);
150
151    Ok(HttpResponse::Ok()
152        .content_type(ContentType::plaintext())
153        .body(""))
154}
155
156/// MultipartError に [Send] が実装されていないからか自動変換が効かない。
157/// 文字列データを取り出して [anyhow::Error] 型に変換する。
158fn conv_mperror<T>(res: Result<T, MultipartError>) -> Result<T, anyhow::Error> {
159    match res {
160        Ok(x) => Ok(x),
161        Err(e) => Err(anyhow!(e.to_string())),
162    }
163}
164
165/// ファイル名をチェックする。
166///
167/// * None および空文字列は NG
168/// * [FILE_NAME_MAX_LEN] 文字まで
169/// * 半角英数字およびドット、ハイフン、アンダースコアのみ
170/// * ドットで始まらない (隠しファイルや `.` `..` などで何かが起こらないようにする)
171fn check_file_name(name: Option<&str>) -> Result<&str, ActixError> {
172    let name = name.unwrap_or("");
173    if name.is_empty() {
174        Err(ActixError::new("No file name", 400))
175    } else if name.len() > FILE_NAME_MAX_LEN {
176        Err(ActixError::new("File name too long", 400))
177    } else if name
178        .chars()
179        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
180        && !name.starts_with('.')
181    {
182        Ok(name)
183    } else {
184        Err(ActixError::new("Invalid file name", 400))
185    }
186}
187
188async fn get_disk_usage(dirpath: &str) -> Result<usize> {
189    let mut cmd = Command::new(format!("du -s -B 1 {dirpath}"));
190    let output = cmd.output().await?;
191    ensure!(output.status.success(), "du command failed");
192
193    // example: "2903404544      ."
194    let stdout = String::from_utf8_lossy(&output.stdout);
195    let token = stdout
196        .split_ascii_whitespace()
197        .next()
198        .context("du parse error")?;
199    let size = token.parse().context("du parse error")?;
200
201    Ok(size)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn check_file_name_ok() {
210        assert!(check_file_name(Some("ok.txt")).is_ok());
211    }
212
213    #[test]
214    fn check_file_name_empty() {
215        assert!(check_file_name(None).is_err());
216        assert!(check_file_name(Some("")).is_err());
217    }
218
219    #[test]
220    fn check_file_name_long() {
221        let long_name = "012345678901234567890123456789.txt";
222        assert!(check_file_name(Some(long_name)).is_err());
223    }
224
225    #[test]
226    fn check_file_name_invalid() {
227        assert!(check_file_name(Some("あ.txt")).is_err());
228    }
229
230    #[test]
231    fn check_file_name_tmpfile() {
232        assert!(check_file_name(Some(TMP_FILE_NAME)).is_err());
233    }
234}