Skip to content

Static Asset -- API Reference

Controller factory, storage interface, type definitions, and component internals.

Controller Factory

The AssetControllerFactory.defineAssetController() method dynamically creates controller classes at runtime. For each storage backend in the options:

  1. A new class extending BaseRestController is created with @controller({ path: basePath })
  2. The class name is set dynamically via Object.defineProperty(_controller, 'name', { value: name })
  3. Routes are bound in the controller's binding() method using this.bindRoute().to()
  4. Route configs are spread-merged with per-route overrides from controller.routes (e.g., { ...StaticAssetDefinitions.UPLOAD, ...routes?.upload })
  5. The controller is registered via this.application.controller()
StaticAssetComponent.binding()
    | iterates options
AssetControllerFactory.defineAssetController({ controller, storage, helper, ... })
    | creates
@controller({ path: basePath })
class _controller extends BaseRestController { ... }
    | registered via
this.application.controller(_controller)

IAssetControllerOptions

The factory method accepts the following options:

typescript
interface IAssetControllerOptions {
  controller: TStaticAssetsComponentOptions[string]['controller'];
  storage: TStaticAssetStorageType;
  helper: IStorageHelper;
  useMetaLink?: boolean;
  metaLink?: TMetaLinkConfig;
  options?: TStaticAssetExtraOptions;
}

StaticAssetStorageTypes

A constants class following the Ignis pattern with static readonly fields, a SCHEME_SET, and an isValid() method:

typescript
class StaticAssetStorageTypes {
  static readonly DISK = 'disk';
  static readonly MINIO = 'minio';
  static readonly BUN_S3 = 'bun-s3';

  static readonly SCHEME_SET = new Set([this.DISK, this.MINIO, this.BUN_S3]);

  static isValid(orgType: string): boolean {
    return this.SCHEME_SET.has(orgType);
  }
}

type TStaticAssetStorageType = TConstValue<typeof StaticAssetStorageTypes>;
// Resolves to: 'disk' | 'minio' | 'bun-s3'

MultipartBodySchema

The Zod schema used to validate the upload request body:

typescript
const MultipartBodySchema = z.object({
  files: z.union([z.instanceof(File), z.array(z.instanceof(File))]).openapi({
    type: 'array',
    items: {
      type: 'string',
      format: 'binary',
    },
  }),
});

This accepts either a single File or an array of File objects. The OpenAPI spec representation uses type: 'array' with format: 'binary' items for compatibility with Swagger/OpenAPI tooling.

Header Sanitization

When streaming files (both inline and download), the controller forwards a specific set of whitelisted headers from the storage metadata to the response. All other metadata headers are dropped.

WHITELIST_HEADERS

The exact list of forwarded headers:

typescript
const WHITELIST_HEADERS = [
  'content-type',
  'content-encoding',
  'cache-control',
  'etag',
  'last-modified',
] as const;

These correspond to HTTP.Headers.CONTENT_TYPE, HTTP.Headers.CONTENT_ENCODING, HTTP.Headers.CACHE_CONTROL, HTTP.Headers.ETAG, and HTTP.Headers.LAST_MODIFIED from @venizia/ignis-helpers.

All header values are sanitized by stripping \r and \n characters via String(value).replace(/[\r\n]/g, '') to prevent HTTP header injection attacks. If no content-type header is present in the storage metadata, the controller falls back to application/octet-stream.

HTTP Security Headers

All file streaming responses include:

http
X-Content-Type-Options: nosniff
Content-Type: <from metadata or application/octet-stream>
Content-Length: <file size in bytes>
Content-Disposition: attachment; filename="..."  (download endpoint only)

Whitelisted metadata headers forwarded from storage: content-type, content-encoding, cache-control, etag, last-modified. All other metadata headers are dropped. Header values are sanitized (see Header Sanitization).

IStorageHelper Interface

All storage helpers implement this unified interface:

typescript
interface IStorageHelper {
  isValidName(name: string): boolean;

  // Bucket operations
  isBucketExists(opts: { name: string }): Promise<boolean>;
  getBuckets(): Promise<IBucketInfo[]>;
  getBucket(opts: { name: string }): Promise<IBucketInfo | null>;
  createBucket(opts: { name: string }): Promise<IBucketInfo | null>;
  removeBucket(opts: { name: string }): Promise<boolean>;

  // File operations
  upload(opts: {
    bucket: string;
    files: IUploadFile[];
    normalizeNameFn?: (opts: { originalName: string }) => string;
    normalizeLinkFn?: (opts: { bucketName: string; normalizeName: string }) => string;
  }): Promise<IUploadResult[]>;

  getFile(opts: { bucket: string; name: string; options?: any }): Promise<Readable>;
  getStat(opts: { bucket: string; name: string }): Promise<IFileStat>;
  removeObject(opts: { bucket: string; name: string }): Promise<void>;
  removeObjects(opts: { bucket: string; names: string[] }): Promise<void>;
  listObjects(opts: IListObjectsOptions): Promise<IObjectInfo[]>;

  // Utility
  getFileType(opts: { mimeType: string }): string;
}

Supporting Types

typescript
interface IUploadFile {
  originalName: string;
  mimetype: string;
  buffer: Buffer;
  size: number;
  encoding?: string;
  [key: string | symbol]: any;
}

interface IUploadResult {
  bucketName: string;
  objectName: string;
  link: string;
  metaLink?: any;
  metaLinkError?: any;
}

interface IFileStat {
  size: number;
  metadata: Record<string, any>;
  lastModified?: Date;
  etag?: string;
  versionId?: string;
}

interface IBucketInfo {
  name: string;
  creationDate: Date;
}

interface IObjectInfo {
  name?: string;
  size?: number;
  lastModified?: Date;
  etag?: string;
  prefix?: string;
}

interface IListObjectsOptions {
  bucket: string;
  prefix?: string;
  useRecursive?: boolean;
  maxKeys?: number;
}

Storage Helper Hierarchy

IStorageHelper (interface)
    |
BaseStorageHelper (abstract class)
    |
    +-- DiskHelper (local filesystem)
    +-- MinioHelper (S3-compatible)
    +-- BunS3Helper (Bun-native S3)

Table: MetaLink

FieldTypeNullableDefaultDescription
idTEXTNo--Primary key (UUID)
created_atTIMESTAMPNoNOW()When record was created
modified_atTIMESTAMPNoNOW()When record was last updated
bucket_nameTEXTNo--Storage bucket name
object_nameTEXTNo--File object name
linkTEXTNo--Access URL to the file
mimetypeTEXTNo--File MIME type
sizeINTEGERNo--File size in bytes
etagTEXTYes--Entity tag for versioning
metadataJSONBYes--Additional file metadata
storage_typeTEXTNo--Storage type ('disk', 'minio', or 'bun-s3')
is_syncedBOOLEANNofalseWhether MetaLink is synchronized with storage
variantTEXTYes--Upload variant tag (e.g., 'thumbnail', 'original')
principal_typeTEXTYes--Type of the associated principal (e.g., 'user', 'service')
principal_idTEXTYes--ID of the associated principal (always stored as string)

Indexes: bucket_name, object_name, storage_type, is_synced.

NOTE

The isSynced field is automatically set to true when files are uploaded or synced via the meta-links endpoint. When a file is deleted, the MetaLink record is removed entirely. The principalType, principalId, and variant fields are only populated during upload when the corresponding query parameters are provided.

When useMetaLink: true, the component:

  • On upload: Creates a MetaLink database record for each uploaded file after fetching file stats from storage. If a createMetaLink callback is provided on TMetaLinkConfig, it is used instead of the default creation logic. Stores principalType, principalId, and variant from query parameters (if provided). The principalId is always coerced to a string via String(). If MetaLink creation fails, the upload still succeeds and the response includes metaLink: null with a metaLinkError message.
  • On delete: Initiates MetaLink record deletion as fire-and-forget (the .then()/.catch() promise chain is not awaited). The HTTP response with { "success": true } returns before the database deletion completes. Deletion errors are logged but do not fail the request.
  • On sync (PUT meta-links): Checks if a MetaLink exists for the object (matched by bucketName + objectName). Updates it if found, creates a new one if not. Always sets isSynced: true. Returns { success: true, metaLink: ... }.

Component Lifecycle

  1. binding() -- Reads STATIC_ASSET_COMPONENT_OPTIONS from the DI container
  2. Iterates each storage key -- For each entry, calls AssetControllerFactory.defineAssetController()
  3. Generates default normalizeLinkFn -- If not provided, creates links in the format {basePath}/buckets/{bucket}/objects/{encodedName}
  4. Registers controller -- Calls this.application.controller() with the dynamically created class
  5. Logs binding -- Logs the storage key, type, and MetaLink status for each registered backend

TIP

When MetaLink deletion fails on object delete, the error is logged but the HTTP response still returns { "success": true }. Check your application logs if MetaLink records are not being cleaned up. Since the deletion is fire-and-forget, the response may return before the deletion attempt even starts.

See Also