Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/sim/app/api/files/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,12 @@ export async function verifyFileAccess(
// Infer context from key if not explicitly provided
const inferredContext = context || inferContextFromKey(cloudKey)

// 0. Public contexts: profile pictures and OG images are publicly accessible
if (inferredContext === 'profile-pictures' || inferredContext === 'og-images') {
// 0. Public contexts: profile pictures, OG images, and workspace logos are publicly accessible
if (
inferredContext === 'profile-pictures' ||
inferredContext === 'og-images' ||
inferredContext === 'workspace-logos'
) {
logger.info('Public file access allowed', { cloudKey, context: inferredContext })
return true
}
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/files/serve/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ export async function GET(
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath

const isPublicByKeyPrefix =
cloudKey.startsWith('profile-pictures/') || cloudKey.startsWith('og-images/')
cloudKey.startsWith('profile-pictures/') ||
cloudKey.startsWith('og-images/') ||
cloudKey.startsWith('workspace-logos/')

if (isPublicByKeyPrefix) {
const context = inferContextFromKey(cloudKey)
Expand Down
57 changes: 54 additions & 3 deletions apps/sim/app/api/files/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { sanitizeFileName } from '@/executor/constants'
import '@/lib/uploads/core/setup.server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { captureServerEvent } from '@/lib/posthog/server'
import type { StorageContext } from '@/lib/uploads/config'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
Expand Down Expand Up @@ -64,7 +66,7 @@ export async function POST(request: NextRequest) {
// Context must be explicitly provided
if (!contextParam) {
throw new InvalidRequestError(
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, or profile-pictures)'
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)'
)
}

Expand Down Expand Up @@ -282,13 +284,35 @@ export async function POST(request: NextRequest) {
continue
}

if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
if (
context === 'copilot' ||
context === 'chat' ||
context === 'profile-pictures' ||
context === 'workspace-logos'
) {
if (context !== 'copilot' && !isImageFileType(file.type)) {
throw new InvalidRequestError(
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
)
}

if (context === 'workspace-logos') {
if (!workspaceId) {
throw new InvalidRequestError('workspace-logos context requires workspaceId parameter')
}
const permission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (permission !== 'admin') {
return NextResponse.json(
{ error: 'Admin access required for workspace logo uploads' },
{ status: 403 }
)
}
}

if (context === 'chat' && workspaceId) {
const permission = await getUserEntityPermissions(
session.user.id,
Expand Down Expand Up @@ -346,13 +370,40 @@ export async function POST(request: NextRequest) {
}

logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`)

if (context === 'workspace-logos' && workspaceId) {
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: `Uploaded workspace logo "${originalName}"`,
metadata: {
fileName: originalName,
fileKey: fileInfo.key,
fileSize: buffer.length,
fileType: file.type,
},
request,
})

captureServerEvent(session.user.id, 'workspace_logo_uploaded', {
workspace_id: workspaceId,
file_name: originalName,
file_size: buffer.length,
})
}

uploadResults.push(uploadResult)
continue
}

// Unknown context
throw new InvalidRequestError(
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, or profile-pictures`
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos`
)
}

Expand Down
17 changes: 16 additions & 1 deletion apps/sim/app/api/workspaces/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ const patchWorkspaceSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
logoUrl: z
.string()
.refine((val) => val.startsWith('/') || val.startsWith('https://'), {
message: 'Logo URL must be an absolute path or HTTPS URL',
})
.nullable()
.optional(),
billedAccountUserId: z.string().optional(),
allowPersonalApiKeys: z.boolean().optional(),
})
Expand Down Expand Up @@ -119,11 +126,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<

try {
const body = patchWorkspaceSchema.parse(await request.json())
const { name, color, billedAccountUserId, allowPersonalApiKeys } = body
const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body

if (
name === undefined &&
color === undefined &&
logoUrl === undefined &&
billedAccountUserId === undefined &&
allowPersonalApiKeys === undefined
) {
Expand All @@ -150,6 +158,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
updateData.color = color
}

if (logoUrl !== undefined) {
updateData.logoUrl = logoUrl
}

if (allowPersonalApiKeys !== undefined) {
updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys)
}
Expand Down Expand Up @@ -216,6 +228,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
changes: {
...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }),
...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }),
...(logoUrl !== undefined && {
logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl },
}),
...(allowPersonalApiKeys !== undefined && {
allowPersonalApiKeys: {
from: existingWorkspace.allowPersonalApiKeys,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useEffect, useState } from 'react'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
Expand All @@ -17,6 +17,7 @@ import { ChevronDown } from '@/components/emcn/icons'
import { Trash } from '@/components/emcn/icons/trash'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'

const logger = createLogger('KnowledgeHeader')

Expand Down Expand Up @@ -48,52 +49,18 @@ interface KnowledgeHeaderProps {
options?: KnowledgeHeaderOptions
}

interface Workspace {
id: string
name: string
permissions: 'admin' | 'write' | 'read'
}

export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false)
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)

const updateKnowledgeBase = useUpdateKnowledgeBase()

useEffect(() => {
if (!options?.knowledgeBaseId) return

const fetchWorkspaces = async () => {
try {
setIsLoadingWorkspaces(true)

const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}

const data = await response.json()

const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions,
}))

setWorkspaces(availableWorkspaces)
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsLoadingWorkspaces(false)
}
}
const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery(
!!options?.knowledgeBaseId
)
const workspaces = allWorkspaces.filter(
(ws) => ws.permissions === 'write' || ws.permissions === 'admin'
)

fetchWorkspaces()
}, [options?.knowledgeBaseId])
const updateKnowledgeBase = useUpdateKnowledgeBase()

const handleWorkspaceChange = async (workspaceId: string | null) => {
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return
Expand Down
Loading
Loading