一键导入
salvo-file-handling
Handle file uploads (single/multiple), downloads, and multipart forms. Use for file management, image uploads, and content delivery.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Handle file uploads (single/multiple), downloads, and multipart forms. Use for file management, image uploads, and content delivery.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Implement authentication and authorization using JWT, Basic Auth, or custom schemes. Use for securing API endpoints and user management.
Create basic Salvo web applications with handlers, routers, and server setup. Use when starting a new Salvo project or adding basic HTTP endpoints.
Implement caching strategies for improved performance. Use for reducing database load and speeding up responses.
Compress HTTP responses using gzip, brotli, zstd, or deflate. Use for reducing bandwidth and improving load times.
Limit concurrent requests to protect resources. Use for file uploads, expensive operations, and preventing resource exhaustion.
Configure Cross-Origin Resource Sharing (CORS) and security headers. Use for APIs accessed from browsers on different domains.
| name | salvo-file-handling |
| description | Handle file uploads (single/multiple), downloads, and multipart forms. Use for file management, image uploads, and content delivery. |
| version | 0.94.0 |
| tags | ["data","file-upload","multipart","download"] |
[dependencies]
salvo = { version = "0.94.0", features = ["size-limiter"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] }
req.file()/req.files() return Option<&FilePart> / Option<&Vec<FilePart>>. Salvo writes multipart parts to a temp dir, so file.path() points at an on-disk file — copy or rename it to the final location; don't rely on it sticking around after the handler returns.
use std::path::Path;
use salvo::prelude::*;
#[handler]
async fn upload(req: &mut Request, res: &mut Response) {
let Some(file) = req.file("file").await else {
res.status_code(StatusCode::BAD_REQUEST);
res.render(Text::Plain("No file in request"));
return;
};
let dest = format!("temp/{}", file.name().unwrap_or("file"));
match tokio::fs::copy(file.path(), Path::new(&dest)).await {
Ok(_) => res.render(Text::Plain(format!("Uploaded to {dest}"))),
Err(e) => {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(Text::Plain(format!("Upload failed: {e}")));
}
}
}
#[tokio::main]
async fn main() {
tokio::fs::create_dir_all("temp").await.unwrap();
let router = Router::new().post(upload);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
HTML form: <form method="post" enctype="multipart/form-data"><input type="file" name="file"/></form>.
#[handler]
async fn upload_files(req: &mut Request, res: &mut Response) {
let Some(files) = req.files("files").await else {
res.status_code(StatusCode::BAD_REQUEST);
return;
};
let mut uploaded = Vec::with_capacity(files.len());
for file in files {
let dest = format!("temp/{}", file.name().unwrap_or("file"));
if tokio::fs::copy(file.path(), &dest).await.is_ok() {
uploaded.push(dest);
}
}
res.render(Json(uploaded));
}
For any-field upload use req.all_files().await -> Vec<&FilePart>; for single unknown-key, req.first_file().await.
FilePart::content_type() returns Option<mime::Mime>, not a &str. Compare with .type_()/.subtype() or .essence_str().
use salvo::prelude::*;
#[handler]
async fn upload_image(req: &mut Request, res: &mut Response) {
let Some(file) = req.file("image").await else {
res.status_code(StatusCode::BAD_REQUEST);
res.render("No file");
return;
};
let ct = file.content_type().map(|m| m.essence_str().to_owned()).unwrap_or_default();
const ALLOWED: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
if !ALLOWED.contains(&ct.as_str()) {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!("Bad type: {ct}"));
return;
}
if file.size() > 5 * 1024 * 1024 {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!("Too large: {} bytes", file.size()));
return;
}
let filename = file.name().unwrap_or("unnamed");
let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
let unique = format!("{}.{ext}", uuid::Uuid::new_v4());
let dest = format!("uploads/{unique}");
if tokio::fs::copy(file.path(), &dest).await.is_err() {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
return;
}
res.render(Json(serde_json::json!({
"filename": unique,
"original": filename,
"size": file.size(),
"content_type": ct,
})));
}
Requires size-limiter feature. Apply as middleware to limit request body size.
use salvo::prelude::*;
use salvo::size_limiter::max_size;
let router = Router::with_path("upload")
.hoop(max_size(10 * 1024 * 1024)) // 10 MiB
.post(upload);
NamedFile::builder(path) returns a NamedFileBuilder. Key methods: attached_name(name) (sets Content-Disposition: attachment), content_type(mime) (takes mime::Mime, not a string), use_etag(bool), disposition_type(ty), send(headers, res).
use salvo::fs::NamedFile;
use salvo::prelude::*;
#[handler]
async fn download(req: &mut Request, res: &mut Response) {
let filename: String = req.param("filename").unwrap();
let path = format!("files/{filename}");
if NamedFile::builder(&path)
.attached_name(&filename)
.send(req.headers(), res)
.await
.is_err()
{
res.status_code(StatusCode::NOT_FOUND);
res.render("File not found");
}
}
Inline view (no attached_name) with an explicit MIME:
use salvo::fs::NamedFile;
use salvo::prelude::*;
#[handler]
async fn view_pdf(req: &mut Request, res: &mut Response) {
let _ = NamedFile::builder("docs/report.pdf")
.content_type(mime::APPLICATION_PDF)
.send(req.headers(), res)
.await;
}
NamedFile::send handles If-None-Match, If-Modified-Since, and Range headers automatically.
#[handler]
async fn protected_download(
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
) {
let Ok(_user) = depot.get_typed::<User>() else {
res.status_code(StatusCode::UNAUTHORIZED);
return;
};
let filename: String = req.param("filename").unwrap();
if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
res.status_code(StatusCode::BAD_REQUEST);
return;
}
let path = format!("private/{filename}");
if NamedFile::builder(&path)
.attached_name(&filename)
.send(req.headers(), res)
.await
.is_err()
{
res.status_code(StatusCode::NOT_FOUND);
}
}
#[handler]
async fn update_profile(req: &mut Request, res: &mut Response) {
let name = req.form::<String>("name").await.unwrap_or_default();
let bio = req.form::<String>("bio").await.unwrap_or_default();
let avatar = if let Some(file) = req.file("avatar").await {
let filename = format!("{}.jpg", uuid::Uuid::new_v4());
let _ = tokio::fs::copy(file.path(), format!("uploads/{filename}")).await;
Some(filename)
} else {
None
};
res.render(Json(serde_json::json!({ "name": name, "bio": bio, "avatar": avatar })));
}
Note: form_data and file/files/form all parse multipart lazily and cache. Interleave calls freely.
use tokio::io::AsyncWriteExt;
use salvo::prelude::*;
#[handler]
async fn raw_upload(req: &mut Request, res: &mut Response) -> Result<(), StatusError> {
let filename: String = req.query("filename").unwrap_or_else(|| "upload".into());
let body = req.payload().await.map_err(|_| StatusError::bad_request())?;
let mut f = tokio::fs::File::create(format!("uploads/{filename}")).await
.map_err(|_| StatusError::internal_server_error())?;
f.write_all(body).await.map_err(|_| StatusError::internal_server_error())?;
res.render(format!("{} bytes", body.len()));
Ok(())
}
payload() honors the request's max_size; use payload_with_max_size(n) for a custom cap.
use salvo::oapi::extract::*;
use salvo::prelude::*;
use serde::Serialize;
#[derive(Serialize, ToSchema)]
struct UploadResult { filename: String, size: u64 }
#[endpoint(
tags("files"),
request_body(content = "multipart/form-data"),
)]
async fn upload(req: &mut Request) -> Result<Json<UploadResult>, StatusError> {
let file = req.file("file").await
.ok_or_else(|| StatusError::bad_request().brief("No file"))?;
let filename = file.name().unwrap_or("unnamed").to_string();
let size = file.size();
tokio::fs::copy(file.path(), format!("uploads/{filename}")).await
.map_err(|_| StatusError::internal_server_error())?;
Ok(Json(UploadResult { filename, size }))
}
filename against .., /, \ before using it as a path segment.content_type() returns Option<Mime>; use .essence_str() for a comparable string.NamedFile::content_type() takes mime::Mime, not &str.