mcpField
C
convex-file-storage

convex-file-storage

github.com/waynesutton开发4052.6k 次安装4 周前更新
代码仓库

关于 convex-file-storage

面向 Convex 开发者的文件存储技能指南,介绍文件上传、元数据保存、访问和删除流程,并强调在数据库中保存存储 ID,而非 URL。

主要功能

  1. 01生成短期上传 URL 并保存存储 ID
  2. 02通过 Action 保存 Blob
  3. 03在查询中解析文件 URL
  4. 04通过 HTTP Action 提供文件
  5. 05验证文件类型、大小及所有权

适用场景

  1. 01为应用实现带所有权检查的文件上传
  2. 02在查询结果中提供文件链接
  3. 03通过稳定的 HTTP 路径或自定义响应提供文件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/waynesutton/builder-skills --skill convex-file-storage

安装后在 Claude Code 中输入/convex-file-storage确认已加载。

附带文件agents/openai.yamlassets/small-logo.svg

详细介绍

Convex 文件存储

为 Convex 中的文件生成上传、存储、提供和删除函数。唯一的原则:持久化 Id<"_storage">,绝不保存 URL 字符串。在读取时,通过查询中的 ctx.storage.getUrl 解析 URL。

架构

为应用元数据(名称、所有者、用途)保留自己的表,并通过 ID 指向 _storage。Convex 会在 _storage 系统表中保存大小、内容类型和哈希值。

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  files: defineTable({
    storageId: v.id("_storage"),
    name: v.string(),
    contentType: v.string(),
    size: v.number(),
    ownerId: v.id("users"),
  }).index("by_owner", ["ownerId"]),
});

上传流程

分为三步:一个 mutation 发放短时有效的上传 URL,客户端将文件 POST 到该 URL,然后第二个 mutation 保存返回的 storageId。上传 URL 一小时后过期,因此每次上传都要生成新的 URL,且绝不要保存它。

// convex/files.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getCurrentUser } from "./lib/auth";

const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "application/pdf"];

export const generateUploadUrl = mutation({
  args: {},
  returns: v.string(),
  handler: async (ctx) => {
    await getCurrentUser(ctx); // only signed in users get an upload slot
    return await ctx.storage.generateUploadUrl();
  },
});

export const saveFile = mutation({
  args: { storageId: v.id("_storage"), name: v.string() },
  returns: v.id("files"),
  handler: async (ctx, args) => {
    const user = await getCurrentUser(ctx);

    // Server side validation reads the real size and type from _storage.
    // Client checks are for UX only; anyone can POST to the upload URL.
    const meta = await ctx.db.system.get(args.storageId);
    if (!meta) throw new Error("Upload not found");
    if (meta.size > MAX_BYTES || !ALLOWED_TYPES.includes(meta.contentType ?? "")) {
      await ctx.storage.delete(args.storageId);
      throw new Error("File type or size not allowed");
    }

    return await ctx.db.insert("files", {
      storageId: args.storageId,
      name: args.name,
      contentType: meta.contentType ?? "application/octet-stream",
      size: meta.size,
      ownerId: user._id,
    });
  },
});
// src/FileUploader.tsx
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
import { useState } from "react";

const MAX_BYTES = 10 * 1024 * 1024;

export function FileUploader() {
  const generateUploadUrl = useMutation(api.files.generateUploadUrl);
  const saveFile = useMutation(api.files.saveFile);
  const [status, setStatus] = useState<"idle" | "uploading" | "error">("idle");

  async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    if (file.size > MAX_BYTES) return setStatus("error");

    setStatus("uploading");
    try {
      const uploadUrl = await generateUploadUrl();
      const res = await fetch(uploadUrl, {
        method: "POST",
        headers: { "Content-Type": file.type }, // recorded as the file's contentType
        body: file,
      });
      if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
      const { storageId } = (await res.json()) as { storageId: Id<"_storage"> };
      await saveFile({ storageId, name: file.name });
      setStatus("idle");
    } catch {
      setStatus("error");
    }
  }

  return <input type="file" accept="image/*,.pdf" onChange={handleChange} disabled={status === "uploading"} />;
}

在 action 中存储 Blob

ctx.storage.store 可在 action 和 HTTP action 中使用,在 mutation 中不可用。默认运行时支持普通 fetch,因此只有在 Node 库生成字节时才需要 "use node"。自行构建字节时,请先将其封装:new Blob([bytes], { type: "application/pdf" })。如果没有设置 type,文件将以 application/octet-stream 提供。

// convex/importFile.ts
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

export const importFromUrl = action({
  args: { url: v.string(), name: v.string(), ownerId: v.id("users") },
  returns: v.id("files"),
  handler: async (ctx, args) => {
    const response = await fetch(args.url);
    if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);

    // response.blob() carries the Content-Type header into blob.type
    const blob = await response.blob();
    const storageId = await ctx.storage.store(blob);

    return await ctx.runMutation(internal.files.saveImported, {
      storageId,
      name: args.name,
      ownerId: args.ownerId,
    });
  },
});

使用 getUrl 提供文件

在返回文件的查询中解析 URL。客户端可直接在 img、iframe 或下载链接中渲染 url。

// convex/files.ts
export const listMine = query({
  args: {},
  returns: v.array(
    v.object({
      _id: v.id("files"),
      name: v.string(),
      contentType: v.string(),
      size: v.number(),
      url: v.union(v.string(), v.null()),
    }),
  ),
  handler: async (ctx) => {
    const user = await getCurrentUser(ctx);
    const files = await ctx.db
      .query("files")
      .withIndex("by_owner", (q) => q.eq("ownerId", user._id))
      .order("desc")
      .collect();
    return await Promise.all(
      files.map(async (f) => ({
        _id: f._id,
        name: f.name,
        contentType: f.contentType,
        size: f.size,
        url: await ctx.storage.getUrl(f.storageId),
      })),
    );
  },
});

文件已删除时,getUrl 会返回 null。请在 UI 中处理这种情况,不要假定它一定是字符串。

通过 HTTP action 提供文件

当文件需要身份验证检查、自定义 Content-Disposition,或稳定路径 https://<deployment>.convex.site/files/<storageId> 时,请使用此方式。ctx.storage.get 会返回 Blob。将路由添加到 convex/http.ts 中的路由器。

// convex/http.ts
import { Id } from "./_generated/dataModel";

http.route({
  pathPrefix: "/files/",
  method: "GET",
  handler: httpAction(async (ctx, request) => {
    const storageId = new URL(request.url).pathname.slice("/files/".length) as Id<"_storage">;
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return new Response("Unauthorized", { status: 401 });

    const blob = await ctx.storage.get(storageId);
    if (!blob) return new Response("Not found", { status: 404 });
    return new Response(blob, {
      headers: { "Content-Type": blob.type || "application/octet-stream" },
    });
  }),
});

获取 _storage 表中的元数据

在任何查询或 mutation 中,通过 ctx.db.system.get(storageId) 读取元数据,如上面的 saveFile 所示。ctx.storage.getMetadata 已弃用,不应出现在新代码中。文档结构如下:

type StorageDoc = {
  _id: Id<"_storage">;
  _creationTime: number;
  contentType?: string; // from the upload's Content-Type header or blob.type
  sha256: string;
  size: number; // bytes
};

对应的校验器为 v.object({ _id: v.id("_storage"), _creationTime: v.number(), contentType: v.optional(v.string()), sha256: v.string(), size: v.number() })。ctx.db.system.query("_storage") 会列出所有已存储的文件,可用于查找没有任何 files 行指向的孤立文件。

删除

在同一个 mutation 中同时删除 Blob 和对应的行,避免留下孤立数据。请检查行的所有权,不要轻信客户端。如果多个行可能共用一个 storageId,只有在最后一个引用被移除时才删除 Blob。

// convex/files.ts
export const remove = mutation({
  args: { fileId: v.id("files") },
  returns: v.null(),
  handler: async (ctx, args) => {
    const user = await getCurrentUser(ctx);
    const file = await ctx.db.get(args.fileId);
    if (!file) return null; // idempotent: already gone
    if (file.ownerId !== user._id) throw new Error("Not allowed");

    await ctx.storage.delete(file.storageId);
    await ctx.db.delete(args.fileId);
    return null;
  },
});

常见错误

错误导致的问题正确做法
在文档中保存 getUrl 返回的 URL 或上传 URL上传 URL 一小时后过期;删除后,已保存的 URL 会失效保存 Id<"_storage">,并在查询中使用 getUrl 解析
将存储 ID 声明为 v.string()失去类型安全性,还会接受无效值使用 v.id("_storage")
在 mutation 中调用 ctx.storage.storemutation 中不可用使用 action 或 HTTP action,然后通过 runMutation 保存
上传 POST 时不设置 Content-Type文件会以 application/octet-stream 提供,图片无法内嵌显示设置 headers: { "Content-Type": file.type }
只在浏览器中校验大小和类型任何人都可以直接向上传 URL 发送 POST在保存文件的 mutation 中再次通过 ctx.db.system.get 检查
使用 ctx.storage.getMetadata(id)已弃用使用 ctx.db.system.get(id)
删除 files 行但不删除 Blob存储费用会持续增加在同一个 mutation 中同时删除两者
通过 HTTP action 上传大文件请求大小上限为 20 MB使用上传 URL 流程

检查清单

  • Schema 存储 v.id("_storage") 以及应用级别的名称、类型、大小和所有者
  • generateUploadUrl 要求用户已登录,且客户端使用 Content-Type: file.type 发送 POST 请求
  • 保存 mutation 通过 ctx.db.system.get 再次验证大小和内容类型,并删除被拒绝的上传文件
  • 查询通过 ctx.storage.getUrl 返回 url,且 UI 会处理 null
  • Actions 使用带有正确 type 的 ctx.storage.store(blob),然后通过 runMutation 保存引用
  • HTTP 服务路由在调用 ctx.storage.get 前检查身份验证
  • 删除 mutation 同时删除 blob 和数据行,并且具有幂等性
  • 不调用 ctx.storage.getMetadata,也不在数据库中持久化 URL 字符串

文档

评论

支持 Markdown