A Convex component for tracking UploadThing files with access control, expiration, and webhook verification.
UploadThing handles file storage. This component adds the metadata layer: who uploaded what, who can see it, and when it expires.
- File tracking -- stores URL, key, name, size, MIME type, and upload time for every file
- User association -- ties each file to a
userIdfor ownership and dashboards - Access control -- per-file and per-folder visibility rules (public / private / restricted)
- Expiration -- configurable TTL by file, MIME type, file type, or a global default
- Replacement -- re-uploading with the same key updates the record in place
- Tags and filters -- tag files and query by user, folder, tag, or MIME type
- Cross-user queries -- list files across all users for galleries, feeds, and shared boards
- On-demand deletion -- delete specific file records by key
- Remote cleanup -- optionally delete files from UploadThing servers when they expire
- Webhook verification -- HMAC SHA-256 signature validation for UploadThing callbacks
- Cleanup -- batch deletion of expired file records
- Custom metadata -- store and retrieve arbitrary metadata on file records
- Usage stats -- total files and bytes per user
npm install @mzedstudio/uploadthingtrack// convex/convex.config.ts
import { defineApp } from "convex/server";
import uploadthingFileTracker from "@mzedstudio/uploadthingtrack/convex.config.js";
const app = defineApp();
app.use(uploadthingFileTracker, { name: "uploadthingFileTracker" });
export default app;// convex/uploadthing.ts
import { UploadThingFiles } from "@mzedstudio/uploadthingtrack";
import { components } from "./_generated/api";
const uploadthing = new UploadThingFiles(components.uploadthingFileTracker);// convex/http.ts
import { httpRouter } from "convex/server";
import { registerRoutes } from "@mzedstudio/uploadthingtrack";
import { components } from "./_generated/api";
const http = httpRouter();
registerRoutes(http, components.uploadthingFileTracker);
export default http;Set UPLOADTHING_API_KEY as an environment variable in the Convex dashboard. The webhook handler reads it automatically.
export const setup = mutation({
handler: async (ctx) => {
await uploadthing.setConfig(ctx, {
config: {
uploadthingApiKey: process.env.UPLOADTHING_API_KEY,
defaultTtlMs: 30 * 24 * 60 * 60 * 1000, // 30 days
ttlByMimeType: { "image/png": 90 * 24 * 60 * 60 * 1000 },
ttlByFileType: { avatar: 365 * 24 * 60 * 60 * 1000 },
deleteRemoteOnExpire: true, // also delete from UploadThing servers
},
});
},
});import { query } from "./_generated/server";
import { v } from "convex/values";
export const listMyFiles = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
return await uploadthing.listFiles(ctx, {
ownerUserId: args.userId,
viewerUserId: args.userId,
});
},
});
export const getFile = query({
args: { key: v.string(), viewerUserId: v.optional(v.string()) },
handler: async (ctx, args) => {
return await uploadthing.getFile(ctx, args);
},
});List files across all users -- useful for galleries, public feeds, and shared boards:
export const publicGallery = query({
args: { viewerUserId: v.optional(v.string()) },
handler: async (ctx, args) => {
return await uploadthing.listAllFiles(ctx, {
viewerUserId: args.viewerUserId,
folder: "gallery",
limit: 20,
});
},
});listAllFiles applies the same access control as listFiles -- viewers only see files they have permission to access. All filters (folder, tag, mimeType, includeExpired) are supported.
import { mutation } from "./_generated/server";
export const trackFile = mutation({
args: { /* ... */ },
handler: async (ctx, args) => {
await uploadthing.upsertFile(ctx, {
file: {
key: args.key,
url: args.url,
name: args.name,
size: args.size,
mimeType: args.mimeType,
},
userId: args.userId,
options: {
folder: "uploads",
tags: ["document"],
metadata: { uploaderName: args.displayName },
},
});
},
});Delete specific file records by key:
export const removeFiles = mutation({
args: { keys: v.array(v.string()) },
handler: async (ctx, args) => {
const count = await uploadthing.deleteFiles(ctx, { keys: args.keys });
// count = number of records actually deleted
},
});// Make a file public
await uploadthing.setFileAccess(ctx, {
key: "file_abc",
access: { visibility: "public" },
});
// Restrict a folder to specific users
await uploadthing.setFolderAccess(ctx, {
folder: "team-docs",
access: {
visibility: "restricted",
allowUserIds: ["user_1", "user_2"],
},
});
// Remove a file-level rule (falls back to folder rule)
await uploadthing.setFileAccess(ctx, { key: "file_abc", access: null });File-level rules always override folder-level rules. Deny lists take precedence over allow lists.
// By tag
await uploadthing.listFiles(ctx, {
ownerUserId: userId,
tag: "avatar",
});
// By MIME type
await uploadthing.listFiles(ctx, {
ownerUserId: userId,
mimeType: "image/png",
});
// By folder
await uploadthing.listFiles(ctx, {
ownerUserId: userId,
folder: "documents",
});const stats = await uploadthing.getUsageStats(ctx, { userId });
// { totalFiles: 42, totalBytes: 1048576 }import { action } from "./_generated/server";
export const cleanup = action({
handler: async (ctx) => {
// Preview what would be deleted
const preview = await uploadthing.cleanupExpired(ctx, { dryRun: true });
// Actually delete expired records
const result = await uploadthing.cleanupExpired(ctx, { batchSize: 100 });
// { deletedCount: 12, keys: [...], hasMore: false }
},
});When deleteRemoteOnExpire is enabled in config, cleanupExpired also calls the UploadThing API to delete files from their servers before removing local records. If remote deletion fails, local records are preserved so the next run can retry. Check remoteDeleteFailed and remoteDeleteError in the return value for details.
When determining a file's expiration, the first match wins:
- Explicit
expiresAttimestamp - Per-file
ttlMs ttlByFileTypefrom configttlByMimeTypefrom configdefaultTtlMsfrom config- No expiration
| Method | Context | Description |
|---|---|---|
upsertFile(ctx, args) |
mutation | Insert or replace a file record by key |
getFile(ctx, args) |
query | Get a file by key with access control |
listFiles(ctx, args) |
query | List files for a specific user with filters |
listAllFiles(ctx, args) |
query | List files across all users with access control |
deleteFiles(ctx, args) |
mutation | Delete specific file records by key |
setFileAccess(ctx, args) |
mutation | Set or clear file-level access rules |
setFolderAccess(ctx, args) |
mutation | Set or clear folder-level access rules |
getFolderRule(ctx, args) |
query | Get access rule for a folder |
listFolderRules(ctx, args) |
query | List all folder access rules |
setConfig(ctx, args) |
mutation | Update component configuration |
getConfig(ctx) |
query | Read current configuration |
getUsageStats(ctx, args) |
query | Get total files and bytes for a user |
cleanupExpired(ctx, args) |
action | Delete expired file records (and optionally remote files) |
handleCallback(ctx, args) |
action | Handle an UploadThing webhook |
| Option | Type | Description |
|---|---|---|
uploadthingApiKey |
string |
API key for webhook verification and remote deletion |
defaultTtlMs |
number |
Default TTL in milliseconds for all files |
ttlByMimeType |
Record<string, number> |
TTL overrides by MIME type |
ttlByFileType |
Record<string, number> |
TTL overrides by custom file type |
deleteRemoteOnExpire |
boolean |
Delete files from UploadThing servers on expiration |
deleteBatchSize |
number |
Max files per cleanup batch (default: 100) |
Mounts the UploadThing webhook at /webhooks/uploadthing (configurable via options.path).
AccessRule--{ visibility, allowUserIds?, denyUserIds? }FileInfo--{ key, url, name, size, mimeType, ... }FileUpsertOptions--{ tags?, folder?, access?, metadata?, expiresAt?, ttlMs?, fileType? }ConfigUpdate--{ uploadthingApiKey?, defaultTtlMs?, ttlByMimeType?, ... }
Validators (accessRuleValidator, fileInfoValidator, etc.) are also exported for use in your own function definitions.
This component exports a test helper for use with convex-test:
import { convexTest } from "convex-test";
import { register } from "@mzedstudio/uploadthingtrack/test";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
test("my test", async () => {
const t = convexTest(schema, modules);
register(t, "uploadthingFileTracker");
// ... test your functions that use the component
});Apache-2.0