New and Updated: ConvexFS Media Library with Bunny CDN integration ,OpenCode AI development tool integration, AI image generation download and copy options

This commit is contained in:
Wayne Sutton
2026-01-10 15:53:27 -08:00
parent d5d8de0058
commit 95cc8a4677
43 changed files with 5941 additions and 526 deletions

View File

@@ -18,6 +18,8 @@ import type * as contactActions from "../contactActions.js";
import type * as crons from "../crons.js";
import type * as embeddings from "../embeddings.js";
import type * as embeddingsQueries from "../embeddingsQueries.js";
import type * as files from "../files.js";
import type * as fs from "../fs.js";
import type * as http from "../http.js";
import type * as importAction from "../importAction.js";
import type * as newsletter from "../newsletter.js";
@@ -48,6 +50,8 @@ declare const fullApi: ApiFromModules<{
crons: typeof crons;
embeddings: typeof embeddings;
embeddingsQueries: typeof embeddingsQueries;
files: typeof files;
fs: typeof fs;
http: typeof http;
importAction: typeof importAction;
newsletter: typeof newsletter;
@@ -682,4 +686,568 @@ export declare const components: {
>;
};
};
fs: {
lib: {
commitFiles: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
files: Array<{
attributes?: { expiresAt?: number };
basis?: null | string;
blobId: string;
path: string;
}>;
},
null
>;
copyByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
destPath: string;
sourcePath: string;
},
null
>;
deleteByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
path: string;
},
null
>;
getDownloadUrl: FunctionReference<
"action",
"internal",
{
blobId: string;
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
extraParams?: Record<string, string>;
},
string
>;
list: FunctionReference<
"query",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
prefix?: string;
},
{
continueCursor: string;
isDone: boolean;
page: Array<{
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
}>;
}
>;
moveByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
destPath: string;
sourcePath: string;
},
null
>;
registerPendingUpload: FunctionReference<
"mutation",
"internal",
{
blobId: string;
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
contentType: string;
size: number;
},
null
>;
stat: FunctionReference<
"query",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
path: string;
},
null | {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
}
>;
transact: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
ops: Array<
| {
dest: { basis?: null | string; path: string };
op: "move";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
dest: { basis?: null | string; path: string };
op: "copy";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
op: "delete";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
attributes: { expiresAt?: null | number };
op: "setAttributes";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
>;
},
null
>;
};
ops: {
basics: {
copyByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
destPath: string;
sourcePath: string;
},
null
>;
deleteByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
path: string;
},
null
>;
list: FunctionReference<
"query",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
prefix?: string;
},
{
continueCursor: string;
isDone: boolean;
page: Array<{
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
}>;
}
>;
moveByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
destPath: string;
sourcePath: string;
},
null
>;
stat: FunctionReference<
"query",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
path: string;
},
null | {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
}
>;
};
transact: {
commitFiles: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
files: Array<{
attributes?: { expiresAt?: number };
basis?: null | string;
blobId: string;
path: string;
}>;
},
null
>;
transact: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
ops: Array<
| {
dest: { basis?: null | string; path: string };
op: "move";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
dest: { basis?: null | string; path: string };
op: "copy";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
op: "delete";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
attributes: { expiresAt?: null | number };
op: "setAttributes";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
>;
},
null
>;
};
};
transfer: {
getDownloadUrl: FunctionReference<
"action",
"internal",
{
blobId: string;
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
extraParams?: Record<string, string>;
},
string
>;
registerPendingUpload: FunctionReference<
"mutation",
"internal",
{
blobId: string;
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
contentType: string;
size: number;
},
null
>;
};
};
};

View File

@@ -1,6 +1,7 @@
import { defineApp } from "convex/server";
import aggregate from "@convex-dev/aggregate/convex.config.js";
import persistentTextStreaming from "@convex-dev/persistent-text-streaming/convex.config";
import fs from "convex-fs/convex.config.js";
const app = defineApp();
@@ -16,5 +17,8 @@ app.use(aggregate, { name: "uniqueVisitors" });
// Persistent text streaming for real-time AI responses in Ask AI feature
app.use(persistentTextStreaming);
// ConvexFS for file storage with Bunny CDN
app.use(fs);
export default app;

222
convex/files.ts Normal file
View File

@@ -0,0 +1,222 @@
import { mutation, query, action } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
import { fs, isBunnyConfigured } from "./fs";
// Allowed image MIME types
const ALLOWED_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
];
// Max file size in bytes (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024;
// Check if media uploads are configured
export const isConfigured = query({
args: {},
handler: async () => {
return { configured: isBunnyConfigured };
},
});
// Commit uploaded file to storage path
export const commitFile = action({
args: {
blobId: v.string(),
filename: v.string(),
contentType: v.string(),
size: v.number(),
width: v.optional(v.number()),
height: v.optional(v.number()),
},
handler: async (ctx, args) => {
if (!fs) {
throw new Error(
"Media uploads not configured. Set BUNNY_API_KEY, BUNNY_STORAGE_ZONE, and BUNNY_CDN_HOSTNAME in Convex Dashboard."
);
}
// Validate file type
if (!ALLOWED_TYPES.includes(args.contentType)) {
throw new Error(
`Invalid file type: ${args.contentType}. Allowed: ${ALLOWED_TYPES.join(", ")}`
);
}
// Validate file size
if (args.size > MAX_FILE_SIZE) {
throw new Error(
`File too large: ${(args.size / 1024 / 1024).toFixed(2)}MB. Max: 10MB`
);
}
// Sanitize filename (remove special chars, preserve extension)
const sanitizedName = args.filename
.replace(/[^a-zA-Z0-9.-]/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
// Create unique path with timestamp
const timestamp = Date.now();
const path = `/uploads/${timestamp}-${sanitizedName}`;
// Commit file to ConvexFS
await fs.commitFiles(ctx, [{ path, blobId: args.blobId }]);
return {
path,
filename: sanitizedName,
contentType: args.contentType,
size: args.size,
width: args.width,
height: args.height,
};
},
});
// List files with pagination
export const listFiles = query({
args: {
prefix: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
if (!fs) {
// Return empty results when not configured
return {
page: [],
isDone: true,
continueCursor: "",
};
}
return await fs.list(ctx, {
prefix: args.prefix ?? "/uploads/",
paginationOpts: args.paginationOpts,
});
},
});
// Get file info by path
export const getFileInfo = query({
args: { path: v.string() },
handler: async (ctx, args) => {
if (!fs) {
return null;
}
const file = await fs.stat(ctx, args.path);
if (!file) {
return null;
}
return {
path: file.path,
blobId: file.blobId,
contentType: file.contentType,
size: file.size,
};
},
});
// Get signed download URL for a file
export const getDownloadUrl = action({
args: { path: v.string() },
handler: async (ctx, args) => {
if (!fs) {
throw new Error("Media uploads not configured");
}
const file = await fs.stat(ctx, args.path);
if (!file) {
throw new Error("File not found");
}
// Generate time-limited signed URL
const url = await fs.getDownloadUrl(ctx, file.blobId);
return { url, expiresIn: 3600 };
},
});
// Delete file by path
export const deleteFile = mutation({
args: { path: v.string() },
handler: async (ctx, args) => {
if (!fs) {
throw new Error("Media uploads not configured");
}
await fs.delete(ctx, args.path);
return { success: true };
},
});
// Delete multiple files at once
export const deleteFiles = mutation({
args: { paths: v.array(v.string()) },
handler: async (ctx, args) => {
if (!fs) {
throw new Error("Media uploads not configured");
}
let deleted = 0;
for (const path of args.paths) {
await fs.delete(ctx, path);
deleted++;
}
return { success: true, deleted };
},
});
// Set file expiration
export const setFileExpiration = action({
args: {
path: v.string(),
expiresInMs: v.optional(v.number()), // null to remove expiration
},
handler: async (ctx, args) => {
if (!fs) {
throw new Error("Media uploads not configured");
}
// Get current file info
const file = await fs.stat(ctx, args.path);
if (!file) {
throw new Error("File not found");
}
const expiresAt = args.expiresInMs ? Date.now() + args.expiresInMs : null;
await fs.transact(ctx, [
{
op: "setAttributes",
source: file,
attributes: { expiresAt },
},
]);
return { success: true, expiresAt };
},
});
// Get total file count
export const getFileCount = query({
args: {},
handler: async (ctx) => {
if (!fs) {
return 0;
}
const result = await fs.list(ctx, {
prefix: "/uploads/",
paginationOpts: { numItems: 1000, cursor: null },
});
return result.page.length;
},
});

34
convex/fs.ts Normal file
View File

@@ -0,0 +1,34 @@
import { ConvexFS } from "convex-fs";
import { components } from "./_generated/api";
// Check if Bunny CDN is configured
// All three required env vars must be set
export const isBunnyConfigured =
!!process.env.BUNNY_API_KEY &&
!!process.env.BUNNY_STORAGE_ZONE &&
!!process.env.BUNNY_CDN_HOSTNAME;
// ConvexFS instance with Bunny.net Edge Storage
// Set these environment variables in Convex Dashboard:
// - BUNNY_API_KEY: Your Bunny.net API key
// - BUNNY_STORAGE_ZONE: Storage zone name (e.g., "my-storage")
// - BUNNY_CDN_HOSTNAME: CDN hostname (e.g., "my-storage.b-cdn.net")
// - BUNNY_TOKEN_KEY: Optional, for signed URLs
// - BUNNY_REGION: Optional, storage region ("ny", "la", "sg", etc.)
// Only create ConvexFS instance if configured
// This prevents validation errors when env vars are not set
export const fs = isBunnyConfigured
? new ConvexFS(components.fs, {
storage: {
type: "bunny",
apiKey: process.env.BUNNY_API_KEY!,
storageZoneName: process.env.BUNNY_STORAGE_ZONE!,
cdnHostname: process.env.BUNNY_CDN_HOSTNAME!,
region: process.env.BUNNY_REGION,
tokenKey: process.env.BUNNY_TOKEN_KEY,
},
downloadUrlTtl: 3600, // URL expiration in seconds (1 hour)
blobGracePeriod: 86400, // Orphaned blobs deleted after 24 hours
})
: null;

View File

@@ -1,8 +1,10 @@
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
import { api, components } from "./_generated/api";
import { rssFeed, rssFullFeed } from "./rss";
import { streamResponse, streamResponseOptions } from "./askAI.node";
import { registerRoutes } from "convex-fs";
import { fs } from "./fs";
const http = httpRouter();
@@ -414,4 +416,24 @@ http.route({
handler: streamResponseOptions,
});
// ConvexFS routes for file uploads/downloads
// Only register routes when Bunny CDN is configured
// - POST /fs/upload - Upload files to Bunny.net storage
// - GET /fs/blobs/{blobId} - Returns 302 redirect to signed CDN URL
if (fs) {
registerRoutes(http, components.fs, fs, {
pathPrefix: "/fs",
uploadAuth: async () => {
// TODO: Add authentication check for production
// const identity = await ctx.auth.getUserIdentity();
// return identity !== null;
return true;
},
downloadAuth: async () => {
// Public downloads - images should be accessible to all
return true;
},
});
}
export default http;