npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@muhgholy/next-drive

v5.1.0

Published

File storage and management for Next.js applications

Readme

@muhgholy/next-drive

File storage and management for Next.js, Express and Hono apps: a MongoDB-backed drive with local or Google Drive storage, resumable uploads, a ready-made React UI, and server functions for your own code.

Features

  • 📁 Files & Folders – Upload, rename, move, reorder, trash, restore and delete
  • ⏯️ Resumable Uploads – tus protocol; multi-GB uploads survive dropped connections
  • 🎬 Media Metadata – Duration, width/height, fps, codec and audio track read on upload (FFmpeg), video thumbnails
  • 🗂️ Flat Library – List every file across folders, filtered by type and mime, newest first
  • 📊 Shared Quota – One allowance across many owner keys (e.g. all workspaces of an account), with a per-kind breakdown
  • 🪝 Hooks – Refuse actions ("file is in use"), audit uploads, react to deletes
  • 🔐 Security – Owner-scoped actions, signed URLs with an owner-session fallback, per-request upload limits, rate limits
  • 🗑️ Trash – Counted toward quota, auto-purged after a retention period
  • 🔓 Anonymous Uploads – One-time uploads with auto-expiry and abuse limits
  • 🖼️ Image Optimization – On-the-fly resize, crop and format conversion with caching
  • ☁️ Google Drive – Connect accounts and browse them like local storage
  • 📱 Responsive UI – Explorer, file chooser, grid/list views, drag & drop, keyboard navigation

Installation

npm install @muhgholy/next-drive

Requirements

| Dependency | Version | | ---------- | ------- | | Next.js | >= 14 | | React | >= 18 | | Mongoose | >= 7 | | TypeScript | >= 5 |

TypeScript Configuration:

This package uses subpath exports. Configure your tsconfig.json based on your project type:

For Next.js (App Router or Pages Router):

{
	"compilerOptions": {
		"module": "esnext",
		"moduleResolution": "bundler"
	}
}

For Node.js/Express servers:

{
	"compilerOptions": {
		"module": "nodenext",
		"moduleResolution": "nodenext"
	}
}

⚠️ The legacy "moduleResolution": "node" is not supported and will cause build errors with subpath imports like @muhgholy/next-drive/server.

FFmpeg (for video thumbnails):

# macOS
brew install ffmpeg

# Ubuntu
sudo apt install ffmpeg

# Windows
# Download from https://ffmpeg.org and add to PATH

Styles

Styles are automatically injected when you import components from @muhgholy/next-drive/client. No additional CSS import is required!

All CSS classes are prefixed with nd- to avoid conflicts with your project's styles. CSS variables are scoped to the .nd-drive-root container class.

If styles are not loading (e.g., with certain bundler configurations), you can manually import:

import "@muhgholy/next-drive/client/styles.css";

Architecture

Package entry points

| Import | Contents | |--------|----------| | @muhgholy/next-drive/server | driveConfiguration, driveAPIHandler, server functions, DriveError, DatabaseMongoDrive, server types | | @muhgholy/next-drive/server/express | driveConfigurationExpress, driveAPIHandlerExpress + everything framework-agnostic | | @muhgholy/next-drive/server/hono | driveConfigurationHono, driveAPIHandlerHono + everything framework-agnostic | | @muhgholy/next-drive/client | DriveProvider, useDrive, components, uploadFile(s), useUpload, driveCreateUrl, client types | | @muhgholy/next-drive/schemas | driveFileSchemaZod (works on client and server) |

Request flow

?action=…  ─►  driveAPIHandler (server/index.ts)
                 ├─ JSON body parse (Next.js runs with bodyParser: false) · CORS (actions/cors)
                 ├─ fileServe / fileThumbnail ──► actions/public   (token or owner session, rate limit, HTTP Range, image transforms)
                 ├─ information()  ─► owner key, quota, per-request limits, meta   (anonymous only for fileUpload)
                 ├─ account*  ──────► actions/account   (Google OAuth, connected accounts)
                 ├─ provider from `x-drive-account` header ─► providers (LOCAL | GOOGLE)
                 ├─ hooks.action.before
                 └─ actions/drive  ─► item*, folderCreate, trash*, quotaGet, fileUpload ─► upload/tus
                                         └─ DriveError ─► 400 { code, message }

Uploads use tus: onUploadCreate checks limits and quota early; onUploadFinish creates (or, for conflict: 'replace', reuses) the item, re-checks quota with the row counted, stores the bytes through the provider (hash + media metadata, then READY), and runs hooks.upload.after.

Source layout

src/
  schemas.ts                         universal zod schema (driveFileSchemaZod)
  server/
    index.ts                         driveAPIHandler + public exports
    config.ts                        driveConfiguration, getDriveInformation, process-wide state, hourly purge
    errors.ts  migration.ts  utils.ts
    adapters/      express.ts  hono.ts
    actions/       public.ts  account.ts  drive.ts  cors.ts
    controllers/drive/
                   file.ts         driveFileUrl/Read/Path/Info/Upload/Confirm
                   item.ts         driveItemList, driveItemDelete
                   quota.ts        driveQuotaGet
                   maintenance.ts  driveMaintenancePurge, driveMaintenanceCleanup
    providers/     index.ts (resolution)  local.ts  google.ts
    upload/        tus.ts
    database/mongoose/schema/   drive.ts  storage/account.ts
    zod/           schemas.ts      request validation
  client/
    index.ts  context.tsx  upload.ts  utils.tsx  style-injector.ts
    hooks/         use-upload.ts  use-marquee.ts  use-item-touch.ts
    components/drive/   chooser  explorer  file-grid  header  path-bar  sidebar  upload  dropzone
                        dnd/context  storage/indicator  folder/create  item/rename  account/rename
  types/                             mirrors src/ (types/server/…, types/client/…, types/schemas.ts)

Data model

Drive item (TDatabaseDrive) — the same shape from the API, server functions and upload results:

{
	id, accountId, name, parentId, order,
	type: 'FILE' | 'FOLDER' | 'BUNDLE' | 'LINK',
	kind,                 // registered kind of a BUNDLE (`project`), else null
	file?: { mime, path, hash, size: { bytes }, media?: { width, height, duration, fps, codec, audio } },
	bundle?: { size: { bytes } },   // a BUNDLE's directory
	link?: { targetId },            // a LINK's target (its `file` mirrors the target's details)
	provider: { type: 'LOCAL' | 'GOOGLE', google?: { id, link: { web, icon, thumbnail } } },
	status: 'READY' | 'PROCESSING' | 'UPLOADING' | 'FAILED',
	lock: { rename, move, delete },               // what the API refuses
	usage: { cache: { bytes }, extra: { bytes } }, // counted besides the item's own data
	tags, favorite: { actorIds },
	meta,                 // e.g. { createdBy, tusId }
	url,                  // serve URL, signed when `security.signing` is set
	trashedAt, expiresAt, createdAt, updatedAt,
}
  • BUNDLE: an app item (Launch video.project) — one item to the user, a local directory its app writes into (driveBundleWrite), and the parent of the files it owns (e.g. renders). Kinds are registered in kinds.
  • LINK: uses another owner's file without a copy (a shared download, a built-in asset). Stored once, counted in full for every owner linking it, served and read as its target, and deleted with it.

File reference (TDriveFile, the <DriveFileChooser> value) uses the same layout: { id, name, type, file?: { mime, size: { bytes } } }.

Storage usage (TDriveQuota):

{ quota: { bytes }, used: { bytes }, trash: { bytes }, files: { count },
  media: { video: { bytes }, audio: { bytes }, image: { bytes }, other: { bytes } },  // files and links by mime
  kinds: { [kind]: { bytes } } }                                                    // bundles by kind

Everything counts: file data, links, bundle directories (a file hard-linked into a Drive file counts once, there), caches (usage.cache) and what the app reports it keeps elsewhere (usage.extra, e.g. logs in its own database).

Local files are stored at {storage.path}/file/{id}/data.bin with thumbnails, image variants and app caches in …/cache/, bundles at {storage.path}/bundle/{id}/; folders and links exist only in MongoDB. Storage and database migrations run once at startup (tracked in {storage.path}/.migration-version).


Quick Start

1. Server Configuration

// lib/drive.ts
import { driveConfiguration, DriveError } from "@muhgholy/next-drive/server";

export const drive = driveConfiguration({
	database: "MONGOOSE",
	api: { url: "/api/drive" },
	storage: { path: "/var/data/drive" },
	security: {
		upload: { limit: { bytes: 50 * 1024 * 1024 }, mimes: ["image/*", "video/*", "application/pdf"] },
		signing: { secret: process.env.DRIVE_SECRET!, expiry: { seconds: 3600 } },
		trash: { retention: { days: 30 } },
	},
	information: async (input) => {
		const auth = input.method === "REQUEST" ? await verifyAuth(input.req) : { workspaceId: String(input.key?.workspaceId) };
		if (!auth) throw new Error("Unauthenticated");
		return {
			key: { workspaceId: auth.workspaceId },
			// ** One allowance shared by all of the owner's workspaces
			storage: { quota: { bytes: 10 * 1024 ** 3 }, owners: (await workspacesOf(auth)).map((workspaceId) => ({ workspaceId })) },
			// ** Per-request limits (override `security.upload`)
			security: { upload: { mimes: ["video/*", "audio/*", "image/*"] } },
			// ** Stamped on every new file and folder
			meta: { createdBy: { userId: auth.userId } },
		};
	},
	hooks: {
		action: {
			before: async ({ action, ids }) => {
				if ((action === "itemTrash" || action === "itemDelete") && (await isUsedByPlaylist(ids))) {
					throw new DriveError({ code: "REFUSED", message: "This file is used by a playlist" });
				}
			},
		},
		upload: { after: async ({ owner, drive }) => audit("upload", owner, drive.id) },
		delete: { after: async ({ owner, ids }) => clearEngineCache(owner, ids) },
	},
});
  • information is called for every API request (REQUEST) and by server functions (KEY). Throw to reject the request (a DriveError answers 400 with its code; any other error answers 500).
  • Quota and limits come from information() per request: storage.quota, security.upload (overrides the global one) and meta (stamped on new files and folders).
  • storage.owners: every key whose files count toward this quota. Each entry must have the same shape and key order as key (they're matched by deep equality). The caller's own key is always included.
  • Quota counts every finished file, including trash (it is still on disk), plus uploads still finalizing. Failed and abandoned uploads never count.
  • Hooks run for API requests only (server functions and the hourly purge skip them). Throwing a DriveError refuses the action with 400 { code, message }; upload.after refusing an upload deletes it.

2. API Route (Pages Router)

⚠️ Important: Must be in pages/ folder with body parser disabled.

// pages/api/drive.ts
import { drive } from "@/lib/drive";
import { driveAPIHandler } from "@muhgholy/next-drive/server";
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
	await drive;
	return driveAPIHandler(req, res);
}

export const config = { api: { bodyParser: false } };

Client

Provider

<DriveProvider
	api={{ endpoint: "/api/drive", credentials: false }} // ** credentials: send cookies cross-origin
	initial={{ accountId: null, selection: { type: "MULTIPLE", max: 10 }, selectedIds: [] }}
	lazy // ** default true: wait for `items.start()` (the chooser calls it when opened)
>
	{children}
</DriveProvider>

useDrive() returns the state grouped by domain:

| Group | Contents | |-------|----------| | api | endpoint, credentials, call(action, { query, ...fetchInit }) | | navigation | folderId, path, open(folder), up() | | items | list, set, loading, error, fetch(), start(), more: { has, loading, load() } | | accounts | list, activeId, setActive(id), refresh(), providers: { google } | | quota | value: TDriveQuota, refresh() | | live | revision — bumps on every change of the drive (one itemEvents stream per provider), for views that list on their own | | information | authenticated, upload: { limit: { bytes }, mimes } (the caller's effective limits) | | view | mode (GRID/LIST), current (BROWSE/TRASH/SEARCH), group, sort, accept, flat, search: { query, scope } — each with its setter | | selection | mode, files, ids, set, toggle(file), setMode | | actions | folder.create({ name }), item.rename({ id, name }), item.delete({ id }), item.move({ ids, targetId }), item.restore({ id }) — optimistic | | url | url({ file, action? }) — serve / thumbnail URL |

Components

| Component | Props | |-----------|-------| | DriveExplorer | accept, flat, className, onItemClick, onItemDoubleClick — full explorer (sidebar, path bar, upload, drop zone, grid) | | DriveFileChooser | value, onChange, multiple, max, accept, flat, placeholder, className, disabled, error, hint, anonymous, meta, trigger (render prop ({ open, disabled }) => ReactNode replacing the built-in field) | | DriveFileGrid | accept, className, onItemClick, onItemDoubleClick — grid/list with selection, marquee, keyboard, context menu, infinite scroll | | DriveUpload | compact, accept, meta, onComplete; ref add({ files, skipped }) | | DriveDropZone | onDrop({ files, skipped: { folders } }), disabled, className | | DriveHeader / DriveContentProgress | toolbar (new folder, actions, search, group, sort, view mode) / loading bar | | DrivePathBar | breadcrumbs (also drop targets) | | DriveSidebar / DriveSidebarContent | account switcher, Google connect, navigation, storage indicator | | DriveStorageIndicator | compact, className | | DriveDndProvider / useDriveDnd() | drag & drop + keyboard cut/paste: { drag: { overId, itemId }, cut: { ids, set, clear, paste } } |

// ** Flat library (every file of every folder, newest first), e.g. behind All / Video / Audio / Image tabs
<DriveExplorer flat accept="video/*,audio/*" />;

// ** Form field
const [file, setFile] = useState<TDriveFile | TDriveFile[] | null>(null);
<DriveFileChooser value={file} onChange={setFile} accept="image/*" multiple max={5} hint="Pick up to 5 images" meta={{ source: "cover" }} />;

// ** Custom trigger: your own button opens the picker
<DriveFileChooser value={null} onChange={add} multiple accept="video/*" trigger={({ open, disabled }) => <button onClick={open} disabled={disabled}>Upload</button>} />;
  • accept uses the HTML accept format. Mime entries (video/*, image/png) are filtered on the server, so paging stays correct (folders are still shown); .ext entries are matched in the browser.
  • Files outside the caller's per-request limits are rejected in the browser before uploading; the server enforces them too.
  • Upload meta is stamped on the item; keys from the server's information().meta win, so a client can't overwrite e.g. createdBy.
  • Grid cards show the duration of video/audio and the dimensions of images/videos.

Headless uploads

uploadFile / uploadFiles run the same resumable engine without React; useUpload adds the queue and conflict handling used by DriveUpload.

import { uploadFile, uploadFiles, useUpload } from "@muhgholy/next-drive/client";

const handle = uploadFile({
	api: { endpoint: "/api/drive" },
	file,
	folderId: "root",
	accountId: null,
	conflict: "replace", // ** 'rename' | 'replace' — replace keeps the existing item's id
	meta: { source: "import" },
	signal,
	onProgress: ({ percent, bytes, chunks }) => console.log(percent, bytes.uploaded, chunks.current),
	onLog: ({ type, message }) => console.log(type, message),
	onComplete: (item) => console.log(item.id),
});
const result = await handle.promise; // ** { status: 'complete', driveId, file, item } | { status: 'cancelled' } | { status: 'error', error }

const batch = uploadFiles({ api: { endpoint: "/api/drive" }, files, concurrency: 3, onFileComplete: (result) => {} });
batch.cancel.all();

// ** React: { list, add, reject, cancel: { one, all }, conflict: { resolve, all } }
const uploader = useUpload({ api: { endpoint: "/api/drive" }, accountId: null, onComplete: ({ item, replaced }) => {} });
uploader.add({ files, folderId: null, existing: [] });

URLs

import { driveCreateUrl } from "@muhgholy/next-drive/client";

driveCreateUrl({ file: { id }, api: { endpoint: "/api/drive" } }); // ** ?action=fileServe&id=…
driveCreateUrl({ file: { id }, api: { endpoint: "/api/drive" }, action: "fileThumbnail" });

These carry no token: they work for the logged-in owner (see Signed URLs). Items from the API also carry a ready-made url.


Express and Hono

import { driveAPIHandlerExpress, driveConfigurationExpress } from "@muhgholy/next-drive/server/express";
import { driveAPIHandlerHono, driveConfigurationHono } from "@muhgholy/next-drive/server/hono";

Both take the same configuration. information and hooks receive the framework's own request (Request / Context).


API Actions

All requests go to ?action=<name>. Responses are { status, message, code?, data?, statistic? }.

| Action | Description | |--------|-------------| | information | { providers: { google }, mode, authenticated, anonymous: { upload }, upload: { limit: { bytes }, mimes } } (the caller's effective limits) | | itemList | folderId (root / all / id), type (FILE / FOLDER / BUNDLE / LINK), kind, tag, favorite=true, repeatable mime (video/*), sort (order / createdAt / updatedAt), limit, afterId → { items, more } | | itemSearch | q, trashed, plus the itemList filters (folderId defaults to all) | | itemRename / itemMove / itemReorder | { name } / { ids, targetId } / { ids } | | itemTrash / itemRestore / itemDelete | ?id= — move to trash, restore, delete permanently | | itemUpdate | { id, tags?, favorite? } — tags, or starring for the caller (information.actor) | | itemEvents | server-sent events: change whenever anything of the caller's drive changes (the provider refetches on it) | | folderCreate | { name, parentId } | | fileUpload | tus endpoint (resumable upload) | | fileServe / fileThumbnail | ?id=&token= — public (see Signed URLs). fileServe honours HTTP Range (206) on raw files, so <video>/<audio> can seek | | trashList / trashEmpty | list or permanently empty the trash | | quotaGet | TDriveQuota | | accountAuth / accountList / accountRename / accountRemove | connected storage accounts (Google Drive) |

  • Storage account: send x-drive-account: <accountId> to act on a connected Google Drive; omit it (or LOCAL) for local storage.
  • Uploads (fileUpload) are tus requests. Upload metadata: filename, filetype, folderId, conflict (rename / replace) and meta (JSON). The final response carries the created item in the X-Drive-Item header (URI-encoded JSON).
  • Hooks: hooks.action.before receives the action and the ids from the body (ids) or query (id); for uploads it runs once, on creation.
  • Locks: itemRename / itemMove / itemTrash / itemDelete refuse an item whose lock names it (code: 'LOCKED'). Server functions are not bound by locks.
  • Owner scoping: every action only sees the caller's items (ROOT mode sees all). Move/create targets must be an owned, live folder, and a folder can't be moved into itself or its own subfolders.

Signed URLs

With security.signing, fileServe / fileThumbnail need a valid token or a session whose owner owns the file. Tokens are for sharing and for server-to-server use; in-app links (driveCreateUrl, useDrive().url) work for logged-in owners without one. Session-served responses are sent with Cache-Control: private.

import { driveFileUrl } from "@muhgholy/next-drive/server";

driveFileUrl({ id }); // ** signed with the configured expiry
driveFileUrl({ id, expiry: { seconds: 60 }, action: "fileThumbnail" });

Items returned by the API and server functions already carry a signed url.


Server Functions

import {
	driveFileUpload, driveFileRead, driveFilePath, driveFileInfo, driveFileConfirm, driveFileUrl,
	driveFileWrite, driveFileCache, driveBundleCreate, driveBundlePath, driveBundleWrite, driveLinkCreate,
	driveItemList, driveItemDelete, driveItemUpdate, driveFolderCreate, driveOwnerDelete, driveQuotaGet, driveQuotaAssert,
	driveMaintenancePurge, driveMaintenanceCleanup, DriveError,
} from "@muhgholy/next-drive/server";

// ** Upload (path, stream or buffer). Applies the owner's limits, quota and `information.meta`.
// ** folder: { id } or { path } (missing folders are created); mime: detected from the name when omitted
const item = await driveFileUpload({ source: "/tmp/a.mp4", key, name: "a.mp4", folder: { path: "videos/2026" }, accountId, mime, bypass: { quota: false }, meta: { source: "import" } });

// ** Details (same item shape as the API) / keep an anonymous upload
const details = await driveFileInfo({ file: item.id });
await driveFileConfirm({ id: item.id });

// ** Read / local path (Google files are downloaded to the cache first)
const { stream, mime, size } = await driveFileRead({ file: item.id, onProgress: ({ bytes, percentage }) => {}, signal });
const { path } = await driveFilePath({ file: item.id });

// ** Flat, filtered, paged listing (omit `key` to list across all owners)
// ** pagination: { page, limit, more, total: { items, pages } }
const { items, pagination } = await driveItemList({ key, folderId: "all", type: "FILE", mime: ["video/*"], sort: "createdAt", page: 1, limit: 50 });

// ** Usage across every shared owner
const quota = await driveQuotaGet({ key });

// ** Permanently delete (folders recursively unless `recurse: false`)
await driveItemDelete({ item: item.id });

// ** Write files in place (local only): `write` gets one path per entry, named like the file. `{ id }` replaces content.
const [video] = await driveFileWrite({ key, files: [{ name: "Final.mp4", folder: { id: bundle.id } }], write: async ([output]) => render({ output }) });

// ** A derived file kept with a file (counted as its cache, written once, removed with it)
const waveform = await driveFileCache({ file: { id: video.id }, name: "waveform.json", write: async target => writeWaveform(target) });

// ** Bundles: create, write into (re-measured after), read from
const bundle = await driveBundleCreate({ key, kind: "project", name: "Launch video", folder: { path: "Projects" }, lock: { move: false } });
await driveBundleWrite({ bundle, write: async directory => fs.promises.writeFile(path.join(directory, "state.json"), json) });
const directory = driveBundlePath({ bundle });

// ** Link another owner's file (once per owner; linking again returns the same link)
const link = await driveLinkCreate({ key, target: { id: sharedFileId }, folder: { path: "Downloads" } });

// ** Locked system folders, app-reported bytes, tags
const projects = await driveFolderCreate({ key, folder: { path: "Projects" }, lock: { rename: true, move: true, delete: true } });
await driveItemUpdate({ item: bundle, extra: { bytes: logBytes } });

// ** Refuse work when the storage is full (or `bytes` more would not fit)
await driveQuotaAssert({ key, bytes: 0 });

// ** Everything of an owner, permanently
await driveOwnerDelete({ key });

Every create and write (driveFileWrite, driveBundleCreate, driveLinkCreate) is refused with QUOTA_EXCEEDED once the owner is full, unless bypass: { quota: true }; a running write may finish slightly over.

Errors thrown on purpose are DriveError with a code (NOT_FOUND, QUOTA_EXCEEDED, INVALID_MIME_TYPE, FILE_TOO_LARGE, ACCESS_DENIED, ABORTED, REFUSED, ...). Create your own with new DriveError({ code, message }).

For direct queries, the Mongoose model is exported as DatabaseMongoDrive (fields as in the data model, plus owner).

Maintenance

driveMaintenancePurge() runs hourly on its own. It deletes expired anonymous uploads, trash older than security.trash.retention.days, failed uploads, and uploads abandoned for over 3 hours. driveMaintenanceCleanup() removes file and bundle directories on disk with no database row, plus expired incomplete uploads.


Anonymous (One-Time) Uploads

security: {
	// ...
	anonymous: {
		ttl: { minutes: 60 },
		upload: { limit: { bytes: 5 * 1024 * 1024 }, mimes: ["image/*"] },
		abuse: {
			ip: { window: { minutes: 10 }, max: 5 },
			hourly: { max: 20 },
			concurrent: { max: 10 },
			live: { bytes: 500 * 1024 * 1024 },
			headers: { trusted: ["cf-connecting-ip", "x-forwarded-for"] },
		},
	},
}

<DriveFileChooser anonymous /> falls back to a one-time upload for visitors who aren't logged in. Keep a file with driveFileConfirm({ id }); unconfirmed files are purged after ttl.


Configuration Reference

| Key | Description | |-----|-------------| | mode | NORMAL (default, information required) or ROOT (no auth, unlimited quota, sees every owner) | | kinds | { [name]: { extension, on?: { trash, restore, delete } } } — bundle kinds; on hears about every bundle of the kind trashed, restored or permanently deleted (also inside folders, also from server code) | | information() → actor | { id } — the person behind the request (favorites) | | security.upload | { limit: { bytes }, mimes } — global upload limits | | security.signing | { secret, expiry: { seconds } } — signed serve URLs with the owner-session fallback | | security.trash | { retention: { days } } — auto-purge | | security.serve | { limit: { window: { minutes }, max }, headers?: { trusted }, client?: { resolve(req) } } — rate limit for fileServe/fileThumbnail (every request counts, including media Range requests) | | security.transform | { concurrency } — max simultaneous sharp/ffmpeg jobs | | security.cache | { expiry: { days } } — evict old image variants | | security.anonymous | anonymous uploads (see above) | | cors | { origins, methods, credentials, headers: { allowed, exposed }, preflight: { seconds } } — present = enabled | | storage.google | { client: { id, secret }, redirect: { uri } } — Google Drive accounts |


Upgrading from v4

Stored data is migrated once on startup (migration v2). Renamed keys:

| v4 | v5 | |----|----| | apiUrl | api.url | | security.maxUploadSizeInBytes / allowedMimeTypes | security.upload.limit.bytes / security.upload.mimes | | security.signedUrls { enabled, secret, expiresIn } | security.signing { secret, expiry: { seconds } } | | security.trash.retentionDays | security.trash.retention.days | | security.publicRateLimit | security.serve | | security.transformConcurrency / cacheMaxAgeDays | security.transform.concurrency / security.cache.expiry.days | | security.unauthenticated | security.anonymous | | cors.enabled / allowedHeaders / exposedHeaders / maxAge | presence / headers.allowed / headers.exposed / preflight.seconds | | storage.google.clientId / clientSecret / redirectUri | storage.google.client.id / client.secret / redirect.uri | | information() → storage.quotaInBytes | storage.quota.bytes | | item information.{type,mime,sizeInBytes,width,height,duration} | type, file.{mime,size.bytes,media.*} | | item storageAccountId / token | accountId / (inside url) | | TDriveQuota.kinds (by mime) | media (by mime); kinds is now usage by bundle kind | | TDriveFile { id, isFolder, file: { name, mime, size } } | { id, name, type, file?: { mime, size: { bytes } } } (item layout) | | actions list, search, upload, serve, delete, deletePermanent, ... | see API Actions | | driveUpload, driveReadFile, driveInfo, driveList / driveListFiles, driveDelete, driveGetUrl, driveConfirm, drivePurgeExpired, driveCleanup | driveFileUpload, driveFileRead, driveFileInfo, driveItemList, driveItemDelete, driveFileUrl, driveFileConfirm, driveMaintenancePurge, driveMaintenanceCleanup (all take one props object) | | <DriveProvider apiEndpoint withCredentials> | <DriveProvider api={{ endpoint, credentials }}> | | <DriveFileChooser maxFiles helperText allowUnauthenticated> | max, hint, anonymous | | mimeFilter prop | accept | | DriveUpload ref handleFiles(files, skippedFolders) / uploadFiles(files, options) / cancelAll() | add({ files, skipped: { folders } }) / uploadFiles({ files, ...options }) / cancel.all() |


Google Drive Integration

  1. Create OAuth credentials in Google Cloud (Drive API enabled) with the redirect URI pointing at your API route.
  2. Configure storage.google: { client: { id, secret }, redirect: { uri } }.
  3. Users connect accounts from the sidebar. Scopes: drive and userinfo.email.

Image Optimization

Serve optimized images with dynamic compression, resizing, and format conversion using query parameters.

URL Format

/api/drive?action=fileServe&id={fileId}&quality={preset}&display={context}&size={scale}&fit={mode}&position={anchor}&format={format}

Parameters

| Parameter | Type | Description | |-----------|------|-------------| | quality | low / medium / high / 1-100 | Compression level | | display | string | Sets aspect ratio, base dimensions, quality factor, and default fit | | size | string | Scale factor (xs/sm/md/lg/xl) or standalone dimension preset | | fit | cover / contain / fill / inside / outside | How image fits into dimensions | | position | center / top / bottom / left / right / attention / entropy | Crop anchor point (for cover/contain) | | format | jpeg / webp / avif / png | Output format |

Fit Options

| Fit | Behavior | Use Case | |-----|----------|----------| | cover | Crop to fill exact dimensions | Thumbnails, avatars, cards | | contain | Fit within dimensions (may letterbox) | Logos, icons | | fill | Stretch to exact dimensions (may distort) | Background fills | | inside | Fit within, no upscaling (default) | Article images | | outside | Cover minimum dimensions | Backgrounds |

Position Options (for cover/contain)

| Position | Anchor Point | |----------|--------------| | center | Center (default) | | top | Top center | | bottom | Bottom center | | left | Left center | | right | Right center | | attention | Focus on most "interesting" area (AI-based) | | entropy | Focus on highest entropy area |

How Display + Size Work Together

When display is specified, it defines the aspect ratio, base dimensions, and default fit. The size parameter then scales those dimensions:

display=article-image + size=sm  → 400×225  (16:9, half size, fit=inside)
display=thumbnail + size=md     → 150×150  (1:1, fit=cover)
display=avatar + fit=contain    → 128×128  (override default cover to contain)

When no display is specified, size uses standalone presets (fixed dimensions).

Quality Presets

| Preset | Base Quality | Use Case | |--------|--------------|----------| | low | 30 | Thumbnails, previews | | medium | 50 | General content | | high | 75 | High-quality display | | 1-100 | Custom | Fine-tuned control |

Quality is dynamically adjusted based on file size. Larger files get more aggressive compression.

Display Presets (Aspect Ratio + Dimensions + Fit)

| Display | Aspect Ratio | Base Size | Quality | Default Fit | |---------|--------------|-----------|---------|-------------| | article-header | 16:9 | 1200×675 | 0.9 | inside | | article-image | 16:9 | 800×450 | 0.85 | inside | | thumbnail | 1:1 | 150×150 | 0.7 | cover | | avatar | 1:1 | 128×128 | 0.8 | cover | | logo | 2:1 | 200×100 | 0.95 | contain | | card | 4:3 | 400×300 | 0.8 | cover | | gallery | 1:1 | 600×600 | 0.85 | cover | | og | ~1.9:1 | 1200×630 | 0.9 | cover | | icon | 1:1 | 48×48 | 0.75 | cover | | cover | 16:9 | 1920×1080 | 0.9 | cover | | story | 9:16 | 1080×1920 | 0.85 | cover | | video | 16:9 | 1280×720 | 0.85 | cover | | banner | 3:1 | 1200×400 | 0.9 | cover | | portrait | 3:4 | 600×800 | 0.85 | inside | | landscape | 4:3 | 800×600 | 0.85 | inside |

Size Scale (with Display)

When used with a display preset, size scales the dimensions:

| Size | Scale | Example with article-image (800×450) | |------|-------|----------------------------------------| | xs | 0.25× | 200×113 | | sm | 0.5× | 400×225 | | md | 1.0× | 800×450 | | lg | 1.5× | 1200×675 | | xl | 2.0× | 1600×900 | | 2xl | 2.5× | 2000×1125 |

Standalone Size Presets (without Display)

When no display is specified, use these fixed dimension presets:

| Size | Dimensions | Size | Dimensions | |------|------------|------|------------| | xs | 64×64 | landscape-sm | 480×270 | | sm | 128×128 | landscape | 800×450 | | md | 256×256 | landscape-lg | 1280×720 | | lg | 512×512 | portrait-sm | 270×480 | | xl | 1024×1024 | portrait | 450×800 | | icon | 48×48 | wide | 1200×630 | | thumb | 150×150 | banner | 1200×400 | | video | 1280×720 | card | 400×300 |

Examples

<!-- Article image, smaller variant (400×225, fit=inside) -->
<img src="/api/drive?action=fileServe&id=123&display=article-image&size=sm&format=webp">

<!-- Thumbnail with cover fit (crops to fill 150×150 square) -->
<img src="/api/drive?action=fileServe&id=123&display=thumbnail&format=webp">

<!-- Avatar with top-focused crop (for face photos) -->
<img src="/api/drive?action=fileServe&id=123&display=avatar&fit=cover&position=top&format=webp">

<!-- Gallery with AI-based attention crop -->
<img src="/api/drive?action=fileServe&id=123&display=gallery&fit=cover&position=attention&format=webp">

<!-- Card image, override default cover to contain -->
<img src="/api/drive?action=fileServe&id=123&display=card&fit=contain&format=webp">

<!-- Banner with custom position -->
<img src="/api/drive?action=fileServe&id=123&display=banner&position=bottom&format=webp">

<!-- Standalone size, no display -->
<img src="/api/drive?action=fileServe&id=123&size=landscape&fit=cover&format=webp">

<!-- Just quality, no resize -->
<img src="/api/drive?action=fileServe&id=123&quality=medium&format=webp">

License

MIT