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,230 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sftpUploadContract } from '@/lib/api/contracts/storage-transfer'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { processFilesToUserFiles } 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 {
|
||||
createSftpConnection,
|
||||
getSftp,
|
||||
isPathSafe,
|
||||
sanitizeFileName,
|
||||
sanitizePath,
|
||||
sftpExists,
|
||||
} from '@/app/api/tools/sftp/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SftpUploadAPI')
|
||||
|
||||
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 SFTP upload attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated SFTP upload request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const parsed = await parseRequest(sftpUploadContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const hasFiles = params.files && params.files.length > 0
|
||||
const hasDirectContent = params.fileContent && params.fileName
|
||||
|
||||
if (!hasFiles && !hasDirectContent) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either files or fileContent with fileName must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPathSafe(params.remotePath)) {
|
||||
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
|
||||
|
||||
const client = await createSftpConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
privateKey: params.privateKey,
|
||||
passphrase: params.passphrase,
|
||||
})
|
||||
|
||||
try {
|
||||
const sftp = await getSftp(client)
|
||||
const remotePath = sanitizePath(params.remotePath)
|
||||
const uploadedFiles: Array<{ name: string; remotePath: string; size: number }> = []
|
||||
|
||||
if (hasFiles) {
|
||||
const rawFiles = params.files!
|
||||
logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload`)
|
||||
|
||||
const userFiles = processFilesToUserFiles(rawFiles, requestId, logger)
|
||||
|
||||
const totalSize = userFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
const maxSize = 100 * 1024 * 1024
|
||||
|
||||
if (totalSize > maxSize) {
|
||||
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Total file size (${sizeMB}MB) exceeds limit of 100MB` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let resolvedTotal = 0
|
||||
for (const file of userFiles) {
|
||||
try {
|
||||
const denied = await assertToolFileAccess(
|
||||
file.key,
|
||||
authResult.userId,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
if (denied) return denied
|
||||
logger.info(
|
||||
`[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)`
|
||||
)
|
||||
const { buffer } = await downloadServableFileFromStorage(file, requestId, logger)
|
||||
|
||||
resolvedTotal += buffer.length
|
||||
if (resolvedTotal > maxSize) {
|
||||
const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Total file size (${sizeMB}MB) exceeds limit of 100MB` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const safeFileName = sanitizeFileName(file.name)
|
||||
const fullRemotePath = remotePath.endsWith('/')
|
||||
? `${remotePath}${safeFileName}`
|
||||
: `${remotePath}/${safeFileName}`
|
||||
|
||||
const sanitizedRemotePath = sanitizePath(fullRemotePath)
|
||||
|
||||
if (!params.overwrite) {
|
||||
const exists = await sftpExists(sftp, sanitizedRemotePath)
|
||||
if (exists) {
|
||||
logger.warn(`[${requestId}] File ${sanitizedRemotePath} already exists, skipping`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = sftp.createWriteStream(sanitizedRemotePath, {
|
||||
mode: params.permissions ? Number.parseInt(params.permissions, 8) : 0o644,
|
||||
})
|
||||
|
||||
writeStream.on('error', reject)
|
||||
writeStream.on('close', () => resolve())
|
||||
writeStream.end(buffer)
|
||||
})
|
||||
|
||||
uploadedFiles.push({
|
||||
name: safeFileName,
|
||||
remotePath: sanitizedRemotePath,
|
||||
size: buffer.length,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Uploaded ${safeFileName} to ${sanitizedRemotePath}`)
|
||||
} catch (error) {
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
logger.error(`[${requestId}] Failed to upload file ${file.name}:`, error)
|
||||
throw new Error(
|
||||
`Failed to upload file "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDirectContent) {
|
||||
const safeFileName = sanitizeFileName(params.fileName!)
|
||||
const fullRemotePath = remotePath.endsWith('/')
|
||||
? `${remotePath}${safeFileName}`
|
||||
: `${remotePath}/${safeFileName}`
|
||||
|
||||
const sanitizedRemotePath = sanitizePath(fullRemotePath)
|
||||
|
||||
if (!params.overwrite) {
|
||||
const exists = await sftpExists(sftp, sanitizedRemotePath)
|
||||
if (exists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File already exists and overwrite is disabled' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let content: Buffer
|
||||
try {
|
||||
content = Buffer.from(params.fileContent!, 'base64')
|
||||
const reEncoded = content.toString('base64')
|
||||
if (reEncoded !== params.fileContent) {
|
||||
content = Buffer.from(params.fileContent!, 'utf-8')
|
||||
}
|
||||
} catch {
|
||||
content = Buffer.from(params.fileContent!, 'utf-8')
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = sftp.createWriteStream(sanitizedRemotePath, {
|
||||
mode: params.permissions ? Number.parseInt(params.permissions, 8) : 0o644,
|
||||
})
|
||||
|
||||
writeStream.on('error', reject)
|
||||
writeStream.on('close', () => resolve())
|
||||
writeStream.end(content)
|
||||
})
|
||||
|
||||
uploadedFiles.push({
|
||||
name: safeFileName,
|
||||
remotePath: sanitizedRemotePath,
|
||||
size: content.length,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Uploaded direct content to ${sanitizedRemotePath}`)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] SFTP upload completed: ${uploadedFiles.length} file(s)`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
uploadedFiles,
|
||||
message: `Successfully uploaded ${uploadedFiles.length} file(s)`,
|
||||
})
|
||||
} finally {
|
||||
client.end()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] SFTP upload failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `SFTP upload failed: ${errorMessage}` }, { status: 500 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user