@mzedstudio/uploadthingtrack
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.
Features
- File tracking -- stores URL, key, name, size, MIME type, and upload time for every file
- User association -- ties each file to a
userId for 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
- Webhook verification -- HMAC SHA-256 signature validation for UploadThing callbacks
- Cleanup -- batch deletion of expired file records
- Usage stats -- total files and bytes per user
Installation
npm install @mzedstudio/uploadthingtrack
Setup
1. Register the component
import { defineApp } from "convex/server";
import uploadthingFileTracker from "@mzedstudio/uploadthingtrack/convex.config.js";
const app = defineApp();
app.use(uploadthingFileTracker, { name: "uploadthingFileTracker" });
export default app;
2. Create the client
import { UploadThingFiles } from "@mzedstudio/uploadthingtrack";
import { components } from "./_generated/api";
const uploadthing = new UploadThingFiles(components.uploadthingFileTracker);
3. Mount the webhook route
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.
4. Configure the component (optional)
export const setup = mutation({
handler: async (ctx) => {
await uploadthing.setConfig(ctx, {
config: {
uploadthingApiKey: process.env.UPLOADTHING_API_KEY,
defaultTtlMs: 30 * 24 * 60 * 60 * 1000,
ttlByMimeType: { "image/png": 90 * 24 * 60 * 60 * 1000 },
ttlByFileType: { avatar: 365 * 24 * 60 * 60 * 1000 },
},
});
},
});
Usage
Querying files
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);
},
});
Inserting files manually
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"],
},
});
},
});
Access control
await uploadthing.setFileAccess(ctx, {
key: "file_abc",
access: { visibility: "public" },
});
await uploadthing.setFolderAccess(ctx, {
folder: "team-docs",
access: {
visibility: "restricted",
allowUserIds: ["user_1", "user_2"],
},
});
await uploadthing.setFileAccess(ctx, { key: "file_abc", access: null });
File-level rules always override folder-level rules. Deny lists take precedence over allow lists.
Filtering
await uploadthing.listFiles(ctx, {
ownerUserId: userId,
tag: "avatar",
});
await uploadthing.listFiles(ctx, {
ownerUserId: userId,
mimeType: "image/png",
});
await uploadthing.listFiles(ctx, {
ownerUserId: userId,
folder: "documents",
});
Usage stats
const stats = await uploadthing.getUsageStats(ctx, { userId });
Cleanup
import { action } from "./_generated/server";
export const cleanup = action({
handler: async (ctx) => {
const preview = await uploadthing.cleanupExpired(ctx, { dryRun: true });
const result = await uploadthing.cleanupExpired(ctx, { batchSize: 100 });
},
});
TTL Precedence
When determining a file's expiration, the first match wins:
- Explicit
expiresAt timestamp
- Per-file
ttlMs
ttlByFileType from config
ttlByMimeType from config
defaultTtlMs from config
- No expiration
API Reference
UploadThingFiles class
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 with filters |
setFileAccess(ctx, args) | mutation | Set or clear file-level access rules |
setFolderAccess(ctx, args) | mutation | Set or clear folder-level 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 |
handleCallback(ctx, args) | action | Handle an UploadThing webhook |
registerRoutes(http, component, options?)
Mounts the UploadThing webhook at /webhooks/uploadthing (configurable via options.path).
Exported types
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.
Testing
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");
});
License
Apache-2.0