Skip to content

Static Asset

Flexible file management system with support for multiple storage backends (local disk, MinIO/S3-compatible, Bun S3) through a unified interface, featuring factory-based controller generation and optional database file tracking via MetaLink.

Quick Reference

ItemValue
Package@venizia/ignis
ClassStaticAssetComponent
HelperDiskHelper, MinioHelper, BunS3Helper
RuntimesBoth

Import Paths

IMPORTANT

StaticAssetComponent and its related exports are not exported from the @venizia/ignis barrel. You must import from the @venizia/ignis/static-asset subpath.

typescript
// From core -- subpath import (NOT from '@venizia/ignis')
import {
  StaticAssetComponent,
  StaticAssetComponentBindingKeys,
  StaticAssetStorageTypes,
  AssetControllerFactory,
  BaseMetaLinkModel,
  BaseMetaLinkRepository,
} from '@venizia/ignis/static-asset';

import { DiskHelper } from '@venizia/ignis-helpers';
import { MinioHelper } from '@venizia/ignis-helpers/minio';
import { BunS3Helper } from '@venizia/ignis-helpers/bun-s3';

import type {
  TStaticAssetsComponentOptions,
  TMetaLinkConfig,
  TStaticAssetExtraOptions,
  TStaticAssetStorageType,
} from '@venizia/ignis/static-asset';

Key Features

FeatureDescription
Unified Storage InterfaceSingle API for all storage types
Multiple Storage InstancesConfigure multiple storage backends simultaneously
Factory PatternDynamic controller generation per storage backend
Built-in SecurityComprehensive name validation, path traversal protection, header sanitization
Database Tracking (MetaLink)Optional database-backed file tracking with metadata, principal association, variant support, and sync status
Per-Route ConfigurationOverride authentication, middleware, and path for individual routes
Flexible ConfigurationEnvironment-based, production-ready setup

Setup

Step 1: Bind Configuration

typescript
import { BaseApplication } from '@venizia/ignis';
import {
  StaticAssetComponentBindingKeys,
  StaticAssetStorageTypes,
} from '@venizia/ignis/static-asset';
import { DiskHelper } from '@venizia/ignis-helpers';
import { MinioHelper } from '@venizia/ignis-helpers/minio';
import type { TStaticAssetsComponentOptions } from '@venizia/ignis/static-asset';

export class Application extends BaseApplication {
  preConfigure() {
    this.bind<TStaticAssetsComponentOptions>({
      key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS,
    }).toValue({
      // MinIO storage for user uploads
      staticAsset: {
        controller: {
          name: 'AssetController',
          basePath: '/assets',
          isStrict: true,
        },
        storage: StaticAssetStorageTypes.MINIO,
        helper: new MinioHelper({
          endPoint: 'localhost',
          port: 9000,
          accessKey: 'minioadmin',
          secretKey: 'minioadmin',
          useSSL: false,
        }),
        extra: {
          parseMultipartBody: { storage: 'memory' },
        },
      },
      // Local disk storage for temporary files
      staticResource: {
        controller: {
          name: 'ResourceController',
          basePath: '/resources',
          isStrict: true,
        },
        storage: StaticAssetStorageTypes.DISK,
        helper: new DiskHelper({
          basePath: './app_data/resources',
        }),
        extra: {
          parseMultipartBody: { storage: 'memory' },
        },
      },
    });
  }
}

Each storage backend gets a unique key (staticAsset, staticResource), its own controller configuration, and a helper instance.

Step 2: Register Component

typescript
import { StaticAssetComponent } from '@venizia/ignis/static-asset';

export class Application extends BaseApplication {
  preConfigure() {
    // ... Step 1 binding ...
    this.component(StaticAssetComponent);
  }
}

Step 3: Use the Endpoints

The component auto-registers REST endpoints for each configured backend. No injection needed in downstream code.

GET    /assets/buckets                                — List all buckets
GET    /assets/buckets/:bucketName                    — Get bucket details (or null)
POST   /assets/buckets/:bucketName                    — Create a bucket
DELETE /assets/buckets/:bucketName                    — Delete a bucket
POST   /assets/buckets/:bucketName/upload             — Upload files
GET    /assets/buckets/:bucketName/objects             — List objects in bucket
GET    /assets/buckets/:bucketName/objects/:obj        — Stream file inline
GET    /assets/buckets/:bucketName/objects/:obj/download — Download file (attachment)
DELETE /assets/buckets/:bucketName/objects/:obj        — Delete file
PUT    /assets/buckets/:bucketName/objects/:obj/meta-links — Sync MetaLink (MetaLink only)

Each storage backend gets its own base path (/assets, /resources, etc.) with the same endpoint structure.

Environment Variables

Add these to your .env file for MinIO:

bash
APP_ENV_MINIO_HOST=localhost
APP_ENV_MINIO_API_PORT=9000
APP_ENV_MINIO_ACCESS_KEY=minioadmin
APP_ENV_MINIO_SECRET_KEY=minioadmin

Environment Keys Configuration

typescript
// src/common/environments.ts
import { EnvironmentKeys as BaseEnv } from '@venizia/ignis';

export class EnvironmentKeys extends BaseEnv {
  static readonly APP_ENV_MINIO_HOST = 'APP_ENV_MINIO_HOST';
  static readonly APP_ENV_MINIO_API_PORT = 'APP_ENV_MINIO_API_PORT';
  static readonly APP_ENV_MINIO_ACCESS_KEY = 'APP_ENV_MINIO_ACCESS_KEY';
  static readonly APP_ENV_MINIO_SECRET_KEY = 'APP_ENV_MINIO_SECRET_KEY';
}

Docker Compose for MinIO

yaml
version: '3.8'
services:
  minio:
    image: minio/minio:latest
    container_name: minio
    ports:
      - "9000:9000"   # API port
      - "9001:9001"   # Console port
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - minio_data:/data

volumes:
  minio_data:

Start with docker-compose up -d and access the console at http://localhost:9001.

Configuration

Storage Types

TypeConstantHelperDescription
'disk'StaticAssetStorageTypes.DISKDiskHelperLocal filesystem with bucket-based directory structure
'minio'StaticAssetStorageTypes.MINIOMinioHelperS3-compatible object storage (MinIO, AWS S3, etc.)
'bun-s3'StaticAssetStorageTypes.BUN_S3BunS3HelperBun-native S3 client (requires Bun runtime)

The StaticAssetStorageTypes class provides a SCHEME_SET (a Set of all valid storage type strings) and an isValid(orgType) method for runtime validation:

typescript
StaticAssetStorageTypes.isValid('minio');  // true
StaticAssetStorageTypes.isValid('bun-s3'); // true
StaticAssetStorageTypes.isValid('s3');     // false
StaticAssetStorageTypes.SCHEME_SET;        // Set { 'disk', 'minio', 'bun-s3' }

TStaticAssetsComponentOptions

Each key in the options object defines a separate storage backend with its own controller:

OptionTypeDefaultDescription
controller.namestring--Controller class name
controller.basePathstring--Base URL path (e.g., '/assets')
controller.isStrictbooleantrueStrict routing mode
controller.routesobjectundefinedPer-route overrides (authenticate, middleware, path)
storage'disk' | 'minio' | 'bun-s3'--Storage type
helperDiskHelper | MinioHelper | BunS3Helper--Storage helper instance
extraTStaticAssetExtraOptionsundefinedExtra options (multipart parsing, name normalization)
useMetaLinkbooleanfalseEnable database file tracking
metaLinkTMetaLinkConfig--MetaLink configuration (required when useMetaLink: true)

Per-Route Configuration

Each route can be individually configured with authentication, middleware, and path overrides:

typescript
{
  controller: {
    name: 'AssetController',
    basePath: '/assets',
    routes: {
      getBuckets: { authenticate: { strategies: ['jwt'], mode: 'required' } },
      upload: { authenticate: { strategies: ['jwt'], mode: 'required' }, middleware: [rateLimitMw] },
      getObjectByName: { /* public -- no authenticate */ },
      downloadObjectByName: { /* public */ },
      deleteObject: { authenticate: { strategies: ['jwt'], mode: 'required' } },
      deleteBucket: { authenticate: { strategies: ['jwt'], mode: 'required' } },
    },
  },
  // ...
}

Available route keys: getBuckets, getBucketByName, createBucket, deleteBucket, upload, listObjects, deleteObject, getObjectByName, downloadObjectByName, recreateMetaLink.

TStaticAssetsComponentOptions -- Full Reference

typescript
type TStaticAssetsComponentOptions = {
  [key: string]: {
    controller: {
      name: string;
      basePath: string;
      isStrict?: boolean;
      routes?: {
        getBuckets?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        getBucketByName?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        createBucket?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        deleteBucket?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        upload?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        listObjects?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        deleteObject?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        getObjectByName?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        downloadObjectByName?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
        recreateMetaLink?: Partial<Omit<IAuthRouteConfig, 'method' | 'request' | 'responses'>>;
      };
    };
    extra?: TStaticAssetExtraOptions;
  } & (
    | { storage: typeof StaticAssetStorageTypes.BUN_S3; helper: BunS3Helper }
    | { storage: typeof StaticAssetStorageTypes.DISK; helper: DiskHelper }
    | { storage: typeof StaticAssetStorageTypes.MINIO; helper: MinioHelper }
  ) &
    ({ useMetaLink?: false | undefined } | { useMetaLink: true; metaLink: TMetaLinkConfig });
};

type TStaticAssetExtraOptions = {
  parseMultipartBody?: {
    storage?: 'memory' | 'disk';
    uploadDir?: string;
  };
  normalizeNameFn?: (opts: { originalName: string }) => string;
  normalizeLinkFn?: (opts: { bucketName: string; normalizeName: string }) => string;
  [key: string]: any;
};

type TMetaLinkConfig<Schema extends TMetaLinkSchema = TMetaLinkSchema> = {
  model: typeof BaseEntity<Schema>;
  repository: DefaultCRUDRepository<Schema>;
  createMetaLink?: (opts: {
    uploadResult: IUploadResult;
    fileStat: IFileStat;
    query: TUploadQuery;
  }) => ValueOrPromise<{ count: number; data: Schema }>;
};

NOTE

The normalizeNameFn receives only { originalName } -- there is no folderPath parameter.

TIP

The createMetaLink callback on TMetaLinkConfig is optional. When provided, it replaces the default MetaLink creation logic during upload, giving you full control over how file metadata is stored.

DiskHelper

Stores files on the local filesystem using a bucket-based directory structure.

typescript
new DiskHelper({
  basePath: string;    // Base directory for storage
  scope?: string;      // Logger scope
  identifier?: string; // Helper identifier
})

Example:

typescript
const diskHelper = new DiskHelper({
  basePath: './app_data/storage',
});

Directory structure:

app_data/storage/
├── bucket-1/
│   ├── file1.pdf
│   └── file2.jpg
├── bucket-2/
│   └── document.docx

Features: automatic directory creation, built-in path validation, metadata from file stats, stream-based operations.

MinioHelper

Connects to MinIO or any S3-compatible object storage.

typescript
new MinioHelper({
  endPoint: string;    // MinIO server hostname
  port: number;        // API port (default: 9000)
  useSSL: boolean;     // Use HTTPS
  accessKey: string;   // Access key
  secretKey: string;   // Secret key
})

Example:

typescript
const minioHelper = new MinioHelper({
  endPoint: 'minio.example.com',
  port: 9000,
  useSSL: true,
  accessKey: process.env.MINIO_ACCESS_KEY,
  secretKey: process.env.MINIO_SECRET_KEY,
});

BunS3Helper

Bun-native S3 client for direct S3/S3-compatible access using Bun's built-in S3 support. Requires Bun runtime.

typescript
import { BunS3Helper } from '@venizia/ignis-helpers/bun-s3';

MetaLink is an optional feature that tracks uploaded files in a database, storing file location, metadata (mimetype, size, etag), storage type, principal association (principalType, principalId), variant, timestamps, and custom metadata (JSONB).

Benefits

  • Query uploaded files by bucket, name, mimetype, variant, etc.
  • Track file history and audit trails
  • Store custom metadata about files
  • Associate files with principals via principalType and principalId (passed as query parameters on the upload endpoint)
  • Tag uploads with a variant query parameter (e.g., "thumbnail", "original")
  • Custom createMetaLink callback for full control over MetaLink creation
  • Graceful errors -- upload succeeds even if MetaLink creation fails

Setup

1. Create Model:

typescript
import { BaseMetaLinkModel, model } from '@venizia/ignis/static-asset';

@model({ type: 'entity' })
export class FileMetaLinkModel extends BaseMetaLinkModel {
  // Inherits all fields from BaseMetaLinkModel
}

2. Create Repository:

typescript
import { BaseMetaLinkRepository } from '@venizia/ignis/static-asset';
import { repository, inject } from '@venizia/ignis';
import type { IDataSource } from '@venizia/ignis';

@repository({})
export class FileMetaLinkRepository extends BaseMetaLinkRepository {
  constructor(@inject({ key: 'datasources.postgres' }) dataSource: IDataSource) {
    super({
      entityClass: FileMetaLinkModel,
      relations: {},
      dataSource,
    });
  }
}

3. Create Database Table:

The model has skipMigrate: true, so create the table manually:

sql
CREATE TABLE "MetaLink" (
  id              TEXT PRIMARY KEY,
  created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
  modified_at     TIMESTAMP NOT NULL DEFAULT NOW(),
  bucket_name     TEXT NOT NULL,
  object_name     TEXT NOT NULL,
  link            TEXT NOT NULL,
  mimetype        TEXT NOT NULL,
  size            INTEGER NOT NULL,
  etag            TEXT,
  metadata        JSONB,
  storage_type    TEXT NOT NULL,
  is_synced       BOOLEAN NOT NULL DEFAULT false,
  variant         TEXT,
  principal_type  TEXT,
  principal_id    TEXT
);

CREATE INDEX "IDX_MetaLink_bucketName" ON "MetaLink"(bucket_name);
CREATE INDEX "IDX_MetaLink_objectName" ON "MetaLink"(object_name);
CREATE INDEX "IDX_MetaLink_storageType" ON "MetaLink"(storage_type);
CREATE INDEX "IDX_MetaLink_isSynced" ON "MetaLink"(is_synced);

4. Configure Component:

typescript
import { FileMetaLinkModel, FileMetaLinkRepository } from './your-models';
import {
  StaticAssetComponent,
  StaticAssetComponentBindingKeys,
  StaticAssetStorageTypes,
} from '@venizia/ignis/static-asset';
import type { TStaticAssetsComponentOptions } from '@venizia/ignis/static-asset';

export class Application extends BaseApplication {
  configureComponents(): void {
    this.repository(FileMetaLinkRepository);

    this.bind<TStaticAssetsComponentOptions>({
      key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS,
    }).toValue({
      uploads: {
        controller: {
          name: 'UploadController',
          basePath: '/uploads',
          isStrict: true,
        },
        storage: StaticAssetStorageTypes.MINIO,
        helper: new MinioHelper({ /* ... */ }),
        useMetaLink: true,
        metaLink: {
          model: FileMetaLinkModel,
          repository: this.getSync(FileMetaLinkRepository),
        },
        extra: {
          parseMultipartBody: { storage: 'memory' },
        },
      },
    });

    this.component(StaticAssetComponent);
  }
}

5. Upload with Principal Association and Variant:

When MetaLink is enabled, you can associate uploaded files with a principal and variant by passing query parameters on the upload endpoint:

typescript
const formData = new FormData();
formData.append('file', fileBlob, 'document.pdf');

// Associate the upload with a user and tag as 'original' variant
const response = await fetch(
  '/uploads/buckets/user-files/upload?principalType=user&principalId=42&variant=original',
  { method: 'POST', body: formData },
);

The principalId value is always stored as a string regardless of input type (coerced via String()).

You can provide a custom createMetaLink callback to fully control how MetaLink records are created:

typescript
metaLink: {
  model: FileMetaLinkModel,
  repository: this.getSync(FileMetaLinkRepository),
  createMetaLink: async ({ uploadResult, fileStat, query }) => {
    // Custom logic -- e.g., add extra fields, validate, transform
    return metaLinkRepo.create({
      data: {
        bucketName: uploadResult.bucketName,
        objectName: uploadResult.objectName,
        link: uploadResult.link,
        mimetype: fileStat.metadata?.['mimetype'],
        size: fileStat.size,
        etag: fileStat.etag,
        metadata: fileStat.metadata,
        storageType: 'minio',
        isSynced: true,
        principalId: query.principalId ? String(query.principalId) : undefined,
        principalType: query.principalType,
        variant: query.variant,
        // ... additional custom fields
      },
    });
  },
},

When createMetaLink is not provided, the component uses a default implementation that stores all standard fields.

typescript
// Get all files in a bucket
const files = await fileMetaLinkRepository.find({
  where: { bucketName: 'user-uploads' },
});

// Get files by mimetype
const pdfs = await fileMetaLinkRepository.find({
  where: { mimetype: 'application/pdf' },
});

// Get files by storage type
const minioFiles = await fileMetaLinkRepository.find({
  where: { storageType: 'minio' },
});

// Get files by principal
const userFiles = await fileMetaLinkRepository.find({
  where: { principalType: 'user', principalId: '42' },
});

// Get files by variant
const thumbnails = await fileMetaLinkRepository.find({
  where: { variant: 'thumbnail' },
});

// Get synced files only
const syncedFiles = await fileMetaLinkRepository.find({
  where: { isSynced: true },
});

Quick Start Options

Option 1: MinIO Only

typescript
import {
  StaticAssetComponent,
  StaticAssetComponentBindingKeys,
  StaticAssetStorageTypes,
} from '@venizia/ignis/static-asset';

this.bind({
  key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS,
}).toValue({
  cloudStorage: {
    controller: { name: 'CloudController', basePath: '/cloud' },
    storage: StaticAssetStorageTypes.MINIO,
    helper: new MinioHelper({ /* ... */ }),
    extra: { parseMultipartBody: { storage: 'memory' } },
  },
});
this.component(StaticAssetComponent);

Option 2: Local Disk Only

typescript
this.bind({
  key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS,
}).toValue({
  localStorage: {
    controller: { name: 'LocalController', basePath: '/files' },
    storage: StaticAssetStorageTypes.DISK,
    helper: new DiskHelper({ basePath: './uploads' }),
    extra: { parseMultipartBody: { storage: 'disk' } },
  },
});
this.component(StaticAssetComponent);

Option 3: Multiple Storage Backends (Recommended)

typescript
this.bind({
  key: StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS,
}).toValue({
  userUploads: {
    controller: { name: 'UploadsController', basePath: '/uploads' },
    storage: StaticAssetStorageTypes.MINIO,
    helper: new MinioHelper({ /* ... */ }),
    extra: {},
  },
  tempFiles: {
    controller: { name: 'TempController', basePath: '/temp' },
    storage: StaticAssetStorageTypes.DISK,
    helper: new DiskHelper({ basePath: './temp' }),
    extra: {},
  },
  publicAssets: {
    controller: { name: 'PublicController', basePath: '/public' },
    storage: StaticAssetStorageTypes.DISK,
    helper: new DiskHelper({ basePath: './public' }),
    extra: {},
  },
});
this.component(StaticAssetComponent);

Custom Filename Normalization

typescript
{
  uploads: {
    controller: { name: 'UploadController', basePath: '/uploads' },
    storage: StaticAssetStorageTypes.MINIO,
    helper: new MinioHelper({ /* ... */ }),
    extra: {
      parseMultipartBody: { storage: 'memory' },
      normalizeNameFn: ({ originalName }) => {
        return `${Date.now()}_${originalName.toLowerCase().replace(/\s/g, '_')}`;
      },
      normalizeLinkFn: ({ bucketName, normalizeName }) => {
        return `/api/files/${bucketName}/${encodeURIComponent(normalizeName)}`;
      },
    },
  },
}

The normalizeNameFn receives only the originalName of the uploaded file.

Binding Keys

KeyConstantTypeRequiredDefault
@app/static-asset-component/optionsStaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONSTStaticAssetsComponentOptionsYes{}

NOTE

The component provides an empty default binding. You must bind this key with your storage configuration before registering the component.

See Also