d25d482dc2
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
280 lines
9.1 KiB
TypeScript
280 lines
9.1 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { supabaseStorageUploadContract } from '@/lib/api/contracts/tools/databases/supabase'
|
|
import { parseToolRequest } from '@/lib/api/server'
|
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
import { validateSupabaseProjectId } from '@/lib/core/security/input-validation'
|
|
import { generateRequestId } from '@/lib/core/utils/request'
|
|
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
|
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
|
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
|
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
|
|
import { assertToolFileAccess } from '@/app/api/files/authorization'
|
|
import { encodeStoragePath, encodeStorageSegment } from '@/tools/supabase/utils'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const logger = createLogger('SupabaseStorageUploadAPI')
|
|
|
|
export const POST = withRouteHandler(async (request: NextRequest) => {
|
|
const requestId = generateRequestId()
|
|
|
|
try {
|
|
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
|
|
|
if (!authResult.success || !authResult.userId) {
|
|
logger.warn(
|
|
`[${requestId}] Unauthorized Supabase storage upload attempt: ${authResult.error}`
|
|
)
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: authResult.error || 'Authentication required',
|
|
},
|
|
{ status: 401 }
|
|
)
|
|
}
|
|
|
|
logger.info(
|
|
`[${requestId}] Authenticated Supabase storage upload request via ${authResult.authType}`,
|
|
{
|
|
userId: authResult.userId,
|
|
}
|
|
)
|
|
|
|
const parsed = await parseToolRequest(supabaseStorageUploadContract, request, {
|
|
errorFormat: 'toolDetails',
|
|
logger,
|
|
})
|
|
if (!parsed.success) return parsed.response
|
|
const validatedData = parsed.data.body
|
|
|
|
const fileData = validatedData.fileData
|
|
const isStringInput = typeof fileData === 'string'
|
|
|
|
logger.info(`[${requestId}] Uploading to Supabase Storage`, {
|
|
bucket: validatedData.bucket,
|
|
fileName: validatedData.fileName,
|
|
path: validatedData.path,
|
|
fileDataType: isStringInput ? 'string' : 'object',
|
|
})
|
|
|
|
if (!fileData) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: 'fileData is required',
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
let uploadBody: Buffer
|
|
let uploadContentType: string | undefined
|
|
|
|
if (isStringInput) {
|
|
let content = fileData as string
|
|
|
|
const dataUrlMatch = content.match(/^data:([^;]+);base64,(.+)$/s)
|
|
if (dataUrlMatch) {
|
|
const [, mimeType, base64Data] = dataUrlMatch
|
|
content = base64Data
|
|
if (!validatedData.contentType) {
|
|
uploadContentType = mimeType
|
|
}
|
|
logger.info(`[${requestId}] Extracted base64 from data URL (MIME: ${mimeType})`)
|
|
}
|
|
|
|
const cleanedContent = content.replace(/[\s\r\n]/g, '')
|
|
const isLikelyBase64 = /^[A-Za-z0-9+/]*={0,2}$/.test(cleanedContent)
|
|
|
|
if (isLikelyBase64 && cleanedContent.length >= 4) {
|
|
try {
|
|
uploadBody = Buffer.from(cleanedContent, 'base64')
|
|
|
|
const expectedMinSize = Math.floor(cleanedContent.length * 0.7)
|
|
const expectedMaxSize = Math.ceil(cleanedContent.length * 0.8)
|
|
|
|
if (
|
|
uploadBody.length >= expectedMinSize &&
|
|
uploadBody.length <= expectedMaxSize &&
|
|
uploadBody.length > 0
|
|
) {
|
|
logger.info(
|
|
`[${requestId}] Decoded base64 content: ${cleanedContent.length} chars -> ${uploadBody.length} bytes`
|
|
)
|
|
} else {
|
|
const reEncoded = uploadBody.toString('base64')
|
|
if (reEncoded !== cleanedContent) {
|
|
logger.info(
|
|
`[${requestId}] Content looked like base64 but re-encoding didn't match, using as plain text`
|
|
)
|
|
uploadBody = Buffer.from(content, 'utf-8')
|
|
} else {
|
|
logger.info(
|
|
`[${requestId}] Decoded base64 content (verified): ${uploadBody.length} bytes`
|
|
)
|
|
}
|
|
}
|
|
} catch (decodeError) {
|
|
logger.info(
|
|
`[${requestId}] Failed to decode as base64, using as plain text: ${decodeError}`
|
|
)
|
|
uploadBody = Buffer.from(content, 'utf-8')
|
|
}
|
|
} else {
|
|
uploadBody = Buffer.from(content, 'utf-8')
|
|
logger.info(`[${requestId}] Using content as plain text (${uploadBody.length} bytes)`)
|
|
}
|
|
|
|
uploadContentType =
|
|
uploadContentType || validatedData.contentType || 'application/octet-stream'
|
|
} else {
|
|
const rawFile = fileData
|
|
logger.info(`[${requestId}] Processing file object: ${rawFile.name || 'unknown'}`)
|
|
|
|
let userFile
|
|
try {
|
|
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
|
|
} catch (error) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: getErrorMessage(error, 'Failed to process file'),
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
|
|
if (denied) return denied
|
|
let buffer: Buffer
|
|
let resolvedContentType: string
|
|
try {
|
|
const resolved = await downloadServableFileFromStorage(userFile, requestId, logger)
|
|
buffer = resolved.buffer
|
|
resolvedContentType = resolved.contentType
|
|
} catch (error) {
|
|
const notReady = docNotReadyResponse(error)
|
|
if (notReady) return notReady
|
|
logger.error(`[${requestId}] Failed to download file for Supabase upload:`, error)
|
|
return NextResponse.json(
|
|
{ success: false, error: getErrorMessage(error, 'Internal server error') },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
uploadBody = buffer
|
|
uploadContentType =
|
|
validatedData.contentType ||
|
|
resolvedContentType ||
|
|
userFile.type ||
|
|
'application/octet-stream'
|
|
}
|
|
|
|
let fullPath = validatedData.fileName
|
|
if (validatedData.path) {
|
|
const folderPath = validatedData.path.endsWith('/')
|
|
? validatedData.path
|
|
: `${validatedData.path}/`
|
|
fullPath = `${folderPath}${validatedData.fileName}`
|
|
}
|
|
|
|
const projectValidation = validateSupabaseProjectId(validatedData.projectId)
|
|
if (!projectValidation.isValid) {
|
|
return NextResponse.json({ success: false, error: projectValidation.error }, { status: 400 })
|
|
}
|
|
|
|
const encodedBucket = encodeStorageSegment(validatedData.bucket)
|
|
const encodedPath = encodeStoragePath(fullPath)
|
|
const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${encodedBucket}/${encodedPath}`
|
|
|
|
const headers: Record<string, string> = {
|
|
apikey: validatedData.apiKey,
|
|
Authorization: `Bearer ${validatedData.apiKey}`,
|
|
'Content-Type': uploadContentType,
|
|
}
|
|
|
|
if (validatedData.cacheControl) {
|
|
const cacheControl = validatedData.cacheControl.trim()
|
|
headers['cache-control'] = /^\d+$/.test(cacheControl)
|
|
? `max-age=${cacheControl}`
|
|
: cacheControl
|
|
}
|
|
|
|
if (validatedData.upsert) {
|
|
headers['x-upsert'] = 'true'
|
|
}
|
|
|
|
logger.info(`[${requestId}] Sending to Supabase: ${supabaseUrl}`, {
|
|
contentType: uploadContentType,
|
|
bodySize: uploadBody.length,
|
|
upsert: validatedData.upsert,
|
|
})
|
|
|
|
const response = await fetch(supabaseUrl, {
|
|
method: 'POST',
|
|
headers,
|
|
body: new Uint8Array(uploadBody),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
let errorData
|
|
try {
|
|
errorData = JSON.parse(errorText)
|
|
} catch {
|
|
errorData = { message: errorText }
|
|
}
|
|
|
|
logger.error(`[${requestId}] Supabase Storage upload failed:`, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
error: errorData,
|
|
})
|
|
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: errorData.message || errorData.error || `Upload failed: ${response.statusText}`,
|
|
details: errorData,
|
|
},
|
|
{ status: response.status }
|
|
)
|
|
}
|
|
|
|
const result = await response.json()
|
|
|
|
logger.info(`[${requestId}] File uploaded successfully to Supabase Storage`, {
|
|
bucket: validatedData.bucket,
|
|
path: fullPath,
|
|
})
|
|
|
|
const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${encodedBucket}/${encodedPath}`
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
output: {
|
|
message: 'Successfully uploaded file to storage',
|
|
results: {
|
|
...result,
|
|
path: fullPath,
|
|
bucket: validatedData.bucket,
|
|
publicUrl,
|
|
},
|
|
},
|
|
})
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error uploading to Supabase Storage:`, error)
|
|
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: getErrorMessage(error, 'Internal server error'),
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
})
|