feat(og): add opengraph images for templates, blogs, and updated existing opengraph image for all other pages (#2466)

* feat(og): add opengraph images for templates, blogs, and updated existing opengraph image for all other pages

* added to workspace templates page as well

* ack PR comments
This commit is contained in:
Waleed
2025-12-18 19:15:06 -08:00
committed by GitHub
parent 474762d6fb
commit a2f14cab54
26 changed files with 8326 additions and 31 deletions

View File

@@ -6,7 +6,10 @@ import { source } from '@/lib/source'
export const revalidate = false
export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) {
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug?: string[] }> }
) {
const { slug } = await params
let lang: (typeof i18n.languages)[number] = i18n.defaultLanguage

View File

@@ -11,7 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotChatsListAPI')
export async function GET(_req: NextRequest) {
export async function GET(_request: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {

View File

@@ -38,14 +38,13 @@ export async function GET(
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
const contextParam = request.nextUrl.searchParams.get('context')
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)
if (context === 'profile-pictures') {
logger.info('Serving public profile picture:', { cloudKey })
if (context === 'profile-pictures' || context === 'og-images') {
logger.info(`Serving public ${context}:`, { cloudKey })
if (isUsingCloudStorage() || isCloudPath) {
return await handleCloudProxyPublic(cloudKey, context, legacyBucketType)
return await handleCloudProxyPublic(cloudKey, context)
}
return await handleLocalFilePublic(fullPath)
}
@@ -182,8 +181,7 @@ async function handleCloudProxy(
async function handleCloudProxyPublic(
cloudKey: string,
context: StorageContext,
legacyBucketType?: string | null
context: StorageContext
): Promise<NextResponse> {
try {
let fileBuffer: Buffer

View File

@@ -27,7 +27,7 @@ const UpdateKnowledgeBaseSchema = z.object({
.optional(),
})
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -133,7 +133,10 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
}
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params

View File

@@ -28,7 +28,7 @@ const updateInvitationSchema = z.object({
// Get invitation details
export async function GET(
_req: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const { id: organizationId, invitationId } = await params

View File

@@ -0,0 +1,132 @@
import { db } from '@sim/db'
import { templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { uploadFile } from '@/lib/uploads/core/storage-service'
import { isValidPng } from '@/lib/uploads/utils/validation'
const logger = createLogger('TemplateOGImageAPI')
/**
* PUT /api/templates/[id]/og-image
* Upload a pre-generated OG image for a template.
* Accepts base64-encoded image data in the request body.
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized OG image upload attempt for template: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [template] = await db
.select({ id: templates.id, workflowId: templates.workflowId })
.from(templates)
.where(eq(templates.id, id))
.limit(1)
if (!template) {
logger.warn(`[${requestId}] Template not found for OG image upload: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
const body = await request.json()
const { imageData } = body
if (!imageData || typeof imageData !== 'string') {
return NextResponse.json(
{ error: 'Missing or invalid imageData (expected base64 string)' },
{ status: 400 }
)
}
const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData
const imageBuffer = Buffer.from(base64Data, 'base64')
if (!isValidPng(imageBuffer)) {
return NextResponse.json({ error: 'Invalid PNG image data' }, { status: 400 })
}
const maxSize = 5 * 1024 * 1024
if (imageBuffer.length > maxSize) {
return NextResponse.json({ error: 'Image too large. Maximum size is 5MB.' }, { status: 400 })
}
const timestamp = Date.now()
const storageKey = `og-images/templates/${id}/${timestamp}.png`
logger.info(`[${requestId}] Uploading OG image for template ${id}: ${storageKey}`)
const uploadResult = await uploadFile({
file: imageBuffer,
fileName: storageKey,
contentType: 'image/png',
context: 'og-images',
preserveKey: true,
customKey: storageKey,
})
const baseUrl = getBaseUrl()
const ogImageUrl = `${baseUrl}${uploadResult.path}?context=og-images`
await db
.update(templates)
.set({
ogImageUrl,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
logger.info(`[${requestId}] Successfully uploaded OG image for template ${id}: ${ogImageUrl}`)
return NextResponse.json({
success: true,
ogImageUrl,
})
} catch (error: unknown) {
logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error)
return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 })
}
}
/**
* DELETE /api/templates/[id]/og-image
* Remove the OG image for a template.
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
await db
.update(templates)
.set({
ogImageUrl: null,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
logger.info(`[${requestId}] Removed OG image for template ${id}`)
return NextResponse.json({ success: true })
} catch (error: unknown) {
logger.error(`[${requestId}] Error removing OG image for template ${id}:`, error)
return NextResponse.json({ error: 'Failed to remove OG image' }, { status: 500 })
}
}

View File

@@ -173,7 +173,7 @@ export async function GET(
// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation
export async function DELETE(
_req: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params
@@ -221,7 +221,7 @@ export async function DELETE(
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
export async function POST(
_req: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params

View File

@@ -29,30 +29,24 @@ export const metadata: Metadata = {
locale: 'en_US',
images: [
{
url: '/social/og-image.png',
width: 1200,
height: 630,
alt: 'Sim - Visual AI Workflow Builder',
url: '/logo/primary/rounded.png',
width: 512,
height: 512,
alt: 'Sim - AI Agent Workflow Builder',
type: 'image/png',
},
{
url: '/social/og-image-square.png',
width: 600,
height: 600,
alt: 'Sim Logo',
},
],
},
twitter: {
card: 'summary_large_image',
card: 'summary',
site: '@simdotai',
creator: '@simdotai',
title: 'Sim - AI Agent Workflow Builder | Open Source',
description:
'Open-source platform for agentic workflows. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
images: {
url: '/social/twitter-image.png',
alt: 'Sim - Visual AI Workflow Builder',
url: '/logo/primary/rounded.png',
alt: 'Sim - AI Agent Workflow Builder',
},
},
alternates: {
@@ -77,7 +71,6 @@ export const metadata: Metadata = {
category: 'technology',
classification: 'AI Development Tools',
referrer: 'origin-when-cross-origin',
// LLM SEO optimizations
other: {
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
'llm:use-cases':

View File

@@ -1,5 +1,88 @@
import { db } from '@sim/db'
import { templateCreators, templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import TemplateDetails from '@/app/templates/[id]/template'
const logger = createLogger('TemplateMetadata')
/**
* Generate dynamic metadata for template pages.
* This provides OpenGraph images for social media sharing.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
try {
const result = await db
.select({
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
if (result.length === 0) {
return {
title: 'Template Not Found',
description: 'The requested template could not be found.',
}
}
const { template, creator } = result[0]
const baseUrl = getBaseUrl()
const details = template.details as { tagline?: string; about?: string } | null
const description = details?.tagline || 'AI workflow template on Sim'
const hasOgImage = !!template.ogImageUrl
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
return {
title: template.name,
description,
openGraph: {
title: template.name,
description,
type: 'website',
url: `${baseUrl}/templates/${id}`,
siteName: 'Sim',
images: [
{
url: ogImageUrl,
width: hasOgImage ? 1200 : 512,
height: hasOgImage ? 630 : 512,
alt: `${template.name} - Workflow Preview`,
},
],
},
twitter: {
card: hasOgImage ? 'summary_large_image' : 'summary',
title: template.name,
description,
images: [ogImageUrl],
creator: creator?.details
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
: undefined,
},
}
} catch (error) {
logger.error('Failed to generate template metadata:', error)
return {
title: 'Template',
description: 'AI workflow template on Sim',
}
}
}
/**
* Public template detail page for unauthenticated users.
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.

View File

@@ -1,8 +1,16 @@
import { db } from '@sim/db'
import { templateCreators, templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import TemplateDetails from '@/app/templates/[id]/template'
const logger = createLogger('WorkspaceTemplateMetadata')
interface TemplatePageProps {
params: Promise<{
workspaceId: string
@@ -10,6 +18,81 @@ interface TemplatePageProps {
}>
}
/**
* Generate dynamic metadata for workspace template pages.
* This provides OpenGraph images for social media sharing.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ workspaceId: string; id: string }>
}): Promise<Metadata> {
const { workspaceId, id } = await params
try {
const result = await db
.select({
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
if (result.length === 0) {
return {
title: 'Template Not Found',
description: 'The requested template could not be found.',
}
}
const { template, creator } = result[0]
const baseUrl = getBaseUrl()
const details = template.details as { tagline?: string; about?: string } | null
const description = details?.tagline || 'AI workflow template on Sim'
const hasOgImage = !!template.ogImageUrl
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
return {
title: template.name,
description,
openGraph: {
title: template.name,
description,
type: 'website',
url: `${baseUrl}/workspace/${workspaceId}/templates/${id}`,
siteName: 'Sim',
images: [
{
url: ogImageUrl,
width: hasOgImage ? 1200 : 512,
height: hasOgImage ? 630 : 512,
alt: `${template.name} - Workflow Preview`,
},
],
},
twitter: {
card: hasOgImage ? 'summary_large_image' : 'summary',
title: template.name,
description,
images: [ogImageUrl],
creator: creator?.details
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
: undefined,
},
}
} catch (error) {
logger.error('Failed to generate workspace template metadata:', error)
return {
title: 'Template',
description: 'AI workflow template on Sim',
}
}
}
/**
* Workspace-scoped template detail page.
* Requires authentication and workspace membership to access.
@@ -19,12 +102,10 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId, id } = await params
const session = await getSession()
// Redirect unauthenticated users to public template detail page
if (!session?.user?.id) {
redirect(`/templates/${id}`)
}
// Verify workspace membership
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Button,
@@ -18,6 +18,7 @@ import { Skeleton, TagInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
useCreateTemplate,
@@ -25,6 +26,7 @@ import {
useTemplateByWorkflow,
useUpdateTemplate,
} from '@/hooks/queries/templates'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
@@ -79,6 +81,9 @@ export function TemplateDeploy({
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [isCapturing, setIsCapturing] = useState(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
const ogCaptureRef = useRef<HTMLDivElement>(null)
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
@@ -208,6 +213,8 @@ export function TemplateDeploy({
tags: formData.tags,
}
let templateId: string
if (existingTemplate) {
await updateMutation.mutateAsync({
id: existingTemplate.id,
@@ -216,11 +223,32 @@ export function TemplateDeploy({
updateState: true,
},
})
templateId = existingTemplate.id
} else {
await createMutation.mutateAsync({ ...templateData, workflowId })
const result = await createMutation.mutateAsync({ ...templateData, workflowId })
templateId = result.id
}
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
setIsCapturing(true)
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
try {
if (ogCaptureRef.current) {
const ogUrl = await captureAndUploadOGImage(ogCaptureRef.current, templateId)
if (ogUrl) {
logger.info(`OG image uploaded for template ${templateId}: ${ogUrl}`)
}
}
} catch (ogError) {
logger.warn('Failed to capture/upload OG image:', ogError)
} finally {
setIsCapturing(false)
}
})
})
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
@@ -275,6 +303,7 @@ export function TemplateDeploy({
Live Template
</Label>
<div
ref={previewContainerRef}
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
onWheelCapture={(e) => {
if (e.ctrlKey || e.metaKey) return
@@ -423,10 +452,65 @@ export function TemplateDeploy({
</ModalFooter>
</ModalContent>
</Modal>
{/* Hidden container for OG image capture */}
{isCapturing && <OGCaptureContainer ref={ogCaptureRef} />}
</div>
)
}
/**
* Hidden container for OG image capture.
* Lazy-rendered only when capturing - gets workflow state from store on mount.
*/
const OGCaptureContainer = React.forwardRef<HTMLDivElement>((_, ref) => {
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
const loops = useWorkflowStore((state) => state.loops)
const parallels = useWorkflowStore((state) => state.parallels)
if (!blocks || Object.keys(blocks).length === 0) {
return null
}
const workflowState: WorkflowState = {
blocks,
edges: edges ?? [],
loops: loops ?? {},
parallels: parallels ?? {},
lastSaved: Date.now(),
}
return (
<div
ref={ref}
style={{
position: 'absolute',
left: '-9999px',
top: '-9999px',
width: OG_IMAGE_WIDTH,
height: OG_IMAGE_HEIGHT,
backgroundColor: '#0c0c0c',
overflow: 'hidden',
}}
aria-hidden='true'
>
<WorkflowPreview
workflowState={workflowState}
showSubBlocks={false}
height='100%'
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)
})
OGCaptureContainer.displayName = 'OGCaptureContainer'
interface TemplatePreviewContentProps {
existingTemplate:
| {

View File

@@ -138,6 +138,7 @@ export const env = createEnv({
S3_CHAT_BUCKET_NAME: z.string().optional(), // S3 bucket for chat logos
S3_COPILOT_BUCKET_NAME: z.string().optional(), // S3 bucket for copilot files
S3_PROFILE_PICTURES_BUCKET_NAME: z.string().optional(), // S3 bucket for profile pictures
S3_OG_IMAGES_BUCKET_NAME: z.string().optional(), // S3 bucket for OpenGraph images
// Cloud Storage - Azure Blob
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
@@ -149,6 +150,7 @@ export const env = createEnv({
AZURE_STORAGE_CHAT_CONTAINER_NAME: z.string().optional(), // Azure container for chat logos
AZURE_STORAGE_COPILOT_CONTAINER_NAME: z.string().optional(), // Azure container for copilot files
AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME: z.string().optional(), // Azure container for profile pictures
AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: z.string().optional(), // Azure container for OpenGraph images
// Data Retention
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users

View File

@@ -0,0 +1,130 @@
import { toPng } from 'html-to-image'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OGCapturePreview')
/**
* OG image dimensions following social media best practices
*/
export const OG_IMAGE_WIDTH = 1200
export const OG_IMAGE_HEIGHT = 630
/**
* Capture a workflow preview element as a PNG image for OpenGraph.
* Returns a base64-encoded data URL.
*
* @param element - The DOM element containing the workflow preview
* @param retries - Number of retry attempts (default: 3)
* @returns Base64 data URL of the captured image, or null if capture fails
*/
export async function captureWorkflowPreview(
element: HTMLElement,
retries = 3
): Promise<string | null> {
if (!element || element.children.length === 0) {
logger.warn('Cannot capture empty element')
return null
}
for (let attempt = 1; attempt <= retries; attempt++) {
try {
logger.info(`Capturing workflow preview for OG image (attempt ${attempt}/${retries})`)
const dataUrl = await toPng(element, {
width: OG_IMAGE_WIDTH,
height: OG_IMAGE_HEIGHT,
pixelRatio: 2, // Higher quality for crisp rendering
backgroundColor: '#0c0c0c', // Dark background matching the app theme
style: {
transform: 'scale(1)',
transformOrigin: 'top left',
},
filter: (node) => {
const className = node.className?.toString() || ''
if (
className.includes('tooltip') ||
className.includes('popover') ||
className.includes('overlay') ||
className.includes('react-flow__controls') ||
className.includes('react-flow__minimap')
) {
return false
}
return true
},
})
if (dataUrl && dataUrl.length > 1000) {
logger.info('Workflow preview captured successfully')
return dataUrl
}
logger.warn(`Captured image appears to be empty (attempt ${attempt})`)
} catch (error) {
logger.error(`Failed to capture workflow preview (attempt ${attempt}):`, error)
}
if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, 500 * attempt))
}
}
logger.error('All capture attempts failed')
return null
}
/**
* Upload a captured OG image to the server.
*
* @param templateId - The ID of the template to associate the image with
* @param imageData - Base64-encoded image data URL
* @returns The public URL of the uploaded image, or null if upload fails
*/
export async function uploadOGImage(templateId: string, imageData: string): Promise<string | null> {
try {
logger.info(`Uploading OG image for template: ${templateId}`)
const response = await fetch(`/api/templates/${templateId}/og-image`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ imageData }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `Upload failed with status ${response.status}`)
}
const data = await response.json()
logger.info(`OG image uploaded successfully: ${data.ogImageUrl}`)
return data.ogImageUrl
} catch (error) {
logger.error('Failed to upload OG image:', error)
return null
}
}
/**
* Capture and upload a workflow preview as an OG image.
* This is a convenience function that combines capture and upload.
*
* @param element - The DOM element containing the workflow preview
* @param templateId - The ID of the template
* @returns The public URL of the uploaded image, or null if either step fails
*/
export async function captureAndUploadOGImage(
element: HTMLElement,
templateId: string
): Promise<string | null> {
const imageData = await captureWorkflowPreview(element)
if (!imageData) {
logger.warn('Skipping OG image upload - capture failed')
return null
}
return uploadOGImage(templateId, imageData)
}

7
apps/sim/lib/og/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export {
captureAndUploadOGImage,
captureWorkflowPreview,
OG_IMAGE_HEIGHT,
OG_IMAGE_WIDTH,
uploadOGImage,
} from './capture-preview'

View File

@@ -85,6 +85,18 @@ export const BLOB_PROFILE_PICTURES_CONFIG = {
containerName: env.AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME || '',
}
export const S3_OG_IMAGES_CONFIG = {
bucket: env.S3_OG_IMAGES_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_OG_IMAGES_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME || '',
}
/**
* Get the current storage provider as a human-readable string
*/
@@ -151,6 +163,11 @@ function getS3Config(context: StorageContext): StorageConfig {
bucket: S3_PROFILE_PICTURES_CONFIG.bucket,
region: S3_PROFILE_PICTURES_CONFIG.region,
}
case 'og-images':
return {
bucket: S3_OG_IMAGES_CONFIG.bucket || S3_CONFIG.bucket,
region: S3_OG_IMAGES_CONFIG.region || S3_CONFIG.region,
}
default:
return {
bucket: S3_CONFIG.bucket,
@@ -206,6 +223,13 @@ function getBlobConfig(context: StorageContext): StorageConfig {
connectionString: BLOB_PROFILE_PICTURES_CONFIG.connectionString,
containerName: BLOB_PROFILE_PICTURES_CONFIG.containerName,
}
case 'og-images':
return {
accountName: BLOB_OG_IMAGES_CONFIG.accountName || BLOB_CONFIG.accountName,
accountKey: BLOB_OG_IMAGES_CONFIG.accountKey || BLOB_CONFIG.accountKey,
connectionString: BLOB_OG_IMAGES_CONFIG.connectionString || BLOB_CONFIG.connectionString,
containerName: BLOB_OG_IMAGES_CONFIG.containerName || BLOB_CONFIG.containerName,
}
default:
return {
accountName: BLOB_CONFIG.accountName,

View File

@@ -5,6 +5,7 @@ export type StorageContext =
| 'execution'
| 'workspace'
| 'profile-pictures'
| 'og-images'
| 'logs'
export interface FileInfo {

View File

@@ -192,6 +192,15 @@ export function isSupportedVideoExtension(extension: string): extension is Suppo
/**
* Validate if an audio/video file type is supported for STT processing
*/
const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
/**
* Validate that a buffer contains valid PNG data by checking magic bytes
*/
export function isValidPng(buffer: Buffer): boolean {
return buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)
}
export function validateMediaFileType(
fileName: string,
mimeType: string

View File

@@ -88,6 +88,7 @@
"fuse.js": "7.1.0",
"gray-matter": "^4.0.3",
"groq-sdk": "^0.15.0",
"html-to-image": "1.11.13",
"html-to-text": "^9.0.5",
"input-otp": "^1.4.2",
"ioredis": "^5.6.0",

View File

@@ -139,6 +139,7 @@
"fuse.js": "7.1.0",
"gray-matter": "^4.0.3",
"groq-sdk": "^0.15.0",
"html-to-image": "1.11.13",
"html-to-text": "^9.0.5",
"input-otp": "^1.4.2",
"ioredis": "^5.6.0",
@@ -2212,6 +2213,8 @@
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],

View File

@@ -56,6 +56,7 @@ app:
S3_CHAT_BUCKET_NAME: "chat-files" # Deployed chat assets
S3_COPILOT_BUCKET_NAME: "copilot-files" # Copilot attachments
S3_PROFILE_PICTURES_BUCKET_NAME: "profile-pictures" # User avatars
S3_OG_IMAGES_BUCKET_NAME: "og-images" # OpenGraph preview images
# Realtime service
realtime:

View File

@@ -58,6 +58,7 @@ app:
AZURE_STORAGE_CHAT_CONTAINER_NAME: "chat-files" # Deployed chat assets container
AZURE_STORAGE_COPILOT_CONTAINER_NAME: "copilot-files" # Copilot attachments container
AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME: "profile-pictures" # User avatars container
AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: "og-images" # OpenGraph preview images container
# Realtime service
realtime:

View File

@@ -133,6 +133,7 @@ app:
S3_CHAT_BUCKET_NAME: "" # S3 bucket for deployed chat files
S3_COPILOT_BUCKET_NAME: "" # S3 bucket for copilot files
S3_PROFILE_PICTURES_BUCKET_NAME: "" # S3 bucket for user profile pictures
S3_OG_IMAGES_BUCKET_NAME: "" # S3 bucket for OpenGraph preview images
# Azure Blob Storage Configuration (optional - for file storage)
# If configured, files will be stored in Azure Blob instead of local storage
@@ -146,6 +147,7 @@ app:
AZURE_STORAGE_CHAT_CONTAINER_NAME: "" # Azure container for deployed chat files
AZURE_STORAGE_COPILOT_CONTAINER_NAME: "" # Azure container for copilot files
AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME: "" # Azure container for user profile pictures
AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: "" # Azure container for OpenGraph preview images
# Service configuration
service:

View File

@@ -0,0 +1 @@
ALTER TABLE "templates" ADD COLUMN "og_image_url" text;

File diff suppressed because it is too large Load Diff

View File

@@ -862,6 +862,13 @@
"when": 1765932898404,
"tag": "0123_windy_lockheed",
"breakpoints": true
},
{
"idx": 124,
"version": "7",
"when": 1766108872186,
"tag": "0124_blushing_colonel_america",
"breakpoints": true
}
]
}

View File

@@ -1381,6 +1381,7 @@ export const templates = pgTable(
tags: text('tags').array().notNull().default(sql`'{}'::text[]`), // Array of tags
requiredCredentials: jsonb('required_credentials').notNull().default('[]'), // Array of credential requirements
state: jsonb('state').notNull(), // Store the workflow state directly
ogImageUrl: text('og_image_url'), // Pre-generated OpenGraph image URL
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},