Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 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、上传大小上限都来自环境变量。