chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile'
|
||||
import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc'
|
||||
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
|
||||
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
|
||||
import { validateMermaidSource } from '@/lib/mermaid/validate'
|
||||
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const logger = createLogger('WorkspaceFileCompiledCheckAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/files/[fileId]/compiled-check
|
||||
*
|
||||
* Compiles or validates the saved source for generated document-like files and
|
||||
* returns whether it succeeds. Used by the file agent to self-verify generated
|
||||
* code or diagram syntax before finalising an edit.
|
||||
*
|
||||
* Returns:
|
||||
* 200 { ok: true }
|
||||
* 200 { ok: false, error: string, errorName: string } — user code error
|
||||
* 4xx on auth / missing file / unsupported extension
|
||||
* 500 on system (sandbox infra) failure
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const parsed = await parseRequest(workspaceFileCompiledCheckContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
// In the E2B regime ALL four formats compile in the doc sandbox (Node for
|
||||
// pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so
|
||||
// a stale file can't trigger an E2B compile when the sandbox is disabled.
|
||||
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileRecord.name) : null
|
||||
const taskId = BINARY_DOC_TASKS[ext]
|
||||
const isMermaidFile = ext === 'mmd' || ext === 'mermaid'
|
||||
if (!e2bFmt && !taskId && !isMermaidFile) {
|
||||
return NextResponse.json(
|
||||
{ error: `Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files` },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = await fetchWorkspaceFileBuffer(fileRecord)
|
||||
} catch (err) {
|
||||
logger.error('Failed to download file for compiled check', {
|
||||
fileId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 })
|
||||
}
|
||||
|
||||
const code = buffer.toString('utf-8')
|
||||
|
||||
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
|
||||
return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 })
|
||||
}
|
||||
|
||||
if (isMermaidFile) {
|
||||
return NextResponse.json(await validateMermaidSource(code))
|
||||
}
|
||||
|
||||
if (e2bFmt) {
|
||||
// Loads the compile-once artifact if present, else compiles via E2B once
|
||||
// (and recalc-scans xlsx formulas). Only a script error is { ok: false };
|
||||
// infra failures rethrow → 500, so the agent isn't told to "fix its script"
|
||||
// during an E2B/S3 outage.
|
||||
const result = await runE2BCompiledCheck({
|
||||
source: code,
|
||||
fileName: fileRecord.name,
|
||||
workspaceId,
|
||||
ext,
|
||||
})
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
try {
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 })
|
||||
}
|
||||
await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` })
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxUserCodeError) {
|
||||
logger.info('Compiled check failed with user code error', {
|
||||
fileId,
|
||||
taskId,
|
||||
error: toError(err).message,
|
||||
errorName: err.name,
|
||||
})
|
||||
return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFileContentAPI')
|
||||
|
||||
/**
|
||||
* PUT /api/workspaces/[id]/files/[fileId]/content
|
||||
* Update a workspace file's text content (requires write permission)
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { content, encoding } = parsed.data.body
|
||||
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const buffer =
|
||||
encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8')
|
||||
|
||||
const maxFileSizeBytes = 50 * 1024 * 1024
|
||||
if (buffer.length > maxFileSizeBytes) {
|
||||
return NextResponse.json(
|
||||
{ error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const updatedFile = await updateWorkspaceFileContent(
|
||||
workspaceId,
|
||||
fileId,
|
||||
session.user.id,
|
||||
buffer
|
||||
)
|
||||
|
||||
logger.info(`Updated content for workspace file: ${updatedFile.name}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.FILE_UPDATED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileId,
|
||||
resourceName: updatedFile.name,
|
||||
description: `Updated content of file "${updatedFile.name}"`,
|
||||
metadata: { contentSize: buffer.length },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: updatedFile,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = toError(error).message || 'Failed to update file content'
|
||||
const isNotFound = errorMessage.includes('File not found')
|
||||
const isQuotaExceeded = errorMessage.includes('Storage limit exceeded')
|
||||
const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500
|
||||
|
||||
if (status === 500) {
|
||||
logger.error('Error updating file content:', error)
|
||||
} else {
|
||||
logger.warn(errorMessage)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: errorMessage }, { status })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceCsvPreviewAPI')
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
const userId = authResult.userId
|
||||
|
||||
const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { key } = parsed.data.query
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Resolve the file record (active, in this workspace) and read from its authoritative key —
|
||||
// never the client-supplied one. This rejects archived/deleted files and keys with no live
|
||||
// row, matching the access guarantees of /api/files/serve.
|
||||
const record = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!record || record.key !== key) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const slice = await getCsvPreviewSlice({
|
||||
key: record.key,
|
||||
context: 'workspace',
|
||||
signal: request.signal,
|
||||
})
|
||||
|
||||
logger.info('CSV preview served', {
|
||||
workspaceId,
|
||||
rows: slice.rows.length,
|
||||
truncated: slice.truncated,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, ...slice })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFileDownloadAPI')
|
||||
|
||||
/**
|
||||
* POST /api/workspaces/[id]/files/[fileId]/download
|
||||
* Return authenticated file serve URL (requires read permission)
|
||||
* Uses /api/files/serve endpoint which enforces authentication and context
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId, fileId } = paramsResult.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!userPermission) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { getBaseUrl } = await import('@/lib/core/utils/urls')
|
||||
const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace`
|
||||
const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}`
|
||||
|
||||
logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileId,
|
||||
resourceName: fileRecord.name,
|
||||
description: `Downloaded file "${fileRecord.name}"`,
|
||||
metadata: { fileId, fileName: fileRecord.name, bytes: fileRecord.size },
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_downloaded',
|
||||
{ workspace_id: workspaceId, is_bulk: false, file_count: 1 },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
downloadUrl: serveUrl,
|
||||
viewerUrl: viewerUrl,
|
||||
fileName: fileRecord.name,
|
||||
expiresIn: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error generating download URL:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to generate download URL'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('RestoreWorkspaceFileAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId, fileId } = paramsResult.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await performRestoreWorkspaceFile({
|
||||
workspaceId,
|
||||
fileId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error },
|
||||
{ status: result.errorCode === 'conflict' ? 409 : 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Restored workspace file ${fileId}`)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Internal server error') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
renameWorkspaceFileContract,
|
||||
workspaceFileParamsSchema,
|
||||
} from '@/lib/api/contracts/workspace-files'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
performDeleteWorkspaceFileItems,
|
||||
performRenameWorkspaceFile,
|
||||
} from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFileAPI')
|
||||
|
||||
/**
|
||||
* PATCH /api/workspaces/[id]/files/[fileId]
|
||||
* Rename a workspace file (requires write permission)
|
||||
*/
|
||||
export const PATCH = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(renameWorkspaceFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { name } = parsed.data.body
|
||||
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await performRenameWorkspaceFile({
|
||||
workspaceId,
|
||||
fileId,
|
||||
name,
|
||||
userId: session.user.id,
|
||||
})
|
||||
if (!result.success || !result.file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{ status: result.errorCode === 'conflict' ? 409 : 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_renamed',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: result.file,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error renaming workspace file:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to rename file'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /api/workspaces/[id]/files/[fileId]
|
||||
* Archive a workspace file (requires write permission)
|
||||
*/
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId, fileId } = paramsResult.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check workspace permissions (requires write)
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await performDeleteWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
fileIds: [fileId],
|
||||
})
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{
|
||||
status:
|
||||
result.errorCode === 'validation'
|
||||
? 400
|
||||
: result.errorCode === 'not_found'
|
||||
? 404
|
||||
: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Archived workspace file: ${fileId}`)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_deleted',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting workspace file:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to delete file'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetWorkspaceFile: vi.fn(),
|
||||
mockGetShareForResource: vi.fn(),
|
||||
mockUpsertFileShare: vi.fn(),
|
||||
mockValidateSharing: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
getWorkspaceFile: mockGetWorkspaceFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => {
|
||||
class ShareValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ShareValidationError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
getShareForResource: mockGetShareForResource,
|
||||
upsertFileShare: mockUpsertFileShare,
|
||||
ShareValidationError,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/ee/access-control/utils/permission-check', () => {
|
||||
class PublicFileSharingNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Public file sharing is not allowed based on your permission group settings')
|
||||
this.name = 'PublicFileSharingNotAllowedError'
|
||||
}
|
||||
}
|
||||
return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
|
||||
const FILE_ID = 'wf_abc'
|
||||
|
||||
import { ShareValidationError } from '@/lib/public-shares/share-manager'
|
||||
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
|
||||
|
||||
const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })
|
||||
|
||||
const putRequest = (body: unknown) =>
|
||||
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const getRequest = () =>
|
||||
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`)
|
||||
|
||||
const SHARE = {
|
||||
id: 'sh_1',
|
||||
token: 'tok_1',
|
||||
url: 'https://sim.ai/f/tok_1',
|
||||
isActive: true,
|
||||
resourceType: 'file' as const,
|
||||
resourceId: FILE_ID,
|
||||
}
|
||||
|
||||
describe('share route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
|
||||
})
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' })
|
||||
mockGetShareForResource.mockResolvedValue(SHARE)
|
||||
mockUpsertFileShare.mockResolvedValue(SHARE)
|
||||
mockValidateSharing.mockResolvedValue(undefined) // policy allows by default
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce(null)
|
||||
const res = await GET(getRequest(), params())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when the caller has no workspace access', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null)
|
||||
const res = await GET(getRequest(), params())
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns the share for a member', async () => {
|
||||
const res = await GET(getRequest(), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ share: SHARE })
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT', () => {
|
||||
it('returns 403 for a read-only member', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockUpsertFileShare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a ShareValidationError to 400, not 500', async () => {
|
||||
mockUpsertFileShare.mockRejectedValueOnce(
|
||||
new ShareValidationError('Password is required for password-protected shares')
|
||||
)
|
||||
const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params())
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error).toBe('Password is required for password-protected shares')
|
||||
})
|
||||
|
||||
it('returns 404 when the file is not in the workspace', async () => {
|
||||
mockGetWorkspaceFile.mockResolvedValueOnce(null)
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
expect(res.status).toBe(404)
|
||||
expect(mockUpsertFileShare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enables the share for a writer', async () => {
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockUpsertFileShare).toHaveBeenCalledWith({
|
||||
workspaceId: WS,
|
||||
fileId: FILE_ID,
|
||||
userId: 'user-1',
|
||||
isActive: true,
|
||||
})
|
||||
expect(await res.json()).toEqual({ share: SHARE })
|
||||
})
|
||||
|
||||
it('returns 403 when org access-control disables public sharing (enable)', async () => {
|
||||
const { PublicFileSharingNotAllowedError } = await import(
|
||||
'@/ee/access-control/utils/permission-check'
|
||||
)
|
||||
mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError())
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockUpsertFileShare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows disabling a share even when policy disallows enabling', async () => {
|
||||
mockValidateSharing.mockRejectedValue(new Error('should not be called for disable'))
|
||||
const res = await PUT(putRequest({ isActive: false }), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockValidateSharing).not.toHaveBeenCalled()
|
||||
expect(mockUpsertFileShare).toHaveBeenCalledWith({
|
||||
workspaceId: WS,
|
||||
fileId: FILE_ID,
|
||||
userId: 'user-1',
|
||||
isActive: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a missing isActive body', async () => {
|
||||
const res = await PUT(putRequest({}), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
getShareForResource,
|
||||
ShareValidationError,
|
||||
upsertFileShare,
|
||||
} from '@/lib/public-shares/share-manager'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
PublicFileSharingNotAllowedError,
|
||||
validatePublicFileSharing,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFileShareAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/files/[fileId]/share
|
||||
* Fetch the public share state for a file (requires workspace membership).
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getFileShareContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission === null) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const file = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const share = await getShareForResource('file', fileId)
|
||||
return NextResponse.json({ share })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching file share:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to fetch share') },
|
||||
{
|
||||
status: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/workspaces/[id]/files/[fileId]/share
|
||||
* Enable or disable the public share for a file (requires write permission).
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(upsertFileShareContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { isActive, authType, password, allowedEmails, token } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const file = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Enabling a share is gated by the org's access-control policy (both the
|
||||
// master on/off and the per-auth-type allow-list); disabling is always
|
||||
// allowed so users can still un-share after the policy is turned on.
|
||||
if (isActive) {
|
||||
try {
|
||||
await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public')
|
||||
} catch (error) {
|
||||
if (error instanceof PublicFileSharingNotAllowedError) {
|
||||
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const share = await upsertFileShare({
|
||||
workspaceId,
|
||||
fileId,
|
||||
userId: session.user.id,
|
||||
isActive,
|
||||
authType,
|
||||
password,
|
||||
allowedEmails,
|
||||
token,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileId,
|
||||
resourceName: file.name,
|
||||
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ share })
|
||||
} catch (error) {
|
||||
if (error instanceof ShareValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error updating file share:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to update share') },
|
||||
{
|
||||
status: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const logger = createLogger('WorkspaceFileStyleAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/files/[fileId]/style
|
||||
* Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file.
|
||||
* OOXML files return theme colors, font pair, and named styles.
|
||||
* PDF files return page dimensions and embedded font names.
|
||||
*/
|
||||
const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(workspaceFileStyleContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
|
||||
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const rawExt = fileRecord.name.split('.').pop()?.toLowerCase()
|
||||
if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Style extraction supports .docx, .pptx, and .pdf files' },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
const ext: 'docx' | 'pptx' | 'pdf' = rawExt
|
||||
|
||||
if (fileRecord.size > MAX_STYLE_FILE_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File is too large for style extraction (limit: 100 MB)' },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = await fetchWorkspaceFileBuffer(fileRecord)
|
||||
} catch (err) {
|
||||
logger.error('Failed to download file for style extraction', {
|
||||
fileId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 })
|
||||
}
|
||||
|
||||
const summary = await extractDocumentStyle(buffer, ext)
|
||||
if (!summary) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information',
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Extracted style summary via API', { fileId, format: ext })
|
||||
|
||||
return NextResponse.json(summary, {
|
||||
headers: { 'Cache-Control': 'private, max-age=300' },
|
||||
})
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user