Exportar archivos a servidores SFTP desde navegadores web
Un navegador puede enviar un archivo a una pasarela HTTP, que lo escribe en un servidor SFTP por SSH. Construye esa ruta localmente con un servidor OpenSSH desechable, una clave de host fijada y un token de acceso de corta duración. Seleccionarás un archivo en el navegador y verificarás que los bytes sean los mismos en un buzón privado.
Introducción
Este ejemplo acepta archivos desde vacíos hasta de 5 MiB y asigna a cada uno un ID de recibo aleatorio. El navegador elige los bytes; la pasarela elige el servidor, la cuenta, el directorio y el nombre del archivo. Exporta datos opacos sin analizarlos en busca de malware ni hacer que los documentos sean seguros para ejecutarlos.
Entender el desafío
Las páginas web estándar no pueden abrir una conexión SSH, por lo que las subidas del navegador a SFTP necesitan una pasarela. El navegador se autentica ante esa pasarela con un token Bearer. Por separado, la pasarela verifica la identidad del servidor SSH y se autentica con su propia clave privada. Mantén esa clave del lado del servidor. Para una transferencia programática sin navegador, consulta la guía de SFTP con Node.js.
Crea un proyecto local aislado
Usa Linux con Bash, Docker Engine, ssh-keygen de OpenSSH, Node.js 24.15.0 o una
versión posterior de Node.js 24, y Corepack para disponer de Yarn 4.12.0.
Node.js 24 es una rama LTS con mantenimiento; este ejemplo también
funciona con Node.js 26.8.1. Los archivos .mts usan la
ejecución nativa de TypeScript de Node, por lo que no heredan el tipo
de módulo de un proyecto padre.
Pega esto en Bash desde el directorio donde quieras crear el proyecto. El subshell deja intactos tu
directorio actual y las opciones del shell, tanto si tiene éxito como si falla. Un archivo de bloqueo
local y un enlazador explícito aíslan la instalación de un espacio de trabajo Yarn que la contenga.
Se rechazan los directorios sftp-upload existentes; una instalación interrumpida
puede dejar un directorio nuevo e incompleto que puedes inspeccionar.
(
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
)
Entra en el proyecto solo después de que la instalación se complete correctamente:
cd sftp-upload
Prepara el buzón y un emisor de tokens de desarrollo
Guarda lo siguiente como demo.mts. Construye una imagen de Ubuntu 24.04 con
OpenSSH, crea una cuenta exclusiva para SFTP y expone SSH en un puerto de loopback asignado
aleatoriamente. El chroot pertenece a root; la cuenta solo puede escribir en
/incoming, con modo 700.
OpenSSH requiere directorios chroot que pertenezcan a root.
Todas las credenciales se generan para esta demo dentro del directorio privado
.demo. No se modifica ninguna cuenta, clave SSH ni archivo de hosts conocidos
existentes. La clave de host fijada se deriva de la clave pública leída a través de tu daemon local
de Docker, que tú controlas, en lugar de una conexión SSH no verificada.
La clave RSA local emite tokens RS256 de cinco minutos mediante
SignJWT de jose. Este emisor de desarrollo no tiene inicio de sesión
ni directorio de usuarios: nunca lo despliegues ni compartas su clave de firma.
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 el frontend
Guarda esto como public/index.html. El campo del token permite introducirlo manualmente
para esta demo local. Una aplicación debería obtener el token de corta duración de cada usuario
autorizado a través de su flujo de inicio de sesión.
<!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>
Guarda esto como public/upload.js. El navegador envía un cuerpo de tipo File, incluso
si es un archivo de longitud cero, y proporciona su Content-Length. Los controles
permanecen deshabilitados mientras la solicitud está pendiente y se ignoran los envíos repetidos.
El token se borra del campo de entrada antes de enviarlo.
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
}
})
Construir la API del backend con Node.js
Guarda esto como index.mts. La
API de ssh2-sftp-client proporciona los flujos SFTP y la operación
para cambiar el nombre. Sus opciones de conexión de ssh2 definen
hostHash y hostVerifier: el valor fijado es un resumen SHA-256
hexadecimal de 64 caracteres de la clave de host sin procesar, no la cadena de visualización
SHA256:base64 de OpenSSH.
La pasarela verifica la firma, el emisor, la audiencia, la antigüedad y el ámbito de subida del token
antes de leer el cuerpo. Almacena temporalmente en local el cuerpo completo, cuyo tamaño está
limitado, escribe un archivo privado .part y luego le cambia el nombre a
.bin antes de devolver HTTP 201 y { id }.
Los consumidores deben ignorar los archivos .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
Consideraciones de seguridad
La página local usa HTTP en loopback. Una página y una pasarela desplegadas necesitan HTTPS, el
Origin exacto configurado y un proveedor de identidad real que asigne sftp:upload
solo a usuarios autorizados. La comprobación de Origin complementa la verificación del token;
no autentica a quien realiza la llamada. CORS permanece deshabilitado.
El buzón tiene un único escritor de confianza. Ningún proceso que no sea de confianza puede cambiar
el nombre de /incoming, añadir enlaces simbólicos ni competir con la creación
de archivos allí. Comprobar su ruta canónica no sustituye estos permisos. Los nombres aleatorios
y la creación exclusiva de archivos temporales evitan reutilizar un nombre de archivo enviado;
cada reintento crea un ID nuevo.
Este proceso único admite dos solicitudes activas, cada una con un máximo de 5 MiB de almacenamiento temporal local y un plazo de transferencia de 30 segundos. Transmite ambas transferencias mediante flujos en lugar de mantener archivos completos en memoria. Los límites cubren las solicitudes activas de un solo proceso. Si adaptas la demo, los archivos remotos completados y los archivos temporales locales que queden tras una caída o un fallo de limpieza necesitan cuotas de disco y limpieza independientes.
Una sesión SSH desconectada puede dejar un archivo .part. Una respuesta HTTP
perdida después del cambio de nombre puede dejar un archivo .bin completo,
aunque el navegador indique incertidumbre. El recibo confirma que el cambio de nombre se completó
correctamente; no es un protocolo de entrega exactamente una vez ni una garantía de durabilidad.
Inspecciona el buzón antes de reintentar una transferencia cuyo resultado sea incierto. Detener esta
demo elimina todo el buzón desechable; un servicio persistente, en cambio, necesita una política para
los archivos temporales abandonados.
Probar la funcionalidad de exportación de archivos
Después de guardar los cuatro archivos, prepara el servidor:
node demo.mts setup
Genera un token y copia la salida. Mantén esta terminal privada; el token otorga acceso de subida hasta que caduque. Vuelve a ejecutar el mismo comando cuando necesites un token nuevo:
node demo.mts token
Inicia la pasarela en primer plano:
node demo.mts serve
Abre http://127.0.0.1:3000/, pega el token en Your access token,
elige un archivo y selecciona Export to SFTP.
Mientras se ejecuta, la página muestra Exporting….
Tras recibir HTTP 201, muestra File exported. Receipt:
seguido de un ID. Abrir file:// o usar localhost en lugar
del origen mostrado no funcionará. Si el puerto 3000 está ocupado, detén el otro servicio o usa
PORT=3002 node demo.mts serve y abre el origen mostrado.
En una segunda terminal, entra en el mismo directorio del proyecto. Sustituye
RECEIPT_ID por ese ID y ORIGINAL_FILE por el archivo exacto que
seleccionaste. Este comando lee de forma independiente el archivo remoto privado a través de Docker,
compara sus bytes con los del original y comprueba el modo 600:
node demo.mts verify RECEIPT_ID ORIGINAL_FILE
Para una entrada de siete bytes, imprime Verified 7 bytes; mode 600. Prueba también un archivo
vacío y uno binario. Un token, emisor, ámbito u Origin incorrectos, un cuerpo demasiado grande o
interrumpido, una clave de host fijada incorrecta o un buzón remoto sin permiso de escritura no deben
permitir que se publique un archivo final. Una respuesta de error de subida contiene un mensaje
genérico y un ID de solicitud, nunca credenciales SSH ni detalles de errores internos.
El navegador muestra
Export could not be confirmed. Check your inbox before retrying.
si se rechaza una transferencia o se pierde la confirmación.
Presiona Ctrl+C en la terminal del servidor para volver al shell. Luego elimina el servidor de demo, la imagen, el buzón y las credenciales. Esto elimina solo los recursos registrados durante la configuración de este proyecto:
node demo.mts stop
Conclusión
Sustituye el emisor de desarrollo y la cuenta desechable al integrar este flujo en una aplicación existente. Mantén intactos el límite de autorización del navegador y la configuración del servidor de confianza. Para procesar archivos antes de exportarlos, consulta el Robot de exportación a SFTP de Transloadit.
