mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
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:
568
convex/_generated/api.d.ts
vendored
568
convex/_generated/api.d.ts
vendored
@@ -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
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
222
convex/files.ts
Normal 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
34
convex/fs.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user