@aginix/adonis-vulcan-storage
v0.2.1
Published
Drop-in object-storage HTTP module for AdonisJS: signed upload/download URLs, Lucid-backed metadata, path-based security rules, on top of @adonisjs/drive.
Maintainers
Readme
@aginix/adonis-vulcan-storage
Drop-in object-storage HTTP module for AdonisJS. Signed upload + download URLs, Lucid-backed metadata, path-based security rules. Wraps
@adonisjs/driveso consumers never calldrive.use()themselves.
What you get
- A
StorageObjectLucid model with built-in disk helpersconst object = await StorageObject.findOrFail(id) const url = await object.getSignedDownloadUrl() // signed download URL await object.deleteFromStorage() // remove from disk - A ready-made HTTP API mounted at
/storage/v1:POST /objects/upload— reserve a path, return a signed upload URLGET /objects/:id— fetch metadataGET /objects/:id/sign— issue a signed download URLDELETE /objects/:id— remove the row + the underlying blobPUT /upload?token=...— proxy upload (auto-engages when the disk can't sign; row id lives in the encrypted token)GET /download?token=...— proxy download (same model)
- Disk- and path-based security rules with
:paramand**placeholders, scoped per Drive disk - Migration auto-published via
node ace configure— just runmigration:run - Zero scaffolding required: import
StorageObjectfrom the package directly. Subclass only if you want extra columns or relations.
Install
npm install @aginix/adonis-vulcan-storage @adonisjs/drive @adonisjs/lucid
node ace configure @aginix/adonis-vulcan-storage
node ace migration:runThe configure hook publishes three files:
config/storage.ts— module configstart/storage.ts— routes + security rules (this is where you wire your auth middleware and define which paths users can read/write)database/migrations/{ts}_create_storage_objects_table.ts— thestorage_objectstable
and adds two entries to adonisrc.ts:
'@aginix/adonis-vulcan-storage/storage_provider'provider'#start/storage'preload file
Required setup
After configure you still need to do four things before uploads work end-to-end. The first two are non-negotiable; the last two only matter if you upload text/* payloads or run against an internally-deployed S3.
1. Pick a Drive disk in config/drive.ts
This module never calls drive.use() itself — it always reads a disk name from a row or from config/storage.ts. So you need at least one disk configured via @adonisjs/drive:
// config/drive.ts
export default defineConfig({
default: env.get('DRIVE_DISK'),
services: {
s3: services.s3({
bucket: env.get('S3_BUCKET'),
// ... credentials, region, endpoint
}),
fs: services.fs({ location: app.makePath('storage'), visibility: 'public' }),
},
})2. Set defaultDisk in config/storage.ts
defaultDisk is the Drive disk name used when an upload doesn't pick one explicitly. It must match a key from config/drive.ts. Everything else has sane defaults — see Configuration.
// config/storage.ts
export default defineConfig({
defaultDisk: env.get('DRIVE_DISK'),
})3. Wire auth middleware + security rules in start/storage.ts
Without any defineRule() call, the module denies every operation (fail-closed). The simplest viable setup mounts the routes inside your auth() middleware so ctx.auth.user is available to your rule handlers:
import { defineRule } from '@aginix/adonis-vulcan-storage'
import { storageRoutes } from '@aginix/adonis-vulcan-storage/routes'
import { middleware } from './kernel.js'
storageRoutes({
prefix: 'storage/v1',
middleware: [middleware.auth()],
})
defineRule('s3/users/:userId/files/**', {
read: async ({ auth }, params) => String(auth.user?.id) === params.userId,
create: async ({ auth }, params) => String(auth.user?.id) === params.userId,
delete: async ({ auth }, params) => String(auth.user?.id) === params.userId,
maxDownloadTtlSeconds: 60 * 60,
})
defineRule('*/public/**', {
read: async () => true,
})[!important]
storageRoutesis imported from@aginix/adonis-vulcan-storage/routes(a subpath), not from the package root. The barrel can't re-export it because@adonisjs/core/services/routerruns a top-levelawait app.booted()that crashes duringnode ace configure. The publishablestart/storage.tsstub already uses the right path; only deviate if you know why.
Rule template format is <disk>/<path>. The disk segment:
- a literal disk name (e.g.
s3) — matches that exact disk :name— captures the disk name intoparams.name*— matches any disk (params.diskstill gets the matched name)
The path segment supports :name (single segment, exposed as params.name) and ** (matches any suffix at the end).
Rules are evaluated in registration order; the first matching template wins. A missing handler for an action denies. Without any rules every action is denied — the module is fail-closed.
4. Disable bodyparser's raw parser if you upload text/* content
Adonis bodyparser's default raw.types is ['text/*'] — every text/html, text/csv, text/plain upload is consumed and stashed as a UTF-8 string before any route handler runs. The storage controller falls back to that cached text, so ASCII-compatible uploads still work, but the round-trip through Buffer.from(str, 'utf8') corrupts non-UTF-8 bytes (TIS-620, Windows-874, Latin-1, mixed-encoding CSVs from legacy Excel exports).
If you upload any text content, override the default in config/bodyparser.ts:
export default defineConfig({
// ... rest of your config
raw: { types: [] }, // give the storage proxy route the raw bytes
})With that off, the proxy upload route reads bytes straight from the request stream — byte-exact regardless of Content-Type.
5. Force proxy mode for internally-deployed S3-compatible disks
Optional, only relevant if you run rustfs / minio / ceph behind an internal endpoint. See Proxy mode for internally-deployed S3-compatible disks.
Paths
Object paths are validated server-side at reservation time. The character set matches flydrive's exactly — anything outside fails fast with E_INVALID_STORAGE_PATH (HTTP 400) before any DB row is created:
- letters, digits,
-,_,!,/,., and whitespace - no leading or trailing
/ - no
./..segments (path-traversal guard) - max 1024 chars
- no control characters
If your users supply filenames, sanitize on the client. A simple name.replace(/[^A-Za-z0-9\-_!.\s]/g, '_') is enough for the common cases (parens, brackets, unicode, URL specials → _); see apps/demo/inertia/pages/posts/index.tsx for a worked example.
Use it from your code
Model utilities
StorageObject lives behind a subpath export (so the configure hook doesn't drag @adonisjs/lucid / @adonisjs/drive / luxon into its import chain when those peers aren't installed yet):
import StorageObject from '@aginix/adonis-vulcan-storage/storage_object'
const object = await StorageObject.findOrFail(id)
const downloadUrl = await object.getSignedDownloadUrl({ expiresIn: '5 mins' })
const uploadUrl = await object.getSignedUploadUrl()
const metadata = await object.getStorageMetadata()
const exists = await object.existsInStorage()
await object.deleteFromStorage() // only the blob; keeps the row
await object.delete() // only the row; keeps the blobThe model picks the right disk based on the object's disk column — no drive.use() needed.
Service-layer orchestration (with authorization)
import storage from '@aginix/adonis-vulcan-storage/services/main'
// Issue a signed download with auth + rule enforcement
const { url, expiresInSeconds } = await storage.requestDownload(ctx, objectId)
// Reserve an upload (validates path, authorizes 'create', upserts the row)
const { object, uploadUrl } = await storage.createUpload({
ctx,
path: `users/${ctx.auth.user!.id}/avatar.png`,
name: 'avatar.png',
contentType: 'image/png',
})
// Authorize 'delete' + remove row + best-effort blob cleanup
await storage.delete(ctx, objectId)Configuration
config/storage.ts:
import env from '#start/env'
import { defineConfig } from '@aginix/adonis-vulcan-storage'
export default defineConfig({
defaultDisk: env.get('DRIVE_DISK'),
uploadUrlTtl: '15 mins',
downloadUrlTtlDefault: '15 mins',
downloadUrlTtlMaxSeconds: 60 * 60 * 24 * 7,
lastAccessedThrottleHours: 1,
})| Field | Default | Notes |
|---|---|---|
| defaultDisk | (required) | Drive disk name used when an upload omits disk. |
| uploadUrlTtl | '15 mins' | TTL for signed upload URLs. |
| downloadUrlTtlDefault | '15 mins' | Default TTL for signed download URLs. |
| downloadUrlTtlMaxSeconds | 60 * 60 * 24 * 7 | Hard ceiling on download URL TTL. Rules can clamp further via maxDownloadTtlSeconds. |
| lastAccessedThrottleHours | 1 | Min hours between lastAccessedAt writes per object. Set 0 to disable. |
| proxyOnlyDisks | [] | Disk names whose signed URLs aren't reachable from the client (e.g. internal rustfs / minio). Listing a disk here forces proxy mode regardless of the driver's signing capability. |
Customizing
Custom routes
storageRoutes({...}) returns a route group, so you can chain or wrap it:
storageRoutes({
prefix: 'api/files',
middleware: [middleware.auth(), middleware.throttle()],
})Custom controller
storageRoutes({
controller: () => import('#controllers/my_storage_controller'),
})Custom service
Register your subclass in your own provider's register() — it overrides the package binding:
import { StorageService } from '@aginix/adonis-vulcan-storage'
class MyStorageService extends StorageService {
protected resolveUserId(ctx) {
return ctx._user?.id ?? null // your auth shape
}
}
// in your provider:
this.app.container.singleton('storage', () => new MyStorageService())Custom model (extra columns / relations)
import StorageObject from '@aginix/adonis-vulcan-storage/storage_object'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
export default class AppStorageObject extends StorageObject {
@belongsTo(() => User, { foreignKey: 'createdBy' })
declare creator: BelongsTo<typeof User>
}(The storage_objects table already exists; just add columns/FKs via your own migration if needed.)
Content validation
The package doesn't ship content validation — it trusts what your security rules let through. Plug your own logic in at one of three points, picking based on what you can know when:
Claim-based, at reserve time
The create handler in defineRule receives the full request context, so you can reject uploads on the client-supplied contentType and size before any DB row is created or any byte is transferred:
const ALLOWED_MIMES = ['image/png', 'image/jpeg', 'application/pdf']
const MAX_BYTES = 10 * 1024 * 1024
defineRule('s3/users/:userId/posts/**', {
create: async ({ auth, request }, params) => {
if (String(auth.user?.id) !== params.userId) return false
const { contentType, size } = request.body() as { contentType?: string; size?: number }
if (!contentType || !ALLOWED_MIMES.includes(contentType)) return false
if (typeof size === 'number' && size > MAX_BYTES) return false
return true
},
read: ...,
delete: ...,
})Cheapest by far, but the client can lie about both fields. Use this as the first gate, not the only one.
Server-side, at proxy upload
For the proxy upload path (FS in dev, disks listed in proxyOnlyDisks), subclass StorageService and inspect the buffered body before it's written:
import { StorageService } from '@aginix/adonis-vulcan-storage/storage_service'
class MyStorageService extends StorageService {
async proxyUpload(token, file) {
// (Token + row resolution happen inside super.proxyUpload —
// for finer control, copy that prologue here.)
if (file.stream || file.rawBody) {
const body = await readBuffer(file) // your helper or use a small lib
if (!magicBytesLookOk(body, file.contentType)) {
throw new E_STORAGE_AUTHORIZATION()
}
}
return super.proxyUpload(token, file)
}
}Wire your subclass via the 'storage' container binding (see Custom service).
This catches real bytes, but only for proxy mode — direct-to-S3 signed uploads (the production hot path) skip your server entirely. Treat this as a dev / staging safety net, not a primary defense.
Post-upload async, universal
Hook into the model's @afterCreate lifecycle and queue a background job that reads the object back from storage:
import { afterCreate } from '@adonisjs/lucid/orm'
import StorageObject from '@aginix/adonis-vulcan-storage/storage_object'
import queue from '#services/queue'
export default class AppStorageObject extends StorageObject {
@afterCreate()
static enqueueValidation(row: AppStorageObject) {
void queue.add('storage:validate', { objectId: row.id })
}
}// worker handler
const obj = await AppStorageObject.findOrFail(payload.objectId)
const bytes = await obj.getDisk().getBytes(obj.path)
if (!(await scanClean(bytes))) {
await obj.deleteFromStorage() // best-effort; ignores if already gone
await obj.delete()
return
}
obj.metadata = { ...obj.metadata, validated: true }
await obj.save()Works regardless of upload mode (proxy or direct-to-S3), runs off the request path, and the row's metadata.validated flag gives the rest of your app a single fact to check before exposing the object. The tradeoff is the validation window: malicious content sits in storage between upload completion and the job running — never present it to other users until metadata.validated === true.
Recommendation
Combine claim-based (cheap front gate via security rule) with post-upload async (the universal defense). The server-side proxy-upload hook is useful in dev and staging when you don't want to wire a queue, but don't rely on it in production: real uploads will bypass it via signed URLs.
Drivers that can't sign URLs (FS in dev)
The bundled @adonisjs/drive FS driver doesn't support signed URLs — getSignedUploadUrl() / getSignedUrl() throw E_CANNOT_GENERATE_URL. Rather than fail your local dev, the service catches that specific error and mints an encrypted, time-bounded, purpose-scoped token instead, then returns a same-origin URL with the token in the query string. The behavior matches how S3/GCS signed URLs work:
| | Signed mode (S3 / GCS) | Proxy mode (FS) |
|---|---|---|
| uploadUrl / url returned | external signed URL | /storage/v1/upload?token=... or /storage/v1/download?token=... |
| Authorization | URL signature | encrypted token in query (adonis-vulcan-storage:upload / :download purposes) |
| Object identity | encoded in the signature | encoded in the encrypted token (no :id URL segment) |
| Client headers required | none | none |
The two proxy endpoints are registered outside your middleware group on purpose — the token IS the auth. So <img src="..."> and window.location work directly with no header juggling, and clients (@aginix/vulcan-js) PUT raw bytes the same way they would to a signed S3 URL. The response carries an informational proxied: boolean field, but the SDK doesn't act on it — the URL is self-describing.
[!note] The proxy is a correctness fallback, not a performance choice. For prod, configure S3 / GCS / a signing-capable FS driver — your app server stays out of the data path.
Proxy mode for internally-deployed S3-compatible disks
Auto-fallback only triggers when the driver throws E_CANNOT_GENERATE_URL. An S3-compatible disk (rustfs, minio, ceph) deployed on an internal network signs fine — but the URL it produces points at an internal host the browser can't reach. Tell the module to skip the signed-URL fast path for those disks via proxyOnlyDisks:
export default defineConfig({
defaultDisk: env.get('DRIVE_DISK'),
proxyOnlyDisks: ['rustfs'],
})The disk still works exactly like a signing-capable disk from the SDK's perspective — only the URL it gets back happens to be same-origin with a token. Use this for staging/preview environments where storage isn't publicly exposed.
Errors
All errors are AdonisJS Exception subclasses and render to the right HTTP status automatically.
| Class | Status | Code |
|---|---|---|
| E_INVALID_STORAGE_PATH | 400 | E_INVALID_STORAGE_PATH |
| E_STORAGE_AUTHORIZATION | 403 | E_STORAGE_AUTHORIZATION |
| E_STORAGE_OBJECT_NOT_FOUND | 404 | E_STORAGE_OBJECT_NOT_FOUND |
License
Proprietary — see LICENSE.md.
