Use when you have a written implementation plan to execute in a separate session with review checkpoints
npx skills add salvo-rs/salvo-skills --skill "salvo-file-handling"
Install specific skill from multi-skill repository
# Description
Handle file uploads (single/multiple), downloads, and multipart forms. Use for file management, image uploads, and content delivery.
# SKILL.md
name: salvo-file-handling
description: Handle file uploads (single/multiple), downloads, and multipart forms. Use for file management, image uploads, and content delivery.
Salvo File Handling
This skill helps handle file uploads and downloads in Salvo applications.
Setup
[dependencies]
salvo = "1.88.1"
tokio = { version = "1", features = ["fs"] }
Single File Upload
use std::path::Path;
use salvo::prelude::*;
#[handler]
async fn index(res: &mut Response) {
res.render(Text::Html(r#"
<!DOCTYPE html>
<html>
<body>
<h1>Upload File</h1>
<form action="/" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
</body>
</html>
"#));
}
#[handler]
async fn upload(req: &mut Request, res: &mut Response) {
let file = req.file("file").await;
if let Some(file) = file {
let dest = format!("temp/{}", file.name().unwrap_or("file"));
println!("Uploading to: {}", dest);
if let Err(e) = std::fs::copy(file.path(), Path::new(&dest)) {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(Text::Plain(format!("Upload failed: {e}")));
} else {
res.render(Text::Plain(format!("File uploaded to {dest}")));
}
} else {
res.status_code(StatusCode::BAD_REQUEST);
res.render(Text::Plain("No file in request"));
}
}
#[tokio::main]
async fn main() {
std::fs::create_dir_all("temp").unwrap();
let router = Router::new().get(index).post(upload);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Multiple File Upload
use std::path::Path;
use salvo::prelude::*;
#[handler]
async fn upload_files(req: &mut Request, res: &mut Response) {
let files = req.files("files").await;
if let Some(files) = files {
let mut uploaded = Vec::with_capacity(files.len());
for file in files {
let dest = format!("temp/{}", file.name().unwrap_or("file"));
if let Err(e) = std::fs::copy(file.path(), Path::new(&dest)) {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(Text::Plain(format!("Failed to upload: {e}")));
return;
} else {
uploaded.push(dest);
}
}
res.render(Text::Plain(format!(
"Files uploaded:\n\n{}",
uploaded.join("\n")
)));
} else {
res.status_code(StatusCode::BAD_REQUEST);
res.render(Text::Plain("No files in request"));
}
}
static UPLOAD_HTML: &str = r#"<!DOCTYPE html>
<html>
<body>
<h1>Upload Multiple Files</h1>
<form action="/" method="post" enctype="multipart/form-data">
<input type="file" name="files" multiple/>
<input type="submit" value="Upload" />
</form>
</body>
</html>
"#;
File Validation
#[handler]
async fn upload_image(req: &mut Request, res: &mut Response) {
let file = req.file("image").await;
let Some(file) = file else {
res.status_code(StatusCode::BAD_REQUEST);
res.render("No file provided");
return;
};
// Validate content type
let content_type = file.content_type().unwrap_or_default();
let allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"];
if !allowed_types.contains(&content_type) {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!(
"Invalid file type: {}. Allowed: {}",
content_type,
allowed_types.join(", ")
));
return;
}
// Validate file size (5MB max)
let max_size = 5 * 1024 * 1024;
if file.size() > max_size {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!(
"File too large: {} bytes. Max: {} bytes",
file.size(),
max_size
));
return;
}
// Validate extension
let filename = file.name().unwrap_or("unnamed");
let allowed_extensions = ["jpg", "jpeg", "png", "gif", "webp"];
let extension = filename.rsplit('.').next().unwrap_or("");
if !allowed_extensions.contains(&extension.to_lowercase().as_str()) {
res.status_code(StatusCode::BAD_REQUEST);
res.render("Invalid file extension");
return;
}
// Generate unique filename
let unique_name = format!(
"{}_{}.{}",
uuid::Uuid::new_v4(),
chrono::Utc::now().timestamp(),
extension
);
let dest = format!("uploads/{}", unique_name);
if let Err(e) = std::fs::copy(file.path(), &dest) {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(format!("Upload failed: {e}"));
} else {
res.render(Json(serde_json::json!({
"filename": unique_name,
"original_name": filename,
"size": file.size(),
"content_type": content_type
})));
}
}
Size Limiting
Limit request body size to prevent resource exhaustion:
use salvo::prelude::*;
#[tokio::main]
async fn main() {
let router = Router::new()
.push(
Router::with_path("upload")
.hoop(max_size(10 * 1024 * 1024)) // 10MB limit
.post(upload_handler)
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
File Download
Basic Download
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 filepath = format!("files/{}", filename);
match NamedFile::builder(&filepath)
.attached_name(&filename) // Set Content-Disposition: attachment
.send(req.headers(), res)
.await
{
Ok(_) => {}
Err(_) => {
res.status_code(StatusCode::NOT_FOUND);
res.render("File not found");
}
}
}
Inline View (PDF, Images)
#[handler]
async fn view_pdf(req: &mut Request, res: &mut Response) {
match NamedFile::builder("documents/report.pdf")
.content_type("application/pdf")
// No attached_name = inline display
.send(req.headers(), res)
.await
{
Ok(_) => {}
Err(_) => {
res.status_code(StatusCode::NOT_FOUND);
}
}
}
Protected Downloads
#[handler]
async fn protected_download(
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
) {
// Check authentication
let user = depot.get::<User>("user");
if user.is_none() {
res.status_code(StatusCode::UNAUTHORIZED);
res.render("Please login");
return;
}
let filename: String = req.param("filename").unwrap();
// Validate filename to prevent directory traversal
if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
res.status_code(StatusCode::BAD_REQUEST);
res.render("Invalid filename");
return;
}
let filepath = format!("private/{}", filename);
match NamedFile::builder(&filepath)
.attached_name(&filename)
.send(req.headers(), res)
.await
{
Ok(_) => {}
Err(_) => {
res.status_code(StatusCode::NOT_FOUND);
res.render("File not found");
}
}
}
Form with File and Other Fields
use salvo::prelude::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct ProfileForm {
name: String,
bio: String,
}
#[handler]
async fn update_profile(req: &mut Request, res: &mut Response) {
// Parse form fields
let name = req.form::<String>("name").await.unwrap_or_default();
let bio = req.form::<String>("bio").await.unwrap_or_default();
// Handle file upload
let avatar_path = if let Some(file) = req.file("avatar").await {
let filename = format!("avatar_{}.jpg", uuid::Uuid::new_v4());
let dest = format!("uploads/avatars/{}", filename);
std::fs::copy(file.path(), &dest).ok();
Some(filename)
} else {
None
};
res.render(Json(serde_json::json!({
"name": name,
"bio": bio,
"avatar": avatar_path
})));
}
Upload with Progress (Chunked)
For large file uploads with progress tracking:
use tokio::io::AsyncWriteExt;
#[handler]
async fn chunked_upload(req: &mut Request, res: &mut Response) {
let filename: String = req.query("filename").unwrap_or_else(|| "upload".to_string());
let dest = format!("uploads/{}", filename);
let mut file = tokio::fs::File::create(&dest).await.unwrap();
let body = req.payload().await.unwrap();
file.write_all(body).await.unwrap();
res.render(format!("Uploaded {} bytes", body.len()));
}
File Upload with OpenAPI
use salvo::oapi::extract::*;
use salvo::prelude::*;
#[derive(Serialize, ToSchema)]
struct UploadResult {
filename: String,
size: u64,
content_type: String,
}
#[endpoint(
tags("files"),
summary = "Upload a file",
request_body(content = "multipart/form-data")
)]
async fn upload_with_openapi(req: &mut Request) -> Result<Json<UploadResult>, StatusError> {
let file = req.file("file").await
.ok_or_else(|| StatusError::bad_request().brief("No file provided"))?;
let filename = file.name().unwrap_or("unnamed").to_string();
let size = file.size();
let content_type = file.content_type().unwrap_or("application/octet-stream").to_string();
let dest = format!("uploads/{}", filename);
std::fs::copy(file.path(), &dest)
.map_err(|_| StatusError::internal_server_error())?;
Ok(Json(UploadResult {
filename,
size,
content_type,
}))
}
Complete Upload/Download Example
use std::path::Path;
use salvo::fs::NamedFile;
use salvo::prelude::*;
#[handler]
async fn index(res: &mut Response) {
res.render(Text::Html(r#"
<!DOCTYPE html>
<html>
<body>
<h1>File Manager</h1>
<h2>Upload</h2>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h2>Files</h2>
<div id="files"></div>
<script>
fetch('/files').then(r => r.json()).then(files => {
document.getElementById('files').innerHTML = files.map(f =>
`<a href="/download/${f}">${f}</a><br/>`
).join('');
});
</script>
</body>
</html>
"#));
}
#[handler]
async fn upload(req: &mut Request, res: &mut Response) {
if let Some(file) = req.file("file").await {
let dest = format!("uploads/{}", file.name().unwrap_or("file"));
std::fs::copy(file.path(), Path::new(&dest)).ok();
res.render(Redirect::other("/"));
} else {
res.status_code(StatusCode::BAD_REQUEST);
res.render("No file");
}
}
#[handler]
async fn list_files() -> Json<Vec<String>> {
let files: Vec<String> = std::fs::read_dir("uploads")
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
Json(files)
}
#[handler]
async fn download(req: &mut Request, res: &mut Response) {
let filename: String = req.param("filename").unwrap();
let filepath = format!("uploads/{}", filename);
match NamedFile::builder(&filepath)
.attached_name(&filename)
.send(req.headers(), res)
.await
{
Ok(_) => {}
Err(_) => {
res.status_code(StatusCode::NOT_FOUND);
}
}
}
#[tokio::main]
async fn main() {
std::fs::create_dir_all("uploads").unwrap();
let router = Router::new()
.get(index)
.push(Router::with_path("upload").post(upload))
.push(Router::with_path("files").get(list_files))
.push(Router::with_path("download/{filename}").get(download));
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Best Practices
- Validate file types: Check MIME type and extension
- Limit file size: Use
max_size()middleware - Use unique filenames: Prevent overwrites and conflicts
- Prevent directory traversal: Validate filenames don't contain
.. - Store outside web root: Keep uploads in non-public directories
- Implement access control: Check permissions before downloads
- Clean up temp files: Salvo cleans up automatically, but verify
- Use async I/O: Don't block with sync file operations
- Consider virus scanning: For user-uploaded content
- Set Content-Disposition: Force download or inline display as appropriate
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.