第 25 章 项目一:去中心化文件网盘
本章实现一个最小可生产化的文件网盘:用户在前端选择文件,后端使用 Walrus Publisher 上传内容,把 blobId、文件名、MIME、大小、epoch 等元数据写入应用数据库,读取时通过 Aggregator 下载。它不是把所有状态都塞进 Walrus,而是把大文件交给 Walrus,把可搜索、可分页、可授权的索引留在应用层。
项目目标
- 支持单文件上传、列表、下载和删除索引。
- 上传成功后保存可恢复的
blobId与业务元数据。 - 前端不直接接触服务端钱包、Publisher 凭证或私有 API。
- 明确区分“删除索引”和“让 Walrus Blob 到期”。
- 给出可以直接复制到 Next.js、Express 或 Hono 后端的核心代码。
架构
Browser
| 1. multipart/form-data
v
App API /api/files
| 2. PUT bytes to Publisher
v
Walrus Publisher
| 3. store blob on Walrus
v
Walrus storage network
Browser
| 4. GET /api/files/:id/download
v
App API
| 5. redirect/proxy Aggregator URL
v
Walrus Aggregator
应用数据库只需要保存索引:
create table files (
id text primary key,
owner text not null,
name text not null,
mime text not null,
size_bytes integer not null,
blob_id text not null,
end_epoch integer,
created_at text not null
);
create index files_owner_created_at on files(owner, created_at desc);
目录结构
walrus-drive/
app/
page.tsx
api/
files/route.ts
files/[id]/download/route.ts
lib/
auth.ts
db.ts
walrus.ts
.env.local
package.json
环境变量
WALRUS_PUBLISHER_URL=https://publisher.walrus-testnet.walrus.space
WALRUS_AGGREGATOR_URL=https://aggregator.walrus-testnet.walrus.space
WALRUS_EPOCHS=5
MAX_UPLOAD_BYTES=104857600
生产环境建议把 Publisher 放在服务端私网或加访问控制;公开 Publisher 等于允许任何人借你的预算上传。
关键代码
lib/walrus.ts 负责和 Publisher、Aggregator 对接:
export type StoredBlob = {
blobId: string;
endEpoch?: number;
};
const publisherUrl = process.env.WALRUS_PUBLISHER_URL!;
const aggregatorUrl = process.env.WALRUS_AGGREGATOR_URL!;
export async function storeBlob(bytes: Uint8Array): Promise<StoredBlob> {
const epochs = Number(process.env.WALRUS_EPOCHS ?? "5");
const res = await fetch(`${publisherUrl}/v1/blobs?epochs=${epochs}`, {
method: "PUT",
body: bytes,
});
if (!res.ok) {
throw new Error(`Walrus store failed: ${res.status} ${await res.text()}`);
}
const json = await res.json();
const blobId =
json.newlyCreated?.blobObject?.blobId ??
json.alreadyCertified?.blobId ??
json.blobId;
if (!blobId) {
throw new Error(`Walrus response did not include blobId: ${JSON.stringify(json)}`);
}
return {
blobId,
endEpoch: json.newlyCreated?.blobObject?.storage?.endEpoch,
};
}
export function readBlobUrl(blobId: string): string {
return `${aggregatorUrl}/v1/blobs/${encodeURIComponent(blobId)}`;
}
上传接口只接受认证用户,并在写入数据库前完成大小检查:
import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { storeBlob } from "@/lib/walrus";
export async function POST(req: NextRequest) {
const user = await requireUser(req);
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "missing file" }, { status: 400 });
}
const maxBytes = Number(process.env.MAX_UPLOAD_BYTES ?? "104857600");
if (file.size > maxBytes) {
return NextResponse.json({ error: "file too large" }, { status: 413 });
}
const bytes = new Uint8Array(await file.arrayBuffer());
const stored = await storeBlob(bytes);
const row = {
id: randomUUID(),
owner: user.id,
name: file.name,
mime: file.type || "application/octet-stream",
sizeBytes: file.size,
blobId: stored.blobId,
endEpoch: stored.endEpoch ?? null,
createdAt: new Date().toISOString(),
};
await db.files.insert(row);
return NextResponse.json(row, { status: 201 });
}
下载接口建议默认重定向到 Aggregator;如果文件需要强鉴权或审计,再改成后端代理流式转发:
import { NextRequest, NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { readBlobUrl } from "@/lib/walrus";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const user = await requireUser(req);
const { id } = await params;
const file = await db.files.findById(id);
if (!file || file.owner !== user.id) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
return NextResponse.redirect(readBlobUrl(file.blobId));
}
前端上传组件保持简单:
"use client";
import { useState } from "react";
export function UploadBox() {
const [busy, setBusy] = useState(false);
async function upload(file: File) {
setBusy(true);
const body = new FormData();
body.set("file", file);
const res = await fetch("/api/files", { method: "POST", body });
setBusy(false);
if (!res.ok) throw new Error(await res.text());
location.reload();
}
return (
<input
disabled={busy}
type="file"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void upload(file);
}}
/>
);
}
运行命令
npm install
npm run dev
手工验证上传链路:
curl -F "file=@./README.md" http://localhost:3000/api/files
curl -L http://localhost:3000/api/files/<file-id>/download -o restored.md
安全与边界
- 不要把 Publisher 管理密钥、Sui 私钥或付费上传能力放到浏览器。
- 数据库删除只会删除应用索引,不会立刻从 Walrus 网络擦除已经存储的 Blob。
- 私密文件应先在客户端或可信服务端加密,再上传 Walrus;Walrus 提供可用性与可验证存储,不等于默认内容保密。
- 对上传接口做用户级配额、MIME 白名单、大小限制和频率限制。
- 对下载接口做所有权检查;公开分享链接应使用独立的 share token,而不是暴露内部文件 ID。
验收标准
- 上传 1 个小于
MAX_UPLOAD_BYTES的文件后,数据库出现一条含blobId的记录。 - 使用 Aggregator URL 能恢复原始文件,哈希与本地文件一致。
- 未登录用户无法上传或下载。
- 删除文件索引后,列表不再显示该文件,并且文档明确说明 Blob 会按 Walrus 生命周期自然到期。
- Publisher、Aggregator、epoch、上传大小上限都来自环境变量。