Exporter des fichiers vers des serveurs SFTP via un navigateur
Un navigateur peut envoyer un fichier à une passerelle HTTP, qui l’écrit sur un serveur SFTP via SSH. Mettez en place ce parcours localement avec un serveur OpenSSH jetable, une clé d’hôte épinglée et un jeton d’accès à courte durée de vie. Vous sélectionnerez un fichier dans le navigateur et vérifierez que les mêmes octets se trouvent dans un dossier de réception privé.
Introduction
Cet exemple accepte des fichiers vides ou d’une taille allant jusqu’à 5 MiB et attribue à chacun un identifiant de reçu aléatoire. Le navigateur choisit les octets ; la passerelle choisit le serveur, le compte, le répertoire et le nom de fichier. Cet exemple exporte des données opaques sans effectuer d’analyse antimalware ni rendre les documents sûrs à exécuter.
Comprendre le problème
Les pages web standard ne peuvent pas ouvrir de connexion SSH. Les téléversements du navigateur vers SFTP nécessitent donc une passerelle. Le navigateur s’authentifie auprès de cette passerelle avec un jeton porteur. De son côté, la passerelle vérifie l’identité du serveur SSH et s’authentifie avec sa propre clé privée. Conservez cette clé côté serveur. Pour un transfert programmatique sans navigateur, consultez le guide SFTP pour Node.js.
Créer un projet local isolé
Utilisez Linux avec Bash, Docker Engine, la commande ssh-keygen d’OpenSSH,
Node.js 24.15.0 ou une version plus récente de Node.js 24, et Corepack
fournissant Yarn 4.12.0. Node.js 24 est une branche LTS maintenue ;
cet exemple fonctionne aussi avec Node.js 26.8.1. Les fichiers .mts utilisent
l’exécution native de TypeScript par Node et n’héritent donc pas du type
de module d’un projet parent.
Collez ceci dans Bash depuis le répertoire où vous souhaitez créer le projet. Le sous-shell conserve
votre répertoire courant et les options du shell, que l’opération réussisse ou échoue. Un fichier de
verrouillage local et un mode de liaison explicite isolent l’installation d’un espace de travail Yarn
englobant. Les répertoires sftp-upload existants sont refusés ; une installation
interrompue peut laisser un nouveau répertoire partiellement créé que vous pourrez examiner.
(
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
)
Accédez au répertoire du projet uniquement après la réussite de l’installation :
cd sftp-upload
Préparer le dossier de réception et un émetteur de jetons de développement
Enregistrez le contenu suivant dans demo.mts. Il construit une image Ubuntu 24.04
avec OpenSSH, crée un compte limité à SFTP et expose SSH sur un port de l’interface de bouclage
attribué aléatoirement. Le chroot appartient à root ; seul /incoming est accessible
en écriture par ce compte, avec le mode 700. OpenSSH exige des répertoires chroot appartenant à root.
Toutes les informations d’accès sont générées pour cette démonstration dans le répertoire privé
.demo. Aucun compte existant, aucune clé SSH existante ni aucun fichier
d’hôtes connus existant n’est modifié. L’empreinte épinglée de l’hôte est dérivée de la clé publique
lue via votre démon Docker local, que vous contrôlez, plutôt que via une connexion SSH non vérifiée.
La clé RSA locale sert à émettre des jetons RS256 valables cinq minutes à l’aide de
SignJWT de jose. Cet émetteur de développement ne propose ni connexion
utilisateur ni annuaire d’utilisateurs : ne le déployez jamais et ne partagez jamais sa clé de signature.
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
})
Configurer l’interface web
Enregistrez ceci dans public/index.html. Le champ du jeton permet une saisie manuelle pour
cette démonstration locale. Une application devrait obtenir le jeton à courte durée de vie de chaque
utilisateur autorisé via son parcours de connexion.
<!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>
Enregistrez ceci dans public/upload.js. Le navigateur envoie un corps de type File, y
compris pour un fichier de longueur nulle, et fournit son Content-Length. Les commandes
restent désactivées tant que la requête est en cours, et les envois répétés sont ignorés. Le jeton est
effacé du champ avant son envoi.
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
}
})
Créer l’API côté serveur avec Node.js
Enregistrez ceci dans index.mts. L’API de ssh2-sftp-client
fournit les flux SFTP et l’opération de renommage. Ses options de connexion de ssh2
définissent hostHash et hostVerifier : l’empreinte épinglée est
le condensat SHA-256 hexadécimal de 64 caractères de la clé d’hôte brute, et non la chaîne d’affichage
SHA256:base64 d’OpenSSH.
La passerelle vérifie la signature, l’émetteur, l’audience, l’âge et la portée de téléversement du
jeton avant de lire le corps de la requête. Elle stocke temporairement en local l’intégralité du
corps, de taille limitée, écrit un fichier privé .part, puis le renomme en
.bin avant de renvoyer HTTP 201 et { id }. Les systèmes
qui consomment ces fichiers doivent ignorer les fichiers .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
Considérations de sécurité
La page locale utilise HTTP sur l’interface de bouclage. Une page et une passerelle déployées
nécessitent HTTPS, la valeur exacte configurée de l’en-tête Origin et un véritable fournisseur
d’identité qui attribue sftp:upload uniquement aux utilisateurs autorisés. Le contrôle
de l’en-tête Origin complète la vérification du jeton ; il n’authentifie pas l’appelant. CORS reste
désactivé.
Le dossier de réception ne reçoit d’écritures que d’un seul acteur de confiance. Aucun processus non
fiable ne doit pouvoir renommer /incoming, ajouter des liens symboliques ou entrer
en concurrence lors de la création de fichiers dans ce dossier. La vérification de son chemin
canonique ne remplace pas ces permissions. Des noms aléatoires et la création exclusive de fichiers
temporaires évitent de réutiliser un nom de fichier soumis ; chaque nouvelle tentative crée un nouvel
identifiant.
Ce processus unique accepte deux requêtes actives, chacune avec au plus 5 MiB de stockage temporaire local et un délai maximal de transfert de 30 secondes. Il effectue les deux transferts en flux continu plutôt que de conserver des fichiers entiers en mémoire. Les limites s’appliquent aux requêtes actives dans un seul processus. Les fichiers distants finalisés, ainsi que les fichiers temporaires locaux laissés après un plantage ou un échec de nettoyage, nécessitent des quotas de disque et un nettoyage distincts si vous adaptez la démonstration.
Une session SSH déconnectée peut laisser un fichier .part. Si la réponse HTTP
se perd après le renommage, un fichier .bin complet peut subsister même si
le navigateur signale une incertitude. Le reçu confirme que le renommage a réussi ; il ne constitue
ni un protocole de livraison exactement une fois ni une garantie de durabilité. Inspectez le dossier
de réception avant de retenter un transfert dont le résultat est incertain. L’arrêt de cette
démonstration supprime la totalité du dossier de réception jetable ; un service persistant nécessite
quant à lui une politique de gestion des fichiers temporaires abandonnés.
Tester la fonctionnalité d’export de fichiers
Après avoir enregistré les quatre fichiers, préparez le serveur :
node demo.mts setup
Générez un jeton et copiez la sortie de la commande. Gardez ce terminal privé ; le jeton accorde un accès en téléversement jusqu’à son expiration. Réexécutez la même commande lorsque vous avez besoin d’un nouveau jeton :
node demo.mts token
Démarrez la passerelle au premier plan :
node demo.mts serve
Ouvrez http://127.0.0.1:3000/, collez le jeton dans Your access token,
choisissez un fichier et sélectionnez Export to SFTP.
Pendant le transfert, la page affiche Exporting….
Après une réponse HTTP 201, elle affiche File exported. Receipt:,
puis un identifiant. Ouvrir file:// ou utiliser localhost
au lieu de l’origine affichée ne fonctionnera pas. Si le port 3000 est occupé, arrêtez l’autre service
ou utilisez PORT=3002 node demo.mts serve et ouvrez l’origine affichée.
Dans un second terminal, accédez au même répertoire du projet. Remplacez RECEIPT_ID
par cet identifiant et ORIGINAL_FILE par le fichier exact que vous avez sélectionné.
Cette commande lit indépendamment le fichier distant privé via Docker, compare ses octets à ceux de
l’original et vérifie le mode 600 :
node demo.mts verify RECEIPT_ID ORIGINAL_FILE
Pour un fichier d’entrée de sept octets, elle affiche Verified 7 bytes; mode 600. Testez aussi un
fichier vide et un fichier binaire. Un jeton incorrect, un émetteur incorrect, une portée incorrecte,
un en-tête Origin incorrect, un corps trop volumineux ou interrompu, une empreinte d’hôte épinglée
incorrecte ou un dossier de réception distant inaccessible en écriture ne doivent pas aboutir à la
publication d’un fichier final. Une réponse d’erreur de téléversement contient un message générique
et un identifiant de requête, jamais d’informations d’accès SSH ni de détails sur les erreurs internes.
Le navigateur affiche
Export could not be confirmed. Check your inbox before retrying.
en cas de transfert rejeté ou de confirmation perdue.
Appuyez sur Ctrl+C dans le terminal du serveur pour revenir au shell. Supprimez ensuite le serveur de démonstration, l’image, le dossier de réception et les informations d’accès. Cette opération ne supprime que les ressources consignées lors de la configuration de ce projet :
node demo.mts stop
Conclusion
Remplacez l’émetteur de développement et le compte jetable lors de l’intégration de ce flux dans une application existante. Conservez intactes la frontière d’autorisation du navigateur et la configuration de confiance du serveur. Pour traiter des fichiers avant leur export, consultez le Robot d’export SFTP (English) de Transloadit.
