Browser-Uploads in Blöcken zu Google Storage mit JavaScript
Google Cloud Storage (GCS) unterstützt mit seinem Protokoll für fortsetzbare Uploads das Hochladen eines einzelnen Objekts über mehrere Anfragen. In dieser Anleitung erstellen Sie ein lokales Upload-Tool für einen einzigen Eigentümer: Node.js authentifiziert ihn und erstellt eine Sitzung. Anschließend sendet der Browser Dateiblöcke direkt an GCS. Das Tool unterstützt Dateien bis 10 GiB, zeigt den Fortschritt nach jedem bestätigten Block an und ermöglicht das Pausieren und Fortsetzen, solange die Seite geöffnet bleibt.
Warum Uploads in Blöcken?
Ein fortsetzbarer Upload erfasst die Bytes, die GCS empfangen hat. Nach einem Verbindungsabbruch fragt der Browser diese Position ab und setzt den Upload dort fort. Die Blöcke gehören zu einem Objekt; es gibt keine temporären Objekte, die zusammengesetzt oder gelöscht werden müssen, und keine Beschränkung auf 32 Komponenten beim Zusammensetzen.
Google Cloud Storage konfigurieren
Verwenden Sie Node.js 24.2 oder neuer, Yarn, die Google Cloud CLI und einen privaten Bucket. Weisen
Sie der Serveridentität die auf den Bucket beschränkte Rolle roles/storage.objectCreator zu:
Dieses Beispiel erstellt ausschließlich neue Objekte. Konfigurieren Sie für die lokale Entwicklung
Application Default Credentials mit einer Identität, die diese Berechtigung besitzt:
gcloud auth application-default login
mkdir gcs-upload
cd gcs-upload
corepack yarn init
corepack yarn config set nodeLinker node-modules
corepack yarn add --exact google-auth-library@11.0.0
Die JSON API behandelt CORS unabhängig von den CORS-Regeln des Buckets. Der Server gibt beim Erstellen der Sitzung den exakten Ursprung des Browsers an, damit nachfolgende Browseranfragen CORS-Header erhalten. Eine CORS-Einstellung authentifiziert keinen Uploadenden.
Backend: authentifizierte Upload-Sitzungen erstellen
Speichern Sie dies als server.mjs. Das konfigurierte Token identifiziert den
einzigen Eigentümer dieses lokalen Tools. Jede Sitzung erfordert dieses Token, und der Server
wählt einen nicht vorhersagbaren Objektnamen innerhalb von uploads/owner/.
Clients können weder Bucket-Namen noch Zielpfade oder Namen zu löschender Objekte angeben.
Die Vorbedingung ifGenerationMatch=0 verhindert außerdem, dass ein vorhandenes Objekt
überschrieben wird.
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { GoogleAuth } from 'google-auth-library'
const ORIGIN = 'http://127.0.0.1:8080'
const MAX_SIZE = 10 * 1024 ** 3
const assets = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/upload.mjs', ['upload.mjs', 'text/javascript; charset=utf-8']],
['/app.mjs', ['app.mjs', 'text/javascript; charset=utf-8']],
])
export function createUploadServer({ auth, bucket, token }) {
if (!bucket || !token || token.length < 32) throw new Error('Set BUCKET and a 32-character token')
const digest = (value) => createHash('sha256').update(value).digest()
const expected = digest(`Bearer ${token}`)
return createServer((req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Referrer-Policy', 'no-referrer')
const reply = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function handle() {
const asset = assets.get(req.url)
if (req.method === 'GET' && asset) {
const body = await readFile(new URL(asset[0], import.meta.url))
res.writeHead(200, { 'Content-Type': asset[1] })
res.end(body)
return
}
if (req.method !== 'POST' || req.url !== '/sessions') return reply(404, { error: 'Not found' })
if (!timingSafeEqual(expected, digest(req.headers.authorization ?? ''))) {
return reply(401, { error: 'Invalid upload token' })
}
if (req.headers.origin !== ORIGIN) return reply(403, { error: 'Invalid origin' })
const chunks = []
let bytes = 0
for await (const chunk of req) {
bytes += chunk.length
if (bytes > 1024) return reply(413, { error: 'Request too large' })
chunks.push(chunk)
}
let input
try {
input = JSON.parse(Buffer.concat(chunks, bytes).toString('utf8'))
} catch {
return reply(400, { error: 'Invalid JSON' })
}
const size = input?.size
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_SIZE) {
return reply(400, { error: 'Choose a nonempty file of at most 10 GiB' })
}
const name = `uploads/owner/${randomUUID()}`
const url = new URL(`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o`)
url.search = new URLSearchParams({ uploadType: 'resumable', name, ifGenerationMatch: '0' })
const client = await auth.getClient()
const response = await client.request({
url: url.href,
method: 'POST',
headers: { Origin: ORIGIN, 'X-Upload-Content-Length': String(size) },
data: { contentType: 'application/octet-stream' },
timeout: 30_000,
retry: false,
})
const sessionUrl = response.headers.get('location')
if (!sessionUrl || new URL(sessionUrl).origin !== 'https://storage.googleapis.com') {
throw new Error('Invalid session response')
}
reply(201, { sessionUrl, name })
}
handle().catch(() => reply(502, { error: 'Could not create upload session' }))
})
}
if (import.meta.main) {
const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/devstorage.read_write'] })
createUploadServer({ auth, bucket: process.env.BUCKET, token: process.env.UPLOAD_TOKEN })
.listen(8080, '127.0.0.1', () => console.log(`Open ${ORIGIN}`))
}
Mit der Google Auth Library bleiben die Google-Zugangsdaten auf dem Server. Die zurückgegebene Sitzungs-URL ist selbst ein Bearer-Zugangsnachweis: Wer sie besitzt, kann an dieses eine Ziel hochladen. Sie ist keine signierte URL mit einer Gültigkeit von 15 Minuten; fortsetzbare GCS-Sitzungen laufen nach einer Woche ab. Halten Sie Sitzungs-URLs aus Protokollen und gemeinsam genutzten Speichern heraus.
Frontend: ein minimaler Dateiuploader mit Blockübertragung
Speichern Sie dies als upload.mjs. Die Blockgröße von 8 MiB ist ein Vielfaches
der von GCS geforderten 256 KiB; der letzte Block darf kleiner sein. Eine Antwort mit
308 bedeutet, dass der Upload unvollständig ist. Daher behandelt der Code
sie vor der regulären HTTP-Fehlerprüfung. Er liest den bestätigten Bytebereich, statt anzunehmen,
dass GCS die gesamte Anfrage angenommen hat. Siehe das
Protokoll für fortsetzbare Uploads.
const CHUNK_SIZE = 8 * 1024 * 1024
export async function uploadFileWithChunks(file, sessionUrl, { signal, onProgress = () => {} } = {}) {
if (file.size < 1 || file.size > 10 * 1024 ** 3) throw new Error('Invalid file size')
let offset = 0
let probe = true
let failures = 0
while (true) {
signal?.throwIfAborted()
const end = Math.min(offset + CHUNK_SIZE, file.size)
let response
try {
response = await fetch(sessionUrl, {
method: 'PUT',
credentials: 'omit',
signal,
headers: {
'Content-Range': probe ? `bytes */${file.size}` : `bytes ${offset}-${end - 1}/${file.size}`,
},
body: probe ? new Blob([]) : file.slice(offset, end),
})
} catch (error) {
signal?.throwIfAborted()
if (++failures > 5) throw error
await backoff(failures, signal)
probe = true
continue
}
await response.body?.cancel()
if (response.status === 200 || response.status === 201) {
onProgress(100)
return
}
if (response.status === 429 || response.status >= 500) {
if (++failures > 5) throw new Error('Upload retries exhausted')
await backoff(failures, signal)
probe = true
continue
}
if (response.status !== 308) throw new Error(`Upload failed (HTTP ${response.status})`)
const range = response.headers.get('range')
const match = range === null ? null : /^bytes=0-(\d+)$/.exec(range)
if (range !== null && !match) throw new Error('Invalid acknowledged range')
const next = match ? Number(match[1]) + 1 : 0
if (!Number.isSafeInteger(next) || next < offset || next >= file.size || (!probe && next > end)) {
throw new Error('Invalid acknowledged position')
}
if (!probe && next === offset) {
if (++failures > 5) throw new Error('Upload made no progress')
await backoff(failures, signal)
probe = true
continue
}
if (next > offset) failures = 0
offset = next
onProgress((offset / file.size) * 100)
probe = false
}
}
function backoff(attempt, signal) {
signal?.throwIfAborted()
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', abort)
resolve()
}, 500 * 2 ** (attempt - 1))
signal?.addEventListener('abort', abort, { once: true })
})
}
Wiederholungsversuche mit exponentiell steigender Wartezeit
Netzwerkfehler, Anfragelimits und Serverfehler lösen zwischen Fortschrittsaktualisierungen bis zu
fünf Wiederholungsversuche aus. Jeder Versuch fragt zunächst mit einer leeren Anfrage vom Typ
PUT die gespeicherte Position ab. Eine Pause bricht sowohl die aktive
Anfrage als auch eine laufende Wartezeit vor einem Wiederholungsversuch ab. Berechtigungsfehler,
abgelaufene Sitzungen und fehlerhafte Bereichsangaben stoppen den Upload, statt eine Endlosschleife
auszulösen. Falls eine abschließende Antwort verloren gegangen ist, kann die nächste Statusabfrage
den Abschluss bestätigen, ohne die Datei erneut hochzuladen.
Speichern Sie die Seite als index.html:
<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Resumable GCS upload</title>
<label>Upload token <input id="token" type="password" autocomplete="off" /></label>
<label>File <input id="file" type="file" /></label>
<button id="upload">Upload / resume</button>
<button id="pause" disabled>Pause</button>
<progress id="progress" max="100" value="0" aria-label="Upload progress"></progress>
<p id="status" role="status">Choose a file.</p>
<script type="module" src="/app.mjs"></script>
</html>
Speichern Sie die Steuerelemente als app.mjs:
import { uploadFileWithChunks } from './upload.mjs'
const fileInput = document.getElementById('file')
const tokenInput = document.getElementById('token')
const upload = document.getElementById('upload')
const pause = document.getElementById('pause')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
let session
let controller
fileInput.addEventListener('change', () => {
session = undefined
progress.value = 0
})
pause.addEventListener('click', () => controller?.abort())
upload.addEventListener('click', async () => {
const file = fileInput.files[0]
if (!file) return
controller = new AbortController()
upload.disabled = fileInput.disabled = tokenInput.disabled = true
pause.disabled = false
status.textContent = 'Uploading…'
try {
if (!session) {
const response = await fetch('/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokenInput.value}` },
body: JSON.stringify({ size: file.size }),
signal: controller.signal,
})
if (!response.ok) throw new Error(`Session failed (HTTP ${response.status})`)
session = await response.json()
}
await uploadFileWithChunks(file, session.sessionUrl, {
signal: controller.signal,
onProgress: (value) => { progress.value = value },
})
status.textContent = `Uploaded to ${session.name}`
session = undefined
} catch {
status.textContent = controller.signal.aborted
? 'Paused. Click Upload / resume to continue.'
: 'Upload failed. Retry, or reselect the file to start a new session.'
} finally {
upload.disabled = fileInput.disabled = tokenInput.disabled = false
pause.disabled = true
}
})
Starten Sie den Server im selben Verzeichnis, nachdem Sie den Namen Ihres bestehenden Buckets
festgelegt und ein geheimes Token mit mindestens 32 Zeichen eingegeben haben. Geben Sie dasselbe
Token auf der Seite unter http://127.0.0.1:8080 ein:
export BUCKET='your-existing-private-bucket'
read -r -s -p 'Upload token: ' UPLOAD_TOKEN
export UPLOAD_TOKEN
node server.mjs
Fortschritt beim Neuladen beibehalten
Dieses Beispiel hält Datei und Sitzung bewusst nur im Arbeitsspeicher. Beim Pausieren bleiben beide erhalten; beim Neuladen der Seite oder Auswählen einer anderen Datei beginnt eine neue Sitzung. Aufgegebene, unvollständige Sitzungen laufen ab, ohne temporäre Blockobjekte zu erstellen.
Um das Neuladen in einer Mehrbenutzeranwendung zu unterstützen, speichern Sie Sitzungen auf dem Server mit Zuordnung zu authentifizierten Benutzer-IDs und geben Sie eine Sitzung nur an ihren Eigentümer zurück. Verlangen Sie, dass der Benutzer dieselbe Datei erneut auswählt, und prüfen Sie vor dem Fortsetzen, ob der Inhalt identisch ist, nicht nur Name und Größe. Fragen Sie den Offset immer bei GCS ab; eine lokal gespeicherte Fortschrittsangabe in Prozent ist nicht maßgeblich.
Bewährte Verfahren für Sicherheit und Leistung
Das Token authentifiziert einen einzelnen vertrauenswürdigen Eigentümer; es ist kein Anmeldesystem für mehrere Benutzer. Lassen Sie diese Demo an die Loopback-Adresse gebunden. Integrieren Sie vor der Bereitstellung die Authentifizierung und Kontingente Ihrer Anwendung, stellen Sie sie über HTTPS bereit und halten Sie den Objektnamensraum an den authentifizierten Benutzer gebunden. Behandeln Sie hochgeladene Bytes als nicht vertrauenswürdig; der angegebene Inhaltstyp einer Datei bestätigt nicht deren tatsächlichen Inhalt.
Das Beispiel begrenzt jede Sitzung auf 10 GiB und fordert diese Länge beim Start des Uploads an. Halten Sie den Bucket privat und prüfen Sie die Metadaten des fertig hochgeladenen Objekts, bevor Sie eine Datei zur nachfolgenden Verarbeitung bereitstellen. Der Browser liest immer nur einen Block; Fortschrittsaktualisierungen melden bestätigte Bytes, keine noch laufenden Übertragungen. GCS speichert das endgültige Objekt direkt. Daher benötigt kein Bereinigungsendpunkt beliebigen Löschzugriff.
Auch Transloadit kann helfen
Um Dateien nach dem Upload zu verarbeiten, kann der Transloadit-Robot
🤖 /google/import
sie mithilfe von Template-Zugangsdaten importieren.
Uppy bietet Upload-Oberflächen für Anwendungen, die mehr als diese
minimale Demo benötigen.
