mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
132
apps/sim/app/api/templates/[id]/og-image/route.ts
Normal file
132
apps/sim/app/api/templates/[id]/og-image/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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:
|
||||
| {
|
||||
|
||||
@@ -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
|
||||
|
||||
130
apps/sim/lib/og/capture-preview.ts
Normal file
130
apps/sim/lib/og/capture-preview.ts
Normal 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
7
apps/sim/lib/og/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
captureAndUploadOGImage,
|
||||
captureWorkflowPreview,
|
||||
OG_IMAGE_HEIGHT,
|
||||
OG_IMAGE_WIDTH,
|
||||
uploadOGImage,
|
||||
} from './capture-preview'
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ export type StorageContext =
|
||||
| 'execution'
|
||||
| 'workspace'
|
||||
| 'profile-pictures'
|
||||
| 'og-images'
|
||||
| 'logs'
|
||||
|
||||
export interface FileInfo {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
1
packages/db/migrations/0124_blushing_colonel_america.sql
Normal file
1
packages/db/migrations/0124_blushing_colonel_america.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "templates" ADD COLUMN "og_image_url" text;
|
||||
7728
packages/db/migrations/meta/0124_snapshot.json
Normal file
7728
packages/db/migrations/meta/0124_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user