@muhgholy/next-drive
v5.1.0
Published
File storage and management for Next.js applications
Maintainers
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-driveRequirements
| 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 PATHStyles
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 inkinds. - 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 kindEverything 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) },
},
});informationis called for every API request (REQUEST) and by server functions (KEY). Throw to reject the request (aDriveErroranswers 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) andmeta(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 askey(they're matched by deep equality). The caller's ownkeyis 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
DriveErrorrefuses the action with400 { code, message };upload.afterrefusing 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>} />;acceptuses the HTMLacceptformat. Mime entries (video/*,image/png) are filtered on the server, so paging stays correct (folders are still shown);.extentries 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
metais stamped on the item; keys from the server'sinformation().metawin, 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 (orLOCAL) for local storage. - Uploads (
fileUpload) are tus requests. Upload metadata:filename,filetype,folderId,conflict(rename/replace) andmeta(JSON). The final response carries the created item in theX-Drive-Itemheader (URI-encoded JSON). - Hooks:
hooks.action.beforereceives the action and theidsfrom the body (ids) or query (id); for uploads it runs once, on creation. - Locks:
itemRename/itemMove/itemTrash/itemDeleterefuse an item whoselocknames it (code: 'LOCKED'). Server functions are not bound by locks. - Owner scoping: every action only sees the caller's items (
ROOTmode 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
- Create OAuth credentials in Google Cloud (Drive API enabled) with the redirect URI pointing at your API route.
- Configure
storage.google: { client: { id, secret }, redirect: { uri } }. - Users connect accounts from the sidebar. Scopes:
driveanduserinfo.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
