mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
* fix(core): consolidate ID generation to prevent HTTP self-hosted crashes crypto.randomUUID() requires a secure context (HTTPS) in browsers, causing white-screen crashes on self-hosted HTTP deployments. This replaces all direct usage of crypto.randomUUID(), nanoid, and the uuid package with a central utility that falls back to crypto.getRandomValues() which works in all contexts. - Add generateId(), generateShortId(), isValidUuid() in @/lib/core/utils/uuid - Replace crypto.randomUUID() imports across ~220 server + client files - Replace nanoid imports with generateShortId() - Replace uuid package validate with isValidUuid() - Remove nanoid dependency from apps/sim and packages/testing - Remove browser polyfill script from layout.tsx - Update test mocks to target @/lib/core/utils/uuid - Update CLAUDE.md, AGENTS.md, cursor rules, claude rules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * update bunlock * fix(core): remove UUID_REGEX shim, use isValidUuid directly * fix(core): remove deprecated uuid mock helpers that use vi.doMock --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
import { db } from '@sim/db'
|
|
import {
|
|
templateCreators,
|
|
templateStars,
|
|
templates,
|
|
workflow,
|
|
workflowDeploymentVersion,
|
|
} from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
import { getSession } from '@/lib/auth'
|
|
import { generateRequestId } from '@/lib/core/utils/request'
|
|
import { generateId } from '@/lib/core/utils/uuid'
|
|
import { canAccessTemplate, verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
|
import {
|
|
extractRequiredCredentials,
|
|
sanitizeCredentials,
|
|
} from '@/lib/workflows/credentials/credential-extractor'
|
|
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
|
|
|
const logger = createLogger('TemplatesAPI')
|
|
|
|
export const revalidate = 0
|
|
|
|
// Function to sanitize sensitive data from workflow state
|
|
// Now uses the more comprehensive sanitizeCredentials from credential-extractor
|
|
function sanitizeWorkflowState(state: any): any {
|
|
return sanitizeCredentials(state)
|
|
}
|
|
|
|
// Schema for creating a template
|
|
const CreateTemplateSchema = z.object({
|
|
workflowId: z.string().min(1, 'Workflow ID is required'),
|
|
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
|
|
details: z
|
|
.object({
|
|
tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(),
|
|
about: z.string().optional(), // Markdown long description
|
|
})
|
|
.optional(),
|
|
creatorId: z.string().min(1, 'Creator profile is required'),
|
|
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
|
|
})
|
|
|
|
// Schema for query parameters
|
|
const QueryParamsSchema = z.object({
|
|
limit: z.coerce.number().optional().default(50),
|
|
offset: z.coerce.number().optional().default(0),
|
|
search: z.string().optional(),
|
|
workflowId: z.string().optional(),
|
|
status: z.enum(['pending', 'approved', 'rejected']).optional(),
|
|
includeAllStatuses: z.coerce.boolean().optional().default(false), // For super users
|
|
})
|
|
|
|
// GET /api/templates - Retrieve templates
|
|
export async function GET(request: NextRequest) {
|
|
const requestId = generateRequestId()
|
|
|
|
try {
|
|
const session = await getSession()
|
|
if (!session?.user?.id) {
|
|
logger.warn(`[${requestId}] Unauthorized templates access attempt`)
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
|
|
|
|
// Check if user is a super user
|
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
|
const isSuperUser = effectiveSuperUser
|
|
|
|
// Build query conditions
|
|
const conditions = []
|
|
|
|
// Apply workflow filter if provided (for getting template by workflow)
|
|
// When fetching by workflowId, we want to get the template regardless of status
|
|
// This is used by the deploy modal to check if a template exists
|
|
if (params.workflowId) {
|
|
const authorization = await authorizeWorkflowByWorkspacePermission({
|
|
workflowId: params.workflowId,
|
|
userId: session.user.id,
|
|
action: 'write',
|
|
})
|
|
if (!authorization.allowed) {
|
|
return NextResponse.json(
|
|
{
|
|
data: [],
|
|
pagination: {
|
|
total: 0,
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
page: 1,
|
|
totalPages: 0,
|
|
},
|
|
},
|
|
{ status: 200 }
|
|
)
|
|
}
|
|
conditions.push(eq(templates.workflowId, params.workflowId))
|
|
} else {
|
|
// Apply status filter - only approved templates for non-super users
|
|
if (params.status) {
|
|
if (!isSuperUser && params.status !== 'approved') {
|
|
return NextResponse.json(
|
|
{
|
|
data: [],
|
|
pagination: {
|
|
total: 0,
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
page: 1,
|
|
totalPages: 0,
|
|
},
|
|
},
|
|
{ status: 200 }
|
|
)
|
|
}
|
|
conditions.push(eq(templates.status, params.status))
|
|
} else if (!isSuperUser || !params.includeAllStatuses) {
|
|
// Non-super users and super users without includeAllStatuses flag see only approved templates
|
|
conditions.push(eq(templates.status, 'approved'))
|
|
}
|
|
}
|
|
|
|
// Apply search filter if provided
|
|
if (params.search) {
|
|
const searchTerm = `%${params.search}%`
|
|
conditions.push(
|
|
or(
|
|
ilike(templates.name, searchTerm),
|
|
sql`${templates.details}->>'tagline' ILIKE ${searchTerm}`
|
|
)
|
|
)
|
|
}
|
|
|
|
// Combine conditions
|
|
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
|
|
|
// Apply ordering, limit, and offset with star information
|
|
const results = await db
|
|
.select({
|
|
id: templates.id,
|
|
workflowId: templates.workflowId,
|
|
name: templates.name,
|
|
details: templates.details,
|
|
creatorId: templates.creatorId,
|
|
creator: templateCreators,
|
|
views: templates.views,
|
|
stars: templates.stars,
|
|
status: templates.status,
|
|
tags: templates.tags,
|
|
requiredCredentials: templates.requiredCredentials,
|
|
state: templates.state,
|
|
createdAt: templates.createdAt,
|
|
updatedAt: templates.updatedAt,
|
|
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
|
|
isSuperUser: sql<boolean>`${isSuperUser}`, // Include super user status in response
|
|
})
|
|
.from(templates)
|
|
.leftJoin(
|
|
templateStars,
|
|
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
|
|
)
|
|
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
|
.where(whereCondition)
|
|
.orderBy(desc(templates.views), desc(templates.createdAt))
|
|
.limit(params.limit)
|
|
.offset(params.offset)
|
|
|
|
// Get total count for pagination
|
|
const totalCount = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(templates)
|
|
.where(whereCondition)
|
|
|
|
const total = totalCount[0]?.count || 0
|
|
|
|
const visibleResults =
|
|
params.workflowId && !isSuperUser
|
|
? (
|
|
await Promise.all(
|
|
results.map(async (template) => {
|
|
if (template.status === 'approved') {
|
|
return template
|
|
}
|
|
const access = await canAccessTemplate(template.id, session.user.id)
|
|
return access.allowed ? template : null
|
|
})
|
|
)
|
|
).filter((template): template is (typeof results)[number] => template !== null)
|
|
: results
|
|
|
|
logger.info(`[${requestId}] Successfully retrieved ${visibleResults.length} templates`)
|
|
|
|
return NextResponse.json({
|
|
data: visibleResults,
|
|
pagination: {
|
|
total: params.workflowId && !isSuperUser ? visibleResults.length : total,
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
page: Math.floor(params.offset / params.limit) + 1,
|
|
totalPages: Math.ceil(
|
|
(params.workflowId && !isSuperUser ? visibleResults.length : total) / params.limit
|
|
),
|
|
},
|
|
})
|
|
} catch (error: any) {
|
|
if (error instanceof z.ZodError) {
|
|
logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.errors })
|
|
return NextResponse.json(
|
|
{ error: 'Invalid query parameters', details: error.errors },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
logger.error(`[${requestId}] Error fetching templates`, error)
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
// POST /api/templates - Create a new template
|
|
export async function POST(request: NextRequest) {
|
|
const requestId = generateRequestId()
|
|
|
|
try {
|
|
const session = await getSession()
|
|
if (!session?.user?.id) {
|
|
logger.warn(`[${requestId}] Unauthorized template creation attempt`)
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const body = await request.json()
|
|
const data = CreateTemplateSchema.parse(body)
|
|
|
|
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
|
workflowId: data.workflowId,
|
|
userId: session.user.id,
|
|
action: 'write',
|
|
})
|
|
|
|
if (!workflowAuthorization.workflow) {
|
|
logger.warn(`[${requestId}] Workflow not found: ${data.workflowId}`)
|
|
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
|
}
|
|
|
|
if (!workflowAuthorization.allowed) {
|
|
logger.warn(`[${requestId}] User denied permission to template workflow ${data.workflowId}`)
|
|
return NextResponse.json(
|
|
{ error: workflowAuthorization.message || 'Access denied' },
|
|
{ status: workflowAuthorization.status || 403 }
|
|
)
|
|
}
|
|
|
|
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
|
|
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
|
|
session.user.id,
|
|
data.creatorId,
|
|
'member'
|
|
)
|
|
|
|
if (!hasPermission) {
|
|
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
|
|
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
|
|
}
|
|
|
|
const templateId = generateId()
|
|
const now = new Date()
|
|
|
|
// Get the active deployment version for the workflow to copy its state
|
|
const activeVersion = await db
|
|
.select({
|
|
id: workflowDeploymentVersion.id,
|
|
state: workflowDeploymentVersion.state,
|
|
})
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, data.workflowId),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (activeVersion.length === 0) {
|
|
logger.warn(
|
|
`[${requestId}] No active deployment version found for workflow: ${data.workflowId}`
|
|
)
|
|
return NextResponse.json(
|
|
{ error: 'Workflow must be deployed before creating a template' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Ensure the state includes workflow variables (if not already included)
|
|
let stateWithVariables = activeVersion[0].state as any
|
|
if (stateWithVariables && !stateWithVariables.variables) {
|
|
// Fetch workflow variables if not in deployment version
|
|
const [workflowRecord] = await db
|
|
.select({ variables: workflow.variables })
|
|
.from(workflow)
|
|
.where(eq(workflow.id, data.workflowId))
|
|
.limit(1)
|
|
|
|
stateWithVariables = {
|
|
...stateWithVariables,
|
|
variables: workflowRecord?.variables || undefined,
|
|
}
|
|
}
|
|
|
|
// Extract credential requirements before sanitizing
|
|
const requiredCredentials = extractRequiredCredentials(stateWithVariables)
|
|
|
|
// Sanitize the workflow state to remove all credential values
|
|
const sanitizedState = sanitizeWorkflowState(stateWithVariables)
|
|
|
|
const newTemplate = {
|
|
id: templateId,
|
|
workflowId: data.workflowId,
|
|
name: data.name,
|
|
details: data.details || null,
|
|
creatorId: data.creatorId,
|
|
views: 0,
|
|
stars: 0,
|
|
status: 'pending' as const, // All new templates start as pending
|
|
tags: data.tags || [],
|
|
requiredCredentials: requiredCredentials, // Store the extracted credential requirements
|
|
state: sanitizedState, // Store the sanitized state without credential values
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
|
|
await db.insert(templates).values(newTemplate)
|
|
|
|
logger.info(`[${requestId}] Successfully created template: ${templateId}`)
|
|
|
|
recordAudit({
|
|
actorId: session.user.id,
|
|
actorName: session.user.name,
|
|
actorEmail: session.user.email,
|
|
action: AuditAction.TEMPLATE_CREATED,
|
|
resourceType: AuditResourceType.TEMPLATE,
|
|
resourceId: templateId,
|
|
resourceName: data.name,
|
|
description: `Created template "${data.name}"`,
|
|
request,
|
|
})
|
|
|
|
return NextResponse.json(
|
|
{
|
|
id: templateId,
|
|
message: 'Template submitted for approval successfully',
|
|
},
|
|
{ status: 201 }
|
|
)
|
|
} catch (error: any) {
|
|
if (error instanceof z.ZodError) {
|
|
logger.warn(`[${requestId}] Invalid template data`, { errors: error.errors })
|
|
return NextResponse.json(
|
|
{ error: 'Invalid template data', details: error.errors },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
logger.error(`[${requestId}] Error creating template`, error)
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
}
|
|
}
|