Runqora UI
File browser

Server setup

Connect the file browser to an S3 or R2 bucket with Next.js server actions.

@runqora/storage-actions adds lib/storage/actions.ts, a set of server actions that read your bucket and sign uploads and downloads. It uses the S3 client and your bucket's access key, which stay on the server.

npx shadcn@latest add @runqora/storage-actions

1. Add your authorization check

Every server action is a public endpoint that anyone can call. The actions refuse every request until you write authorize() in lib/storage/actions.ts. For example, to allow only admins with Better Auth:

lib/storage/actions.ts
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

async function authorize(): Promise<void> {
  const session = await auth.api.getSession({ headers: await headers() });
  if (session?.user.role !== "admin") {
    throw new StorageActionError("You are not allowed to manage files.");
  }
}

2. Set the environment variables

.env
S3_BUCKET=my-app-files
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# Cloudflare R2: https://<account ID>.r2.cloudflarestorage.com
# Amazon S3: leave empty.
S3_ENDPOINT=
# Amazon S3: the bucket's region, such as eu-central-1. R2: auto.
S3_REGION=auto

Use a key that can only reach this bucket:

  • Cloudflare R2: create an API token with Object Read & Write, limited to the bucket. The account ID is on the R2 overview page.
  • Amazon S3: create an IAM user or role with s3:ListBucket on the bucket and s3:GetObject, s3:PutObject and s3:DeleteObject on its objects.

3. Allow uploads from your site

Browsers upload straight to the bucket, so the bucket needs a CORS rule that allows your site to send PUT requests. Downloads and previews don't need one.

[
  {
    "AllowedOrigins": ["https://your-app.com", "http://localhost:3000"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["content-type"],
    "MaxAgeSeconds": 3600
  }
]
  • Cloudflare R2: the bucket's Settings, then CORS Policy.
  • Amazon S3: the bucket's Permissions, then Cross-origin resource sharing (CORS).

Without it, uploads fail with a message that the browser blocked them.

Settings

The top of lib/storage/actions.ts has the settings you may want to change:

SettingDefaultWhat it does
ROOT""Keeps the browser inside one folder, such as uploads/. Keys outside it can't be read or changed.
MAX_UPLOAD_BYTES100 MBLargest file that can be uploaded.
LINK_EXPIRY_SECONDS1 hourHow long download links and copied links work.
UPLOAD_EXPIRY_SECONDS10 minutesHow long a signed upload stays valid.

What the actions check

  • Each key is a string without control characters, at most 1,024 bytes, and inside ROOT.
  • Uploads and moves never replace an existing file.
  • Each upload URL is signed for the file's exact type and size, so a different file can't use it.
  • Errors from the bucket are turned into messages that are safe to show. Anything unexpected is logged on the server and shown as a generic error.

Source

"use server";

// Server actions that give the file browser access to one bucket. Pass them
// to <FileBrowser source={{ list, getObject, ... }} />.
//
// Every server action is a public endpoint that anyone can call, so each one
// starts with `authorize()`. Write that check before you use the browser.

import { createS3Client, S3Error } from "@/lib/storage/s3";
import type {
  SignedUpload,
  StorageListing,
  StorageObjectDetails,
  StorageResult,
} from "@/lib/storage/types";

/**
 * Keeps the browser inside one folder of the bucket, such as "uploads/".
 * Keys outside it can't be read or changed, and the browser shows the folder
 * as the top. Leave empty to show the whole bucket.
 */
const ROOT = "";

/** Largest file the browser may upload, in bytes. One PUT can't exceed 5 GB. */
const MAX_UPLOAD_BYTES = 100 * 1000 * 1000;

/** How long links from Download and Copy link work, in seconds. */
const LINK_EXPIRY_SECONDS = 60 * 60;

/** How long a signed upload stays valid, in seconds. */
const UPLOAD_EXPIRY_SECONDS = 10 * 60;

const PAGE_SIZE = 100;
const MAX_KEY_BYTES = 1024;
const CONTROL_CHARACTERS = /\p{Cc}/u;

/** An error whose message is safe to show to the person using the browser. */
class StorageActionError extends Error {}

/**
 * Rejects when the person making the request may not use the bucket. For
 * example, with Better Auth:
 *
 *   async function authorize() {
 *     const session = await auth.api.getSession({ headers: await headers() });
 *     if (session?.user.role !== "admin") {
 *       throw new StorageActionError("You are not allowed to manage files.");
 *     }
 *   }
 */
function authorize(): Promise<void> {
  return Promise.reject(
    new StorageActionError(
      "Add an authorization check to lib/storage/actions.ts to use the file browser."
    )
  );
}

function readEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Set ${name} to use the file browser.`);
  }
  return value;
}

let client: ReturnType<typeof createS3Client> | undefined;

function getClient() {
  client ??= createS3Client({
    accessKeyId: readEnv("S3_ACCESS_KEY_ID"),
    bucket: readEnv("S3_BUCKET"),
    endpoint: process.env.S3_ENDPOINT || undefined,
    region: process.env.S3_REGION || "auto",
    secretAccessKey: readEnv("S3_SECRET_ACCESS_KEY"),
  });
  return client;
}

async function run<T>(task: () => Promise<T>): Promise<StorageResult<T>> {
  try {
    await authorize();
    return { data: await task(), ok: true };
  } catch (error) {
    if (error instanceof StorageActionError || error instanceof S3Error) {
      return { error: error.message, ok: false };
    }
    console.error(error);
    return { error: "Something went wrong. Try again.", ok: false };
  }
}

/** The bucket key for a key or folder the browser sent, checked. */
function toBucketKey(value: unknown, kind: "file" | "folder"): string {
  if (typeof value !== "string" || CONTROL_CHARACTERS.test(value)) {
    throw new StorageActionError("That name can't be used.");
  }
  if (kind === "file" && (value === "" || value.endsWith("/"))) {
    throw new StorageActionError("Enter a file name.");
  }
  if (kind === "folder" && value !== "" && !value.endsWith("/")) {
    throw new StorageActionError("That folder can't be opened.");
  }
  if (value.startsWith("/") || value.includes("//")) {
    throw new StorageActionError(
      "Names can't start with or contain empty folders."
    );
  }
  const key = ROOT + value;
  if (new TextEncoder().encode(key).length > MAX_KEY_BYTES) {
    throw new StorageActionError("That name is too long.");
  }
  return key;
}

/** The key as the browser sees it, without the root folder. */
function fromBucketKey(key: string): string {
  return key.slice(ROOT.length);
}

async function assertFree(key: string): Promise<void> {
  if (await getClient().headObject(key)) {
    throw new StorageActionError("A file with that name already exists.");
  }
}

export async function list(input: {
  cursor?: string | null;
  prefix: string;
  startsWith?: string;
}): Promise<StorageResult<StorageListing>> {
  return await run(async () => {
    const prefix = toBucketKey(input.prefix, "folder");
    const startsWith = input.startsWith ?? "";
    if (
      typeof startsWith !== "string" ||
      startsWith.includes("/") ||
      CONTROL_CHARACTERS.test(startsWith) ||
      startsWith.length > MAX_KEY_BYTES
    ) {
      throw new StorageActionError("Search for the start of one name.");
    }
    const listing = await getClient().listObjects({
      cursor: typeof input.cursor === "string" ? input.cursor : undefined,
      limit: PAGE_SIZE,
      prefix: prefix + startsWith,
    });
    return {
      folders: listing.folders.map(fromBucketKey),
      nextCursor: listing.nextCursor,
      objects: listing.objects
        // Some tools store an empty object as a folder marker.
        .filter((object) => object.key !== prefix)
        .map((object) => ({ ...object, key: fromBucketKey(object.key) })),
    };
  });
}

export async function getObject(
  key: string
): Promise<StorageResult<StorageObjectDetails>> {
  return await run(async () => {
    const object = await getClient().headObject(toBucketKey(key, "file"));
    if (!object) {
      throw new StorageActionError("The file no longer exists.");
    }
    return { ...object, key: fromBucketKey(object.key) };
  });
}

export async function getDownloadUrl(
  key: string,
  options: { download: boolean }
): Promise<StorageResult<string>> {
  return await run(() => {
    const bucketKey = toBucketKey(key, "file");
    return getClient().signDownloadUrl(bucketKey, {
      downloadAs:
        options.download === true
          ? bucketKey.slice(bucketKey.lastIndexOf("/") + 1)
          : undefined,
      expiresIn: LINK_EXPIRY_SECONDS,
    });
  });
}

export async function createUpload(input: {
  contentType: string;
  key: string;
  size: number;
}): Promise<StorageResult<SignedUpload>> {
  return await run(async () => {
    const key = toBucketKey(input.key, "file");
    if (
      !Number.isSafeInteger(input.size) ||
      input.size < 0 ||
      input.size > MAX_UPLOAD_BYTES
    ) {
      throw new StorageActionError(
        `Files can be up to ${MAX_UPLOAD_BYTES / 1000 / 1000} MB.`
      );
    }
    const contentType =
      typeof input.contentType === "string" &&
      input.contentType.length > 0 &&
      input.contentType.length <= 255
        ? input.contentType
        : "application/octet-stream";
    await assertFree(key);
    const signed = await getClient().signUploadUrl(key, {
      contentType,
      expiresIn: UPLOAD_EXPIRY_SECONDS,
      size: input.size,
    });
    return { ...signed, method: "PUT" };
  });
}

export async function moveObject(
  fromKey: string,
  toKey: string
): Promise<StorageResult<null>> {
  return await run(async () => {
    const from = toBucketKey(fromKey, "file");
    const to = toBucketKey(toKey, "file");
    if (from !== to) {
      await assertFree(to);
      await getClient().moveObject(from, to);
    }
    return null;
  });
}

export async function deleteObject(key: string): Promise<StorageResult<null>> {
  return await run(async () => {
    await getClient().deleteObject(toBucketKey(key, "file"));
    return null;
  });
}

On this page