Exportar arquivos para servidores SFTP pelo navegador
Um navegador pode enviar um arquivo a um gateway HTTP, que o grava em um servidor SFTP via SSH. Monte esse fluxo localmente com um servidor OpenSSH descartável, uma chave de host fixada e um token de acesso de curta duração. Você selecionará um arquivo no navegador e verificará os mesmos bytes em uma caixa de entrada privada.
Introdução
Este exemplo aceita desde arquivos vazios até arquivos de 5 MiB e atribui a cada um um ID de recibo aleatório. O navegador escolhe os bytes; o gateway escolhe o servidor, a conta, o diretório e o nome do arquivo. Ele exporta dados opacos sem fazer uma varredura antimalware nem tornar os documentos seguros para execução.
Entender o desafio
Páginas web comuns não podem abrir uma conexão SSH, por isso uploads do navegador para SFTP precisam de um gateway. O navegador se autentica nesse gateway com um token bearer. Separadamente, o gateway verifica a identidade do servidor SSH e se autentica com sua própria chave privada. Mantenha essa chave no lado do servidor. Para uma transferência programática sem navegador, consulte o guia de SFTP com Node.js.
Criar um projeto local isolado
Use Linux com Bash, Docker Engine, o ssh-keygen do OpenSSH, Node.js 24.15.0 ou uma
versão mais recente da linha Node.js 24 e Corepack fornecendo Yarn 4.12.0.
O Node.js 24 é uma linha LTS com suporte; este exemplo também
funciona no Node.js 26.8.1. Os arquivos .mts usam a
execução nativa de TypeScript do Node, por isso não herdam o tipo de
módulo de um projeto pai.
Cole o conteúdo a seguir no Bash, a partir do diretório em que você quer criar o projeto. O subshell
mantém seu diretório atual e as opções do shell intactos em caso de sucesso ou falha. Um arquivo de
lock local e um vinculador explícito isolam a instalação de um workspace do Yarn que contenha o
projeto. Diretórios sftp-upload existentes são recusados; uma instalação interrompida
pode deixar um novo diretório parcial para você inspecionar.
(
set -eu
mkdir sftp-upload
cd sftp-upload
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json
touch yarn.lock
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml
mkdir .corepack
printf '%s\n' '{"type":"commonjs"}' > .corepack/package.json
COREPACK_HOME="$PWD/.corepack" YARN_IGNORE_PATH=1 corepack yarn add --exact express@5.2.1 helmet@8.3.0 jose@6.2.12 ssh2-sftp-client@12.1.1 execa@9.6.1
mkdir public
)
Entre no projeto somente depois que a instalação for concluída com sucesso:
cd sftp-upload
Provisionar a caixa de entrada e um emissor de tokens para desenvolvimento
Salve o conteúdo a seguir como demo.mts. Ele cria uma imagem Ubuntu 24.04 com
OpenSSH, cria uma conta exclusiva para SFTP e expõe o SSH em uma porta de loopback alocada
aleatoriamente. O chroot pertence ao root; a conta só tem permissão de escrita em
/incoming, com modo 700. O OpenSSH exige que os diretórios chroot pertençam ao
root.
Todas as credenciais desta demonstração são geradas no diretório privado
.demo. Nenhuma conta, chave SSH ou arquivo de hosts conhecidos existente é
alterado. O valor fixado para o host é derivado da chave pública lida pelo seu daemon local do Docker,
que você controla, em vez de uma conexão SSH não verificada. A chave RSA local emite tokens RS256 com
validade de cinco minutos usando SignJWT do jose. Este emissor de
desenvolvimento não tem login nem diretório de usuários: nunca o implante nem compartilhe sua chave
de assinatura.
import { createHash, generateKeyPairSync, randomUUID } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { execa } from 'execa'
import { importPKCS8, SignJWT } from 'jose'
import SftpClient from 'ssh2-sftp-client'
const directory = join(import.meta.dirname, '.demo')
const statePath = join(directory, 'state.json')
const docker = (args, options = {}) => execa('docker', args, { timeout: 180_000, ...options })
async function setup() {
const prefix = process.env.SFTP_DEMO_PREFIX ?? 'sftp-demo'
if (!/^[a-z0-9-]{1,40}$/.test(prefix)) throw new Error('Invalid demo prefix')
const name = prefix + '-' + randomUUID()
await mkdir(directory, { mode: 0o700 }) // Refuse to replace an existing demo.
let ready = false
try {
await mkdir(join(directory, 'staging'), { mode: 0o700 })
await execa('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(directory, 'client')])
const pair = generateKeyPairSync('rsa', { modulusLength: 2048 })
await writeFile(join(directory, 'jwt-private.pem'), pair.privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 })
await writeFile(join(directory, 'jwt-public.pem'), pair.publicKey.export({ type: 'spki', format: 'pem' }), { mode: 0o600 })
const [keyType, keyBytes] = (await readFile(join(directory, 'client.pub'), 'utf8')).split(' ')
const config = `Port 22
HostKey /etc/ssh/ssh_host_ed25519_key
AuthorizedKeysFile /etc/ssh/demo_authorized_keys
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM no
PermitRootLogin no
AllowUsers uploader
Subsystem sftp internal-sftp
Match User uploader
ChrootDirectory /srv/sftp
ForceCommand internal-sftp -u 077
DisableForwarding yes
PermitTTY no`
await docker(['build', '--tag', name, '-'], { input: `FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/* /etc/ssh/ssh_host_*
RUN useradd --no-create-home --home-dir /incoming --shell /usr/sbin/nologin uploader && passwd -d uploader && mkdir -p /run/sshd /srv/sftp/incoming && chown uploader:uploader /srv/sftp/incoming && chmod 700 /srv/sftp/incoming && chmod 755 /srv/sftp
RUN printf '%s\\n' '${keyType} ${keyBytes}' > /etc/ssh/demo_authorized_keys
RUN printf '%s\\n' '${config.replaceAll('\n', "' '")}' > /etc/ssh/sshd_config
CMD ["sh", "-c", "ssh-keygen -A && exec /usr/sbin/sshd -D -e"]
` })
await docker(['run', '--detach', '--rm', '--name', name, '--publish', '127.0.0.1::22', name])
let publicKey
for (let attempt = 0; attempt < 50; attempt++) {
const probe = await docker(['exec', name, 'cat', '/etc/ssh/ssh_host_ed25519_key.pub'], { reject: false })
if (probe.exitCode === 0) { publicKey = probe.stdout; break }
const running = await docker(['inspect', '--format', '{{.State.Running}}', name], { reject: false })
if (running.stdout !== 'true') throw new Error('Disposable SFTP server exited during startup')
await new Promise((resolve) => setTimeout(resolve, 100))
}
if (!publicKey) throw new Error('Disposable SFTP server did not become ready')
const mapping = await docker(['port', name, '22/tcp'])
const port = Number(mapping.stdout.split(':').at(-1))
const hostKey = createHash('sha256').update(Buffer.from(publicKey.split(' ')[1], 'base64')).digest('hex')
const sftp = new SftpClient()
try {
await sftp.connect({ host: '127.0.0.1', port, username: 'uploader', privateKey: await readFile(join(directory, 'client')), hostHash: 'sha256', hostVerifier: (hash) => hash === hostKey, readyTimeout: 10_000 })
if (await sftp.realPath('/incoming') !== '/incoming') throw new Error('Unexpected demo inbox')
} finally { await sftp.end() }
await writeFile(statePath, JSON.stringify({ name, port, hostKey }) + '\n', { mode: 0o600 })
ready = true
console.log('Local SFTP inbox ready. Next: node demo.mts token')
} finally {
if (!ready) {
await docker(['rm', '--force', name], { reject: false })
await docker(['image', 'rm', '--force', name], { reject: false })
await rm(directory, { recursive: true, force: true })
}
}
}
async function main() {
const [command, id, original] = process.argv.slice(2)
if (command === 'setup') return setup()
const state = JSON.parse(await readFile(statePath, 'utf8'))
if (command === 'stop') {
await docker(['rm', '--force', state.name])
await docker(['image', 'rm', '--force', state.name])
await rm(directory, { recursive: true })
console.log('Removed the disposable server, inbox, and demo credentials')
return
}
if (command === 'verify') {
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id ?? '') || !original) throw new Error('Usage: node demo.mts verify RECEIPT_ID ORIGINAL_FILE')
const path = '/srv/sftp/incoming/' + id + '.bin'
const remote = await docker(['exec', state.name, 'cat', path], { encoding: 'buffer', stripFinalNewline: false })
const expected = await readFile(original)
const mode = await docker(['exec', state.name, 'stat', '-c', '%a', path])
if (Buffer.compare(remote.stdout, expected) !== 0 || mode.stdout !== '600') throw new Error('Remote bytes or file permissions differ')
console.log('Verified ' + expected.length + ' bytes; mode 600')
return
}
if (command === 'token') {
const key = await importPKCS8(await readFile(join(directory, 'jwt-private.pem'), 'utf8'), 'RS256')
console.log(await new SignJWT({ scope: 'sftp:upload' }).setProtectedHeader({ alg: 'RS256' }).setIssuer('urn:local-sftp-demo').setAudience('sftp-gateway').setSubject('local-uploader').setIssuedAt().setExpirationTime('5m').sign(key))
return
}
if (command !== 'serve') throw new Error('Use setup, token, serve, verify, or stop')
const port = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid HTTP port')
Object.assign(process.env, {
PORT: String(port), APP_ORIGIN: 'http://127.0.0.1:' + port,
JWT_PUBLIC_KEY: await readFile(join(directory, 'jwt-public.pem'), 'utf8'),
JWT_ISSUER: 'urn:local-sftp-demo', JWT_AUDIENCE: 'sftp-gateway',
SFTP_HOST: '127.0.0.1', SFTP_PORT: String(state.port), SFTP_USERNAME: 'uploader',
SFTP_PRIVATE_KEY: await readFile(join(directory, 'client'), 'utf8'), SFTP_HOST_SHA256: state.hostKey,
TMPDIR: join(directory, 'staging'),
})
await import('./index.mts')
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : 'Demo failed')
process.exitCode = 1
})
Configurar o frontend
Salve o conteúdo a seguir como public/index.html. O campo de token permite a entrada
manual nesta demonstração local. Uma aplicação deve obter o token de curta duração de cada usuário
autorizado por meio do seu fluxo de login.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SFTP file export</title>
</head>
<body>
<h1>Export a file</h1>
<form id="uploadForm">
<label>Your access token <input id="token" type="password" autocomplete="off" required /></label>
<label>File (up to 5 MiB) <input type="file" id="fileInput" required /></label>
<button type="submit">Export to SFTP</button>
</form>
<p id="status" role="status"></p>
<script src="/upload.js" defer></script>
</body>
</html>
Salve o conteúdo a seguir como public/upload.js. O navegador envia um corpo do tipo File,
inclusive para um arquivo de tamanho zero, e fornece seu Content-Length. Os controles
permanecem desabilitados enquanto a requisição está pendente, e envios repetidos são ignorados.
O token é removido do campo de entrada antes de ser enviado.
const form = document.getElementById('uploadForm')
const token = document.getElementById('token')
const input = document.getElementById('fileInput')
const status = document.getElementById('status')
const button = form.querySelector('button')
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (button.disabled) return
const file = input.files[0]
if (!file || file.size > 5 * 1024 * 1024) {
status.textContent = 'Choose a file of up to 5 MiB.'
return
}
button.disabled = true
input.disabled = true
token.disabled = true
status.textContent = 'Exporting…'
const accessToken = token.value
token.value = ''
try {
const response = await fetch('/upload', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/octet-stream',
},
body: file,
signal: AbortSignal.timeout(35_000),
})
if (!response.ok) throw new Error('Export rejected')
const result = await response.json()
status.textContent = 'File exported. Receipt: ' + result.id
} catch {
status.textContent = 'Export could not be confirmed. Check your inbox before retrying.'
} finally {
button.disabled = false
input.disabled = false
token.disabled = false
}
})
Criar a API de backend com Node.js
Salve o conteúdo a seguir como index.mts. A
API do ssh2-sftp-client fornece os fluxos SFTP e a operação de renomeação.
Suas opções de conexão do ssh2 definem hostHash e
hostVerifier: o valor fixado é um resumo SHA-256 hexadecimal de 64 caracteres da chave
bruta do host, não a string de exibição SHA256:base64 do OpenSSH.
O gateway verifica a assinatura, o emissor, o público-alvo, a idade e o escopo de upload do token
antes de ler o corpo. Ele armazena temporariamente o corpo completo, de tamanho limitado, no ambiente
local, grava um arquivo privado .part e então o renomeia para
.bin antes de retornar HTTP 201 e { id }.
Os consumidores devem ignorar arquivos .part.
import { randomUUID } from 'node:crypto'
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { createConnection } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'node:url'
import express from 'express'
import helmet from 'helmet'
import { importSPKI, jwtVerify } from 'jose'
import SftpClient from 'ssh2-sftp-client'
const {
APP_ORIGIN, JWT_PUBLIC_KEY, JWT_ISSUER, JWT_AUDIENCE,
SFTP_HOST, SFTP_USERNAME, SFTP_PRIVATE_KEY, SFTP_HOST_SHA256,
} = process.env
if (!APP_ORIGIN || !JWT_PUBLIC_KEY || !JWT_ISSUER || !JWT_AUDIENCE ||
!SFTP_HOST || !SFTP_USERNAME || !SFTP_PRIVATE_KEY ||
!/^[a-f0-9]{64}$/.test(SFTP_HOST_SHA256 ?? '')) {
throw new Error('Gateway configuration is incomplete')
}
const sshPort = Number(process.env.SFTP_PORT ?? 22)
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) {
throw new Error('Invalid SFTP port')
}
const key = await importSPKI(JWT_PUBLIC_KEY, 'RS256')
const webOrigin = new URL(APP_ORIGIN)
const localHttp = webOrigin.protocol === 'http:' &&
['localhost', '127.0.0.1', '[::1]'].includes(webOrigin.hostname)
if (webOrigin.origin !== APP_ORIGIN || (!localHttp && webOrigin.protocol !== 'https:')) {
throw new Error('Use an HTTPS origin or loopback HTTP for local development')
}
const remoteRoot = '/incoming'
const maxBytes = 5 * 1024 * 1024
let active = 0
export const app = express()
app.disable('x-powered-by')
app.use(helmet({
contentSecurityPolicy: {
// The local demo uses HTTP, so its assets must not be upgraded to HTTPS.
directives: { 'upgrade-insecure-requests': localHttp ? null : [] },
},
strictTransportSecurity: localHttp ? false : undefined,
}))
app.use((_req, res, next) => {
res.locals.requestId = randomUUID()
res.set('X-Request-ID', res.locals.requestId)
res.set('Cache-Control', 'no-store')
next()
})
app.post('/upload', async (req, res) => {
const fail = (status, error) => {
if (!res.destroyed && !res.headersSent) {
res.status(status).json({ error, requestId: res.locals.requestId })
}
}
if (req.get('origin') !== APP_ORIGIN || req.originalUrl !== '/upload') {
return fail(403, 'Request not allowed')
}
let claims
try {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.get('authorization') ?? '')
if (!match) return fail(401, 'Authentication required')
const verified = await jwtVerify(match[1], key, {
algorithms: ['RS256'], issuer: JWT_ISSUER, audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'], maxTokenAge: '15m',
})
claims = verified.payload
} catch {
return fail(401, 'Invalid access token')
}
if (typeof claims.scope !== 'string' || !claims.scope.split(' ').includes('sftp:upload')) {
return fail(403, 'Permission denied')
}
const length = req.get('content-length') ?? ''
if (!/^(0|[1-9][0-9]*)$/.test(length)) return fail(411, 'Content length required')
const expected = Number(length)
if (!Number.isSafeInteger(expected) || expected > maxBytes) return fail(413, 'File too large')
if (req.get('content-type') !== 'application/octet-stream') {
return fail(415, 'Send an octet-stream file')
}
if (active >= 2) return fail(503, 'Gateway busy')
active += 1
const id = randomUUID()
const remotePart = remoteRoot + '/' + id + '.part'
const remoteFinal = remoteRoot + '/' + id + '.bin'
const abort = new AbortController()
const timer = setTimeout(() => abort.abort(), 30_000)
const disconnect = () => { if (!res.writableFinished) abort.abort() }
req.once('aborted', disconnect)
res.once('close', disconnect)
let directory
let socket
let connected = false
let pending = false
const sftp = new SftpClient('gateway', {
error: () => abort.abort(),
end: () => abort.abort(),
close: () => abort.abort(),
})
const cancelSocket = () => socket?.destroy()
abort.signal.addEventListener('abort', cancelSocket)
try {
directory = await mkdtemp(join(tmpdir(), 'sftp-upload-'))
const localPath = join(directory, 'payload')
let received = 0
const limit = new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length
callback(received > expected ? new Error('Byte limit exceeded') : null, chunk)
},
flush(callback) {
callback(received !== expected ? new Error('Incomplete upload') : null)
},
})
await pipeline(req, limit, createWriteStream(localPath, { flags: 'wx', mode: 0o600 }), {
signal: abort.signal,
})
abort.signal.throwIfAborted()
socket = createConnection({ host: SFTP_HOST, port: sshPort })
// ssh2 observes socket failures; this also covers the handoff before its listeners attach.
socket.on('error', () => {})
await sftp.connect({
sock: socket,
username: SFTP_USERNAME,
privateKey: SFTP_PRIVATE_KEY,
hostHash: 'sha256',
hostVerifier: (hash) => hash === SFTP_HOST_SHA256,
readyTimeout: 10_000,
})
connected = true
abort.signal.throwIfAborted()
if (await sftp.realPath(remoteRoot) !== remoteRoot) throw new Error('Unexpected inbox')
pending = true
// The private inbox has no other writers; exclusive creation refuses an existing .part file.
await pipeline(
createReadStream(localPath),
sftp.createWriteStream(remotePart, { flags: 'wx', mode: 0o600 }),
{ signal: abort.signal },
)
abort.signal.throwIfAborted()
await sftp.rename(remotePart, remoteFinal)
pending = false
if (!abort.signal.aborted) res.status(201).json({ id })
} catch {
console.error(JSON.stringify({ event: 'export_failed', requestId: res.locals.requestId }))
fail(502, 'Export could not be confirmed')
} finally {
// A disconnected SSH session cannot guarantee deletion; the inbox janitor handles those parts.
if (connected && pending && !socket.destroyed) {
await sftp.delete(remotePart, true).catch(() => {
console.error(JSON.stringify({ event: 'cleanup_pending', requestId: res.locals.requestId }))
})
}
socket?.destroy()
await sftp.end().catch(() => {})
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {
console.error(JSON.stringify({ event: 'local_cleanup_failed', requestId: res.locals.requestId }))
})
clearTimeout(timer)
abort.signal.removeEventListener('abort', cancelSocket)
req.off('aborted', disconnect)
res.off('close', disconnect)
active -= 1
}
})
app.use(express.static(fileURLToPath(new URL('./public/', import.meta.url))))
app.use((_req, res) => res.status(404).json({ error: 'Not found' }))
app.use((_error, _req, res, _next) => {
if (!res.headersSent) res.status(500).json({ error: 'Request failed' })
})
const httpPort = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(httpPort) || httpPort < 0 || httpPort > 65535) {
throw new Error('Invalid HTTP port')
}
const server = app.listen(httpPort, '127.0.0.1', (error) => {
if (error) {
console.error('Gateway could not bind its HTTP port: ' + error.code)
process.exitCode = 1
return
}
console.log('Open ' + APP_ORIGIN)
})
server.requestTimeout = 35_000
server.headersTimeout = 10_000
Considerações de segurança
A página local usa HTTP em loopback. Uma página e um gateway implantados precisam de HTTPS, do valor
exato de Origin configurado e de um provedor de identidade real que atribua
sftp:upload somente a usuários autorizados. A checagem de Origin complementa a
verificação do token; ela não autentica quem faz a chamada. O CORS permanece desabilitado.
A caixa de entrada tem um único gravador confiável. Nenhum processo não confiável pode renomear
/incoming, adicionar links simbólicos ou disputar a criação de arquivos nesse
local. Verificar seu caminho canônico não substitui essas permissões. Nomes aleatórios e a criação
exclusiva de arquivos temporários evitam reutilizar um nome de arquivo enviado; cada nova tentativa
cria um novo ID.
Este processo único aceita duas requisições ativas, cada uma com no máximo 5 MiB de armazenamento temporário local e um prazo de transferência de 30 segundos. Ele realiza ambas as transferências em fluxo, em vez de manter arquivos inteiros na memória. Os limites se aplicam às requisições ativas em um único processo. Arquivos remotos concluídos e arquivos temporários locais deixados por uma interrupção inesperada ou falha de limpeza precisam de cotas de disco e limpeza separadas caso você adapte a demonstração.
Uma sessão SSH desconectada pode deixar um arquivo .part. A perda de uma
resposta HTTP após a renomeação pode deixar um arquivo .bin completo, mesmo
que o navegador informe incerteza. O recibo confirma que a renomeação foi bem-sucedida; ele não é um
protocolo de entrega exatamente uma vez nem uma garantia de durabilidade. Inspecione a caixa de
entrada antes de tentar novamente uma transferência de resultado incerto. Encerrar esta demonstração
exclui toda a caixa de entrada descartável; um serviço persistente, por sua vez, precisa de uma
política para arquivos temporários abandonados.
Testar a funcionalidade de exportação de arquivos
Depois de salvar os quatro arquivos, provisione o servidor:
node demo.mts setup
Gere um token e copie a saída do comando. Mantenha este terminal privado; o token concede acesso para upload até expirar. Execute o mesmo comando novamente quando precisar de um novo token:
node demo.mts token
Inicie o gateway em primeiro plano:
node demo.mts serve
Abra http://127.0.0.1:3000/, cole o token em Your access token,
escolha um arquivo e selecione Export to SFTP.
Durante a execução, a página mostra Exporting….
Após HTTP 201, ela mostra File exported. Receipt: seguido de um ID.
Abrir file:// ou usar localhost em vez da origem exibida no
terminal não funcionará. Se a porta 3000 estiver ocupada, pare o outro serviço ou use
PORT=3002 node demo.mts serve e abra a origem exibida no terminal.
Em um segundo terminal, entre no mesmo diretório do projeto. Substitua RECEIPT_ID
por esse ID e ORIGINAL_FILE pelo arquivo exato que você selecionou. Este comando lê
de forma independente o arquivo remoto privado pelo Docker, compara seus bytes com os do original
e verifica o modo 600:
node demo.mts verify RECEIPT_ID ORIGINAL_FILE
Para uma entrada de sete bytes, ele imprime Verified 7 bytes; mode 600. Teste também um arquivo
vazio e um arquivo binário. Um token, emissor, escopo ou Origin incorreto, um corpo acima do limite de
tamanho ou interrompido, um valor incorreto fixado para o host ou uma caixa de entrada remota sem
permissão de escrita não devem resultar na publicação de um arquivo final. Uma resposta de erro de
upload contém uma mensagem genérica e um ID de requisição, nunca credenciais SSH nem detalhes de
erros internos. O navegador informa
Export could not be confirmed. Check your inbox before retrying.
quando uma transferência é rejeitada ou sua confirmação é perdida.
Pressione Ctrl+C no terminal do servidor para voltar ao shell. Em seguida, remova o servidor da demonstração, a imagem, a caixa de entrada e as credenciais. Isso remove apenas os recursos registrados pela configuração deste projeto:
node demo.mts stop
Conclusão
Substitua o emissor de desenvolvimento e a conta descartável ao integrar este fluxo a uma aplicação existente. Mantenha intactos o limite de autorização do navegador e a configuração confiável do servidor. Para processar arquivos antes da exportação, consulte o Robot de exportação SFTP da Transloadit.
