关于 convex-file-storage
面向 Convex 开发者的文件存储技能指南,介绍文件上传、元数据保存、访问和删除流程,并强调在数据库中保存存储 ID,而非 URL。
主要功能
- 01生成短期上传 URL 并保存存储 ID
- 02通过 Action 保存 Blob
- 03在查询中解析文件 URL
- 04通过 HTTP Action 提供文件
- 05验证文件类型、大小及所有权
适用场景
- 01为应用实现带所有权检查的文件上传
- 02在查询结果中提供文件链接
- 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.store | mutation 中不可用 | 使用 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 字符串