mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
* Fix ope * File upload fixes * Fix lint * Materialization shows up * Snapshot * Fix * Nuke migrations * Add migs * migs --------- Co-authored-by: Waleed <walif6@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai> Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
595 lines
17 KiB
TypeScript
595 lines
17 KiB
TypeScript
/**
|
|
* Workspace file storage system
|
|
* Files uploaded at workspace level persist indefinitely and are accessible across all workflows
|
|
*/
|
|
|
|
import { db } from '@sim/db'
|
|
import { workspaceFiles } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { and, eq, isNull, sql } from 'drizzle-orm'
|
|
import {
|
|
checkStorageQuota,
|
|
decrementStorageUsage,
|
|
incrementStorageUsage,
|
|
} from '@/lib/billing/storage'
|
|
import {
|
|
downloadFile,
|
|
hasCloudStorage,
|
|
uploadFile,
|
|
} from '@/lib/uploads/core/storage-service'
|
|
import { getFileMetadataByKey, insertFileMetadata } from '@/lib/uploads/server/metadata'
|
|
import { isUuid, sanitizeFileName } from '@/executor/constants'
|
|
import type { UserFile } from '@/executor/types'
|
|
|
|
const logger = createLogger('WorkspaceFileStorage')
|
|
|
|
export type WorkspaceFileScope = 'active' | 'archived' | 'all'
|
|
|
|
export class FileConflictError extends Error {
|
|
readonly code = 'FILE_EXISTS' as const
|
|
constructor(name: string) {
|
|
super(`A file named "${name}" already exists in this workspace`)
|
|
}
|
|
}
|
|
|
|
export interface WorkspaceFileRecord {
|
|
id: string
|
|
workspaceId: string
|
|
name: string
|
|
key: string
|
|
path: string // Full serve path including storage type
|
|
url?: string // Presigned URL for external access (optional, regenerated as needed)
|
|
size: number
|
|
type: string
|
|
uploadedBy: string
|
|
deletedAt?: Date | null
|
|
uploadedAt: Date
|
|
}
|
|
|
|
/**
|
|
* Workspace file key pattern: workspace/{workspaceId}/{timestamp}-{random}-{filename}
|
|
*/
|
|
const WORKSPACE_KEY_PATTERN = /^workspace\/([a-f0-9-]{36})\/(\d+)-([a-z0-9]+)-(.+)$/
|
|
|
|
/**
|
|
* Check if a key matches workspace file pattern
|
|
* Format: workspace/{workspaceId}/{timestamp}-{random}-{filename}
|
|
*/
|
|
export function matchesWorkspaceFilePattern(key: string): boolean {
|
|
if (!key || key.startsWith('/api/') || key.startsWith('http')) {
|
|
return false
|
|
}
|
|
return WORKSPACE_KEY_PATTERN.test(key)
|
|
}
|
|
|
|
/**
|
|
* Parse workspace file key to extract workspace ID
|
|
* Format: workspace/{workspaceId}/{timestamp}-{random}-{filename}
|
|
* @returns workspaceId if key matches pattern, null otherwise
|
|
*/
|
|
export function parseWorkspaceFileKey(key: string): string | null {
|
|
if (!matchesWorkspaceFilePattern(key)) {
|
|
return null
|
|
}
|
|
|
|
const match = key.match(WORKSPACE_KEY_PATTERN)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
const workspaceId = match[1]
|
|
return isUuid(workspaceId) ? workspaceId : null
|
|
}
|
|
|
|
/**
|
|
* Generate workspace-scoped storage key with explicit prefix
|
|
* Format: workspace/{workspaceId}/{timestamp}-{random}-{filename}
|
|
*/
|
|
export function generateWorkspaceFileKey(workspaceId: string, fileName: string): string {
|
|
const timestamp = Date.now()
|
|
const random = Math.random().toString(36).substring(2, 9)
|
|
const safeFileName = sanitizeFileName(fileName)
|
|
return `workspace/${workspaceId}/${timestamp}-${random}-${safeFileName}`
|
|
}
|
|
|
|
/**
|
|
* Upload a file to workspace-scoped storage
|
|
*/
|
|
export async function uploadWorkspaceFile(
|
|
workspaceId: string,
|
|
userId: string,
|
|
fileBuffer: Buffer,
|
|
fileName: string,
|
|
contentType: string
|
|
): Promise<UserFile> {
|
|
logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`)
|
|
|
|
const exists = await fileExistsInWorkspace(workspaceId, fileName)
|
|
if (exists) {
|
|
throw new Error(`A file named "${fileName}" already exists in this workspace`)
|
|
}
|
|
|
|
const quotaCheck = await checkStorageQuota(userId, fileBuffer.length)
|
|
|
|
if (!quotaCheck.allowed) {
|
|
throw new Error(quotaCheck.error || 'Storage limit exceeded')
|
|
}
|
|
|
|
const storageKey = generateWorkspaceFileKey(workspaceId, fileName)
|
|
let fileId = `wf_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
|
|
|
try {
|
|
logger.info(`Generated storage key: ${storageKey}`)
|
|
|
|
const metadata: Record<string, string> = {
|
|
originalName: fileName,
|
|
uploadedAt: new Date().toISOString(),
|
|
purpose: 'workspace',
|
|
userId: userId,
|
|
workspaceId: workspaceId,
|
|
}
|
|
|
|
const uploadResult = await uploadFile({
|
|
file: fileBuffer,
|
|
fileName: storageKey, // Use the full storageKey as fileName
|
|
contentType,
|
|
context: 'workspace',
|
|
preserveKey: true, // Don't add timestamp prefix
|
|
customKey: storageKey, // Explicitly set the key
|
|
metadata, // Pass metadata for cloud storage consistency
|
|
})
|
|
|
|
logger.info(`Upload returned key: ${uploadResult.key}`)
|
|
|
|
const usingCloudStorage = hasCloudStorage()
|
|
|
|
if (!usingCloudStorage) {
|
|
const metadataRecord = await insertFileMetadata({
|
|
id: fileId,
|
|
key: uploadResult.key,
|
|
userId,
|
|
workspaceId,
|
|
context: 'workspace',
|
|
originalName: fileName,
|
|
contentType,
|
|
size: fileBuffer.length,
|
|
})
|
|
fileId = metadataRecord.id
|
|
logger.info(`Stored metadata in database for local file: ${uploadResult.key}`)
|
|
} else {
|
|
const existing = await getFileMetadataByKey(uploadResult.key, 'workspace')
|
|
|
|
if (!existing) {
|
|
logger.warn(`Metadata not found for cloud file ${uploadResult.key}, inserting...`)
|
|
const metadataRecord = await insertFileMetadata({
|
|
id: fileId,
|
|
key: uploadResult.key,
|
|
userId,
|
|
workspaceId,
|
|
context: 'workspace',
|
|
originalName: fileName,
|
|
contentType,
|
|
size: fileBuffer.length,
|
|
})
|
|
fileId = metadataRecord.id
|
|
} else {
|
|
fileId = existing.id
|
|
logger.info(`Using existing metadata record for cloud file: ${uploadResult.key}`)
|
|
}
|
|
}
|
|
|
|
logger.info(`Successfully uploaded workspace file: ${fileName} with key: ${uploadResult.key}`)
|
|
|
|
try {
|
|
await incrementStorageUsage(userId, fileBuffer.length)
|
|
} catch (storageError) {
|
|
logger.error(`Failed to update storage tracking:`, storageError)
|
|
}
|
|
|
|
const { getServePathPrefix } = await import('@/lib/uploads')
|
|
const pathPrefix = getServePathPrefix()
|
|
const serveUrl = `${pathPrefix}${encodeURIComponent(uploadResult.key)}?context=workspace`
|
|
|
|
return {
|
|
id: fileId,
|
|
name: fileName,
|
|
size: fileBuffer.length,
|
|
type: contentType,
|
|
url: serveUrl, // Use authenticated serve URL (enforces context)
|
|
key: uploadResult.key,
|
|
context: 'workspace',
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to upload workspace file ${fileName}:`, error)
|
|
throw new Error(
|
|
`Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track a file that was already uploaded to workspace S3 as a chat-scoped upload.
|
|
* Creates a workspaceFiles record with context='mothership' and the given chatId.
|
|
* No S3 operations -- the file is already in storage from the presigned/upload step.
|
|
*/
|
|
export async function trackChatUpload(
|
|
workspaceId: string,
|
|
userId: string,
|
|
chatId: string,
|
|
s3Key: string,
|
|
fileName: string,
|
|
contentType: string,
|
|
size: number
|
|
): Promise<void> {
|
|
const fileId = `wf_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
|
|
|
await db.insert(workspaceFiles).values({
|
|
id: fileId,
|
|
key: s3Key,
|
|
userId,
|
|
workspaceId,
|
|
context: 'mothership',
|
|
chatId,
|
|
originalName: fileName,
|
|
contentType,
|
|
size,
|
|
})
|
|
|
|
logger.info(`Tracked chat upload: ${fileName} for chat ${chatId}`)
|
|
}
|
|
|
|
/**
|
|
* Check if a file with the same name already exists in workspace
|
|
*/
|
|
export async function fileExistsInWorkspace(
|
|
workspaceId: string,
|
|
fileName: string
|
|
): Promise<boolean> {
|
|
try {
|
|
const existing = await db
|
|
.select()
|
|
.from(workspaceFiles)
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.originalName, fileName),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
return existing.length > 0
|
|
} catch (error) {
|
|
logger.error(`Failed to check file existence for ${fileName}:`, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all files for a workspace
|
|
*/
|
|
export async function listWorkspaceFiles(
|
|
workspaceId: string,
|
|
options?: { scope?: WorkspaceFileScope }
|
|
): Promise<WorkspaceFileRecord[]> {
|
|
try {
|
|
const { scope = 'active' } = options ?? {}
|
|
const files = await db
|
|
.select()
|
|
.from(workspaceFiles)
|
|
.where(
|
|
scope === 'all'
|
|
? and(eq(workspaceFiles.workspaceId, workspaceId), eq(workspaceFiles.context, 'workspace'))
|
|
: scope === 'archived'
|
|
? and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
sql`${workspaceFiles.deletedAt} IS NOT NULL`
|
|
)
|
|
: and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
.orderBy(workspaceFiles.uploadedAt)
|
|
|
|
const { getServePathPrefix } = await import('@/lib/uploads')
|
|
const pathPrefix = getServePathPrefix()
|
|
|
|
return files.map((file) => ({
|
|
id: file.id,
|
|
workspaceId: file.workspaceId || workspaceId, // Use query workspaceId as fallback (should never be null for workspace files)
|
|
name: file.originalName,
|
|
key: file.key,
|
|
path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`,
|
|
size: file.size,
|
|
type: file.contentType,
|
|
uploadedBy: file.userId,
|
|
deletedAt: file.deletedAt,
|
|
uploadedAt: file.uploadedAt,
|
|
}))
|
|
} catch (error) {
|
|
logger.error(`Failed to list workspace files for ${workspaceId}:`, error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a specific workspace file
|
|
*/
|
|
export async function getWorkspaceFile(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
options?: { includeDeleted?: boolean }
|
|
): Promise<WorkspaceFileRecord | null> {
|
|
try {
|
|
const { includeDeleted = false } = options ?? {}
|
|
const files = await db
|
|
.select()
|
|
.from(workspaceFiles)
|
|
.where(
|
|
includeDeleted
|
|
? and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
: and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (files.length === 0) return null
|
|
|
|
const { getServePathPrefix } = await import('@/lib/uploads')
|
|
const pathPrefix = getServePathPrefix()
|
|
|
|
const file = files[0]
|
|
return {
|
|
id: file.id,
|
|
workspaceId: file.workspaceId || workspaceId, // Use query workspaceId as fallback (should never be null for workspace files)
|
|
name: file.originalName,
|
|
key: file.key,
|
|
path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`,
|
|
size: file.size,
|
|
type: file.contentType,
|
|
uploadedBy: file.userId,
|
|
deletedAt: file.deletedAt,
|
|
uploadedAt: file.uploadedAt,
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to get workspace file ${fileId}:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download workspace file content
|
|
*/
|
|
export async function downloadWorkspaceFile(fileRecord: WorkspaceFileRecord): Promise<Buffer> {
|
|
logger.info(`Downloading workspace file: ${fileRecord.name}`)
|
|
|
|
try {
|
|
const buffer = await downloadFile({
|
|
key: fileRecord.key,
|
|
context: 'workspace',
|
|
})
|
|
logger.info(
|
|
`Successfully downloaded workspace file: ${fileRecord.name} (${buffer.length} bytes)`
|
|
)
|
|
return buffer
|
|
} catch (error) {
|
|
logger.error(`Failed to download workspace file ${fileRecord.name}:`, error)
|
|
throw new Error(
|
|
`Failed to download file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a workspace file's content (re-uploads to same storage key)
|
|
*/
|
|
export async function updateWorkspaceFileContent(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
userId: string,
|
|
content: Buffer
|
|
): Promise<WorkspaceFileRecord> {
|
|
logger.info(`Updating workspace file content: ${fileId} for workspace ${workspaceId}`)
|
|
|
|
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
|
if (!fileRecord) {
|
|
throw new Error('File not found')
|
|
}
|
|
|
|
const sizeDiff = content.length - fileRecord.size
|
|
if (sizeDiff > 0) {
|
|
const quotaCheck = await checkStorageQuota(userId, sizeDiff)
|
|
if (!quotaCheck.allowed) {
|
|
throw new Error(quotaCheck.error || 'Storage limit exceeded')
|
|
}
|
|
}
|
|
|
|
try {
|
|
const metadata: Record<string, string> = {
|
|
originalName: fileRecord.name,
|
|
uploadedAt: new Date().toISOString(),
|
|
purpose: 'workspace',
|
|
userId,
|
|
workspaceId,
|
|
}
|
|
|
|
await uploadFile({
|
|
file: content,
|
|
fileName: fileRecord.key,
|
|
contentType: fileRecord.type,
|
|
context: 'workspace',
|
|
preserveKey: true,
|
|
customKey: fileRecord.key,
|
|
metadata,
|
|
})
|
|
|
|
await db
|
|
.update(workspaceFiles)
|
|
.set({ size: content.length })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
)
|
|
|
|
if (sizeDiff !== 0) {
|
|
try {
|
|
if (sizeDiff > 0) {
|
|
await incrementStorageUsage(userId, sizeDiff)
|
|
} else {
|
|
await decrementStorageUsage(userId, Math.abs(sizeDiff))
|
|
}
|
|
} catch (storageError) {
|
|
logger.error(`Failed to update storage tracking:`, storageError)
|
|
}
|
|
}
|
|
|
|
logger.info(`Successfully updated workspace file content: ${fileRecord.name}`)
|
|
|
|
return {
|
|
...fileRecord,
|
|
size: content.length,
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to update workspace file content ${fileId}:`, error)
|
|
throw new Error(
|
|
`Failed to update file content: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename a workspace file (updates the display name in the database)
|
|
*/
|
|
export async function renameWorkspaceFile(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
newName: string
|
|
): Promise<WorkspaceFileRecord> {
|
|
logger.info(`Renaming workspace file: ${fileId} to "${newName}" in workspace ${workspaceId}`)
|
|
|
|
const trimmedName = newName.trim()
|
|
if (!trimmedName) {
|
|
throw new Error('File name cannot be empty')
|
|
}
|
|
|
|
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
|
if (!fileRecord) {
|
|
throw new Error('File not found')
|
|
}
|
|
|
|
if (fileRecord.name === trimmedName) {
|
|
return fileRecord
|
|
}
|
|
|
|
const exists = await fileExistsInWorkspace(workspaceId, trimmedName)
|
|
if (exists) {
|
|
throw new FileConflictError(trimmedName)
|
|
}
|
|
|
|
const updated = await db
|
|
.update(workspaceFiles)
|
|
.set({ originalName: trimmedName })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
)
|
|
.returning({ id: workspaceFiles.id })
|
|
|
|
if (updated.length === 0) {
|
|
throw new Error('File not found or could not be renamed')
|
|
}
|
|
|
|
logger.info(`Successfully renamed workspace file ${fileId} to "${trimmedName}"`)
|
|
|
|
return {
|
|
...fileRecord,
|
|
name: trimmedName,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Soft delete a workspace file.
|
|
*/
|
|
export async function deleteWorkspaceFile(workspaceId: string, fileId: string): Promise<void> {
|
|
logger.info(`Deleting workspace file: ${fileId}`)
|
|
|
|
try {
|
|
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
|
if (!fileRecord) {
|
|
throw new Error('File not found')
|
|
}
|
|
|
|
await db
|
|
.update(workspaceFiles)
|
|
.set({ deletedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
|
|
logger.info(`Successfully archived workspace file: ${fileRecord.name}`)
|
|
} catch (error) {
|
|
logger.error(`Failed to delete workspace file ${fileId}:`, error)
|
|
throw new Error(
|
|
`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore a soft-deleted workspace file.
|
|
*/
|
|
export async function restoreWorkspaceFile(workspaceId: string, fileId: string): Promise<void> {
|
|
logger.info(`Restoring workspace file: ${fileId}`)
|
|
|
|
const fileRecord = await getWorkspaceFile(workspaceId, fileId, { includeDeleted: true })
|
|
if (!fileRecord) {
|
|
throw new Error('File not found')
|
|
}
|
|
|
|
if (!fileRecord.deletedAt) {
|
|
throw new Error('File is not archived')
|
|
}
|
|
|
|
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
|
|
const ws = await getWorkspaceWithOwner(workspaceId)
|
|
if (!ws || ws.archivedAt) {
|
|
throw new Error('Cannot restore file into an archived workspace')
|
|
}
|
|
|
|
await db
|
|
.update(workspaceFiles)
|
|
.set({ deletedAt: null })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
)
|
|
|
|
logger.info(`Successfully restored workspace file: ${fileRecord.name}`)
|
|
}
|