npm package: @os-team/image-storage
Description: Library for uploading images to a storage.
Latest version: 1.2.1

# @os-team/image-storage [![NPM version](https://img.shields.io/npm/v/@os-team/image-storage)](https://yarnpkg.com/package/@os-team/image-storage) [![BundlePhobia](https://img.shields.io/bundlephobia/minzip/@os-team/image-storage)](https://bundlephobia.com/result?p=@os-team/image-storage)

Library for uploading images to a storage.

Usually when you want to store an image you need:

1. Validate the file type (e.g. allow only JPG, PNG, WEBP image formats). The file type should be detected by the first bytes, not by the file name (e.g. using the [file-type](https://github.com/sindresorhus/file-type) library).
2. Validate the file size.
3. Convert the image to a specific extension (e.g. to JPG).
4. Upload an image in multiple sizes to use them on the frontend side in different places (e.g. a large avatar on the profile page, a small avatar in the header).
5. Crop some sizes of the image to use them, for example, in a list of blog posts.
6. Append a hash to the file name to avoid caching when updating the image.
7. Delete old images that have been replaced with new ones.

This library performs all these steps using an abstract storage, which must first be implemented (see below).

The following image storage implementations are currently available:

- [@os-team/google-image-storage](https://gitlab.com/os-team/libs/utils/-/tree/main/packages/image-storage-google)
- [@os-team/yandex-image-storage](https://gitlab.com/os-team/libs/utils/-/tree/main/packages/image-storage-yandex)

## Usage

### Step 1. Install the package

Install the package using the following command:

```
yarn add @os-team/image-storage
```

### Step 2. Create a storage

The storage must implement the following interface:

```ts
export interface Storage {
  /**
   * Returns the list of files in a bucket starting with the specified prefix.
   */
  list(bucket: string, prefix: string): Promise<string[]>;

  /**
   * Uploads the file to a bucket with the specified name.
   */
  upload(options: StorageUploadOptions): Promise<void>;

  /**
   * Deletes a file.
   */
  delete(bucket: string, key: string): Promise<void>;

  /**
   * Deletes multiple files at once.
   */
  deleteMultiple(bucket: string, keys: string[]): Promise<void>;
}
```

For example, let's create the file storage in which images will be stored in the specified directory (bucket):

```ts
import { Storage, StorageUploadOptions } from '@os-team/image-storage';
import * as fs from 'fs';
import * as path from 'path';

class FileStorage implements Storage {
  public list(bucket: string, prefix: string): Promise<string[]> {
    return new Promise((resolve, reject) => {
      fs.readdir(bucket, (error, files) => {
        if (error) reject(error);
        else resolve(files.filter((file) => file.startsWith(prefix)));
      });
    });
  }

  public async upload(options: StorageUploadOptions): Promise<void> {
    const { bucket, key, body } = options;
    return new Promise((resolve, reject) => {
      const filePath = path.resolve(bucket, key);
      const writeStream = fs.createWriteStream(filePath);
      body.pipe(writeStream).on('error', reject).on('close', resolve);
    });
  }

  public delete(bucket: string, key: string): Promise<void> {
    return new Promise((resolve, reject) => {
      const filePath = path.resolve(bucket, key);
      fs.unlink(filePath, (error) => {
        if (error) reject(error);
        else resolve(undefined);
      });
    });
  }

  public deleteMultiple(bucket: string, keys: string[]): Promise<void> {
    if (keys.length === 0) return Promise.resolve();
    let loadedFilesCount = 0;
    return new Promise((resolve, reject) => {
      keys.forEach((key) => {
        const filePath = path.resolve(bucket, key);
        fs.unlink(filePath, (error) => {
          if (error) reject(error);
          else if (loadedFilesCount < keys.length - 1) loadedFilesCount += 1;
          else resolve(undefined);
        });
      });
    });
  }
}
```

### Step 3. Create an image storage

Your image storage must extend the abstract `ImageStorage`:

```ts
import ImageStorage, { ImageStorageOptions } from '@os-team/image-storage';

class FileImageStorage extends ImageStorage {
  protected readonly storage: Storage;

  public constructor(options: ImageStorageOptions) {
    super(options);
    this.storage = new FileStorage();
  }
}
```

### Step 4. Use your image storage

```ts
import ImageStorage from '@os-team/image-storage';

const imageStorage: ImageStorage = new FileImageStorage();

// Upload an image
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'name',
});

// Delete the image
await imageStorage.delete(name);
```

By default, the `ImageStorage` uploads the following images:

- fileName-72
- fileName-192
- fileName-512
- fileName-1024
- fileName-2560
- fileName-72-c
- fileName-192-c
- fileName-512-c
- fileName-1024-c
- fileName-2560-c

## Customizations

### Using a hash

To avoid caching, it is recommended to append a hash to the image name.

```ts
// name = 'name~hash'
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'name',
  useHash: true,
});
```

The length of the hash is 4 characters.

### Deleting old images

If you use a hash and upload an image with the same name for the second time, then the files will not be replaced. If an image stores in 10 different sizes (by default), then after the 10th upload of an image with the same name, there will be 100 files in a storage. To avoid this, it is necessary to delete the old files after the new image has been uploaded (not before, because the upload may fail).

You can delete old images as follows:

```ts
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'name',
  useHash: true,
  deleteOldImages: true,
});
```

### Uploading to a directory

By default, an image is uploaded in the root directory of a bucket.
You can specify a directory inside the `name`, but in this case the `upload` method returns the name with the directory.

```ts
// name = 'dir1/dir2/name'
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'dir1/dir2/name',
});
```

Usually, the image name is later stored in the database. In this case, the path is redundant.
If you specify a directory in the `path` parameter, the `upload` method will return only the image name.

```ts
// name = 'name'
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'name',
  path: 'dir1/dir2',
});
```

### Changing image sizes

By default, an image is uploaded in the following sizes: `72`, `192`, `512`, `1024`, `2560`.
These sizes are used in most cases in any app (e.g. the Slack app uses similar image sizes).

You can specify your own sizes using `sizes` and `croppedSizes` options.
The second one is used to determine in which sizes the cropped version of an image must be uploaded in a storage.

```ts
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'fileName',
  sizes: [50, 100],
  croppedSizes: [200],
});
```

If the `croppedSizes` option is not specified, it will be the same as `sizes`.

### Setting the max size of an image

By default, the max size of an image is `20 MB`. You can change it as follows:

```ts
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'fileName',
  maxSize: 50 * 1024 * 1024, // In bytes
});
```

### Setting allowed types of an image

By default, you can upload an image in any of the following extensions: `jpg`, `png`, `webp`, `gif`, `avif`, `tif`.

You can restrict allowed extensions as follows:

```ts
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'fileName',
  types: ['jpg', 'png'], // Allow only JPG and PNG images to upload
});
```

### Changing a suffix for cropped images

By default, the `-c` suffix is used for cropped images. You can change this suffix using the `cropSuffix` option:

```ts
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'fileName',
  cropSuffix: '-cropped',
});
```

Now the image name will be `fileName-72-cropped` instead of `fileName-72-c`.

### Setting concurrent files that uploads to a storage

By default, the max number of files that are uploaded in parallel is `4`, but you can change it as follows:

```ts
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'fileName',
  concurrentFiles: 10,
});
```

If you set `sizes: [72, 192, 512, 1024, 2560]`, 10 files will be uploaded to a storage for each image (5 default + 5 cropped).
The concurrency allows you to upload all image sizes faster, but the more simultaneous files are uploaded to a storage, the more memory is used.

### Making base transformations

By default, an image is uploaded to a storage in progressive JPG with a quality of 90.
If an image has a transparent background, it is set to white.

You can override these transformations as follows:

```ts
const { name } = await imageStorage.upload({
  body: createReadStream('my-image.jpg'),
  name: 'fileName',
  transformer: (sharp) =>
    sharp.flatten({ background: { r: 255, g: 255, b: 255 } }).jpeg({
      quality: 60, // Change the quality from 90 to 60
      progressive: true,
    }),
});
```

### Deleting the specified image sizes

If you want to delete only the specified sizes of the image, pass them in the second parameter:

```ts
await imageStorage.delete(name, [50]);
```