Skip to content
GigadriveDocs

Client-side uploads

Let a browser send a file straight into a bucket, with your server issuing a short-lived upload URL and never touching the bytes.

A browser can upload straight into a Gigadrive Network bucket, so a large file never passes through your own server or its request size limit. Your server stays in the loop for one small call: it decides the upload is allowed and hands back a signed URL that is good for one key, one size and one digest.

How the flow works

  1. The browser hashes the file

    An upload session cannot be opened without the exact byte length and the SHA-256, and the file is in the browser, so that is where the digest is computed.

  2. Your server opens the session

    The browser posts the name, size and digest to your own endpoint. That endpoint authenticates the caller, chooses the object key, and calls the API with your credentials.

  3. The browser sends the bytes

    A tus client sends them to the signed URL, chunked, with progress and resume. No Gigadrive credential is involved, because the URL carries its own.

  4. Your server confirms

    Read the session back to learn that the object was verified and created. A message from the browser saying so is not proof.

Issuing and using the URL

Your endpoint, running as a Function, where the client needs no configuration because the platform injects credentials. See OIDC federation for what it reads and how to get the same credentials elsewhere.

// app/api/uploads/route.ts
import { GigadriveClient } from '@gigadrive/sdk';

const client = new GigadriveClient();

const MAX_BYTES = 25 * 1024 * 1024;

export async function POST(request: Request) {
  // Replace this with your own session lookup.
  const userId = request.headers.get('x-user-id');
  if (!userId) return Response.json({ error: 'Not signed in.' }, { status: 401 });

  const body = (await request.json()) as {
    name: string;
    size: number;
    sha256: string;
    contentType?: string;
  };

  if (!Number.isInteger(body.size) || body.size < 1 || body.size > MAX_BYTES) {
    return Response.json({ error: 'File is too large.' }, { status: 400 });
  }
  if (!/^[0-9a-f]{64}$/.test(body.sha256)) {
    return Response.json({ error: 'Invalid checksum.' }, { status: 400 });
  }

  const { session, upload } = await client.storage.uploadSessions.create('user-uploads', {
    key: `users/${userId}/${body.name.replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 200)}`,
    contentLength: body.size,
    checksumSha256: body.sha256,
    contentType: body.contentType,
  });

  return Response.json({
    sessionId: session.id,
    uploadUrl: upload.url,
    expiresAt: session.expiresAt,
    publicUrl: upload.publicObjectUrl,
  });
}

The browser half, with npm install tus-js-client:

import * as tus from 'tus-js-client';

const toHex = (digest: ArrayBuffer) =>
  Array.from(new Uint8Array(digest))
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');

export async function uploadFile(file: File, onProgress: (percent: number) => void): Promise<string> {
  const sha256 = toHex(await crypto.subtle.digest('SHA-256', await file.arrayBuffer()));

  const response = await fetch('/api/uploads', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: file.name, size: file.size, sha256, contentType: file.type }),
  });
  if (!response.ok) throw new Error('The server refused the upload.');

  const { uploadUrl, publicUrl } = (await response.json()) as {
    uploadUrl: string;
    publicUrl: string;
  };

  await new Promise<void>((resolve, reject) => {
    const upload = new tus.Upload(file, {
      uploadUrl,
      chunkSize: 5 * 1024 * 1024,
      retryDelays: [0, 1000, 3000, 5000],
      // Do not set Tus-Resumable here. tus-js-client already sends it, and a
      // second value makes the browser send "1.0.0, 1.0.0", which fails with a 400.
      onProgress: (sent, total) => onProgress(Math.round((sent / total) * 100)),
      onSuccess: () => resolve(),
      onError: reject,
    });

    upload.start();
  });

  return publicUrl;
}

crypto.subtle is available only in a secure context, so this works over HTTPS and on localhost. It also has no streaming digest: file.arrayBuffer() holds the whole file in memory, which is fine for the tens of megabytes a form usually accepts and is not fine for a multi-gigabyte one. For those, hash incrementally with a library that supports it.

Why the session call stays on your server

Opening a session takes an API credential, and a credential that can open one can read and write everything else its scopes allow. That is the first reason the call belongs on a machine you control.

Browser CORS is the second. The authenticated API answers cross-origin requests only from the Gigadrive console, so a fetch from your own page to the session endpoint is blocked before the request leaves. The upload endpoint is deliberately the opposite: it answers any origin, because the signed URL is the credential and it authorizes one write and nothing else.

What your endpoint owes you

The browser proposes a filename. Your server decides the key, because a session authorizes exactly the key it was created with, and a caller who picks the key can overwrite any object in that bucket. Prefixing with the caller's own id, as the example does, keeps one user out of another's files.

contentLength is the hard cap for the transfer, so it is also your size limit: a request declaring 25 MB cannot then send 4 GB. Check it before you create the session, since afterwards the only enforcement you get is a 413 the browser sees and you do not.

The URL you return is a bearer credential for 15 minutes. Treat leaking it as letting someone write those exact bytes at that exact key, which is the whole of what it permits.

Knowing when the file landed

The final PATCH answers 204 only after Gigadrive Network has verified the bytes and created the object, so onSuccess firing in the browser means the object exists. That is still the browser telling you, and if your database needs a row for the file, confirm it from your own server:

const session = await client.storage.uploadSessions.get('user-uploads', sessionId);

if (session.state !== 'completed') {
  throw new Error(`Upload ${sessionId} is ${session.state}.`);
}

A private bucket does not serve publicUrl to anyone: readers need a signed link, which Access URLs covers.

Cancelling and resuming

upload.abort(true) stops the transfer and terminates the upload, which marks the session failed and releases the staged bytes. Without the argument the transfer stops but the session stays open until it expires.

Resuming across a page reload takes the same file and the same URL: keep uploadUrl in sessionStorage, construct the upload again, and tus asks the server for the current offset before sending anything. That only works inside the session's 15 minute window. Past it, hash the file again and ask your endpoint for a new URL.