1、目标与路由设计
最终我们有 3 个路由:
GET /:返回使用说明POST /:接收原始 body,保存成文件,返回可访问的 URLGET /<id>:根据 id 取回内容(不存在就 404)
存储策略:把每次上传保存到项目根目录的upload/目录里;文件名就是 paste 的id(一串可读的随机字符)。
目录结构大致是:
. ├── Cargo.toml ├── src │ ├── main.rs │ └── paste_id.rs └── upload2、Cargo.toml:最小依赖
[dependencies] rocket = "0.5.1" rand = "0.8"3、PasteId:把“合法 ID 的规则”收敛成一个类型
我们不想在每个路由里手写一堆校验逻辑,所以用一个PasteId类型集中定义策略:
- 生成:从 base62(0-9A-Za-z)里挑字符
- 落盘:只允许在
upload/目录里构造路径 - 校验:只接受 ASCII 字母数字(你也可以加长度限制等)
src/paste_id.rs:
usestd::borrow::Cow;usestd::path::{Path,PathBuf};userand::{self,Rng};userocket::request::FromParam;#[derive(rocket::http::uri::UriDisplayPath)]pubstructPasteId<'a>(Cow<'a,str>);implPasteId<'_>{pubfnnew(size:usize)->PasteId<'static>{constBASE62:&[u8]=b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";letmutid=String::with_capacity(size);letmutrng=rand::thread_rng();for_in0..size{id.push(BASE62[rng.gen::<usize>()%62]aschar);}PasteId(Cow::Owned(id))}pubfnfile_path(&self)->PathBuf{letroot=concat!(env!("CARGO_MANIFEST_DIR"),"/","upload");Path::new(root).join(self.0.as_ref())}}/// 把不可信的 path segment 变成可信的 PasteId:/// 只允许字母数字(可按需加长度上限/下限)impl<'a>FromParam<'a>forPasteId<'a>{typeError=&'astr;fnfrom_param(param:&'astr)->Result<Self,Self::Error>{letok_chars=param.chars().all(|c|c.is_ascii_alphanumeric());(ok_chars).then(||PasteId(param.into())).ok_or(param)}}为什么必须做 FromParam?
如果你在retrieve(id: &str)里直接拿用户输入拼路径,用户完全可以请求/_credentials.txt之类的敏感文件名(或更复杂的变种),导致你把不该暴露的文件读出来。这类问题常被归为路径相关的文件泄露/穿越风险。
用PasteId+FromParam后,Rocket 会先校验<id>,不合法就根本不会进入你的 handler,从入口把攻击面切断,而且策略集中维护。
4、main.rs:三条路由,流式上传与类型安全 URI
src/main.rs:
#[macro_use]externcraterocket;modpaste_id;usepaste_id::PasteId;userocket::data::{Data,ToByteUnit};userocket::http::uri::Absolute;userocket::tokio::fs::{self,File};constID_LENGTH:usize=3;// 实际生产建议从配置读取;这里只是演示constHOST:Absolute<'static>=uri!("http://localhost:8000");#[get("/")]fnindex()->&'staticstr{r#" USAGE POST / accepts raw data in the body of the request and responds with a URL of a page containing the body's content GET /<id> retrieves the content for the paste with id `<id>` "#}#[get("/<id>")]asyncfnretrieve(id:PasteId<'_>)->Option<File>{File::open(id.file_path()).await.ok()}#[post("/", data ="<paste>")]asyncfnupload(paste:Data<'_>)->std::io::Result<String>{// 确保 upload/ 存在(避免首次运行忘建目录)letupload_dir=concat!(env!("CARGO_MANIFEST_DIR"),"/","upload");fs::create_dir_all(upload_dir).await?;letid=PasteId::new(ID_LENGTH);// 128KiB 只是示例:限制请求体大小,防止被大包打爆磁盘/内存/IOpaste.open(128.kibibytes()).into_file(id.file_path()).await?;// 生成绝对 URL:类型安全、路由变更能编译期兜底Ok(uri!(HOST,retrieve(id)).to_string())}#[launch]fnrocket()->_{rocket::build().mount("/",routes![index,retrieve,upload])}这里顺手把几个关键点都用上了:
Data<'_>:代表“未打开的请求体流”,适合大文件/流式写入paste.open(128.kibibytes()):给上传设上限(默认你不设就可能被打穿)into_file(path):把请求体流直接落盘,不用你手写循环读写PasteId: FromParam:动态路径参数的类型化校验PasteId: UriDisplayPath+uri!:构造 URL 时类型安全、自动编码、路由签名变更可编译期报错
5、跑起来:curl 上传与取回
项目根目录先确保有upload/(代码里也会自动建):
cargo run另开一个终端上传:
echo"Hello, Rocket!"|curl--data-binary @- http://localhost:8000会返回类似:
http://localhost:8000/eGs再 GET 一下:
curlhttp://localhost:8000/eGs你也可以直接看磁盘:
lsuploadcatupload/*6、这套写法为什么“工程上更靠谱”
1)安全策略集中化
ID 的合法性只在PasteId::from_param定义一次,任何用到PasteId的路由都自动继承这套策略,后续加DELETE /<id>、PUT /<id>也不容易漏。
2)类型安全的 URL 生成uri!(HOST, retrieve(id))会检查路由参数匹配与类型转换;你改了路由签名,编译器会提醒所有构造 URL 的地方一起改。
3)流式落盘 + 明确限制Data的模式天然适合大 body,配合上限避免资源型攻击或误操作(比如误传超大文件)。
7、可以继续增强的方向(很适合当练手清单)
- 更严格的
PasteId校验:长度范围、黑名单文件名、甚至检查文件是否存在 - 返回不同状态码:比如上传达到限制时返回 206 Partial Content,否则 201 Created
retrieve/upload返回text/plain(用content::RawText或自定义 Responder)- 删除与权限:上传返回一个 key,
DELETE /<id>必须带正确 key - 支持
PUT /<id>覆盖内容(同样要 key) - 新增
GET /<id>/<lang>:做语法高亮(lang也用FromParam校验) - 用 Rocket 的 local client 写单元/集成测试
- 增加定时清理:启动前/启动后起一个任务,清理过期 paste(注意配合优雅停机)