teableio/teable

View on GitHub
apps/nestjs-backend/src/features/attachments/attachments.service.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable @typescript-eslint/naming-convention */
import fs from 'fs';
import type { IncomingHttpHeaders } from 'http';
import { tmpdir } from 'os';
import { join } from 'path';
import { Readable } from 'stream';
import { BadRequestException, HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import type { IAttachmentItem } from '@teable/core';
import { generateAttachmentId } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import {
  axios,
  UploadType,
  type INotifyVo,
  type SignatureRo,
  type SignatureVo,
} from '@teable/openapi';
import type { Request, Response } from 'express';
import mimeTypes from 'mime-types';
import { nanoid } from 'nanoid';
import { ClsService } from 'nestjs-cls';
import { CacheService } from '../../cache/cache.service';
import { StorageConfig, IStorageConfig } from '../../configs/storage';
import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';
import type { IClsStore } from '../../types/cls';
import { FileUtils } from '../../utils';
import { second } from '../../utils/second';
import { AttachmentsStorageService } from './attachments-storage.service';
import StorageAdapter from './plugins/adapter';
import type { LocalStorage } from './plugins/local';
import { InjectStorageAdapter } from './plugins/storage';

@Injectable()
export class AttachmentsService {
  private logger = new Logger(AttachmentsService.name);

  constructor(
    private readonly prismaService: PrismaService,
    private readonly cls: ClsService<IClsStore>,
    private readonly cacheService: CacheService,
    private readonly attachmentsStorageService: AttachmentsStorageService,
    @StorageConfig() readonly storageConfig: IStorageConfig,
    @ThresholdConfig() readonly thresholdConfig: IThresholdConfig,
    @InjectStorageAdapter() readonly storageAdapter: StorageAdapter
  ) {}
  /**
   * Local upload
   */
  async upload(req: Request, token: string) {
    const tokenCache = await this.cacheService.get(`attachment:signature:${token}`);
    const localStorage = this.storageAdapter as LocalStorage;
    if (!tokenCache) {
      throw new BadRequestException(`Invalid token: ${token}`);
    }
    const { path, bucket } = tokenCache;
    const file = await localStorage.saveTemporaryFile(req);
    await localStorage.validateToken(token, file);
    const hash = await FileUtils.getHash(file.path);
    await localStorage.save(file.path, join(bucket, path));

    await this.cacheService.set(
      `attachment:upload:${token}`,
      { mimetype: file.mimetype, hash, size: file.size },
      second(this.storageConfig.tokenExpireIn)
    );
  }

  async readLocalFile(path: string, token?: string) {
    const localStorage = this.storageAdapter as LocalStorage;
    let respHeaders: Record<string, string> = {};

    if (!path) {
      throw new HttpException(`Could not find attachment: ${token}`, HttpStatus.NOT_FOUND);
    }
    const { bucket, token: tokenInPath } = localStorage.parsePath(path);
    if (token && !StorageAdapter.isPublicBucket(bucket)) {
      respHeaders = localStorage.verifyReadToken(token).respHeaders ?? {};
    } else {
      const attachment = await this.prismaService
        .txClient()
        .attachments.findUnique({ where: { token: tokenInPath, deletedTime: null } });
      if (!attachment) {
        throw new BadRequestException(`Invalid path: ${path}`);
      }
      respHeaders['Content-Type'] = attachment.mimetype;
    }

    const headers: Record<string, string> = respHeaders ?? {};
    const fileStream = localStorage.read(path);

    return { headers, fileStream };
  }

  localFileConditionalCaching(path: string, reqHeaders: IncomingHttpHeaders, res: Response) {
    const ifModifiedSince = reqHeaders['if-modified-since'];
    const localStorage = this.storageAdapter as LocalStorage;
    const lastModifiedTimestamp = localStorage.getLastModifiedTime(path);
    if (!lastModifiedTimestamp) {
      throw new BadRequestException(`Could not find attachment: ${path}`);
    }
    // Comparison of accuracy in seconds
    if (
      !ifModifiedSince ||
      Math.floor(new Date(ifModifiedSince).getTime() / 1000) <
        Math.floor(lastModifiedTimestamp / 1000)
    ) {
      res.set('Last-Modified', new Date(lastModifiedTimestamp).toUTCString());
      return false;
    }
    return true;
  }

  async signature(signatureRo: SignatureRo & { internal?: boolean }): Promise<SignatureVo> {
    const { type, ...presignedParams } = signatureRo;
    const contentLength = signatureRo.contentLength;
    const MAX_FILE_SIZE = this.thresholdConfig.maxAttachmentUploadSize;
    if (contentLength > MAX_FILE_SIZE) {
      throw new BadRequestException(
        `File size exceeds the maximum limit of ${(MAX_FILE_SIZE / (1024 * 1024)).toFixed(2)} MB`
      );
    }
    const hash = presignedParams.hash;
    const dir = StorageAdapter.getDir(type);
    const bucket = StorageAdapter.getBucket(type);
    const res = await this.storageAdapter.presigned(bucket, dir, {
      ...presignedParams,
    });
    const { path, token } = res;
    await this.cacheService.set(
      `attachment:signature:${token}`,
      { path, bucket, hash },
      signatureRo.expiresIn ?? second(this.storageConfig.tokenExpireIn)
    );
    return res;
  }

  async notify(token: string, filename?: string): Promise<INotifyVo> {
    const tokenCache = await this.cacheService.get(`attachment:signature:${token}`);
    if (!tokenCache) {
      throw new BadRequestException(`Invalid token: ${token}`);
    }
    const userId = this.cls.get('user.id');
    const { path, bucket } = tokenCache;
    const { hash, size, mimetype, width, height, url } = await this.storageAdapter.getObjectMeta(
      bucket,
      path,
      token
    );
    const attachment = await this.prismaService.txClient().attachments.create({
      data: {
        hash,
        size,
        mimetype,
        token,
        path,
        width,
        height,
        createdBy: userId,
      },
      select: {
        token: true,
        size: true,
        mimetype: true,
        width: true,
        height: true,
        path: true,
      },
    });
    const filenameHeader = filename
      ? {
          // eslint-disable-next-line @typescript-eslint/naming-convention
          'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
        }
      : {};
    return {
      ...attachment,
      width: attachment.width ?? undefined,
      height: attachment.height ?? undefined,
      url,
      presignedUrl: await this.attachmentsStorageService.getPreviewUrlByPath(
        bucket,
        path,
        token,
        undefined,
        // eslint-disable-next-line @typescript-eslint/naming-convention
        { 'Content-Type': mimetype, ...filenameHeader }
      ),
    };
  }

  private async notifyToAttachmentItem(token: string, filename: string): Promise<IAttachmentItem> {
    const notifyVo = await this.notify(token, filename);
    return {
      ...notifyVo,
      id: generateAttachmentId(),
      name: filename,
    };
  }

  async uploadFile(file: Express.Multer.File): Promise<IAttachmentItem> {
    const MAX_FILE_SIZE = this.thresholdConfig.maxOpenapiAttachmentUploadSize;
    if (file.size > MAX_FILE_SIZE) {
      throw new BadRequestException(
        `File size exceeds the maximum limit of ${(MAX_FILE_SIZE / (1024 * 1024)).toFixed(2)} MB`
      );
    }

    const contentType =
      file.mimetype === 'application/octet-stream'
        ? mimeTypes.lookup(file.originalname) || file.mimetype
        : file.mimetype;
    const contentLength = file.size;

    const { token, url } = await this.signature({
      type: UploadType.Table,
      contentLength,
      contentType,
      internal: true,
    });
    const fileStream = Readable.from(file.buffer);

    this.logger.log(
      `Uploading file: ${file.originalname}, size: ${contentLength} bytes, mimetype: ${contentType}`
    );

    await this.uploadStreamToStorage(url, fileStream, contentType, contentLength);

    return await this.notifyToAttachmentItem(token, file.originalname);
  }

  async uploadFromUrl(fileUrl: string): Promise<IAttachmentItem> {
    const MAX_FILE_SIZE = this.thresholdConfig.maxOpenapiAttachmentUploadSize;

    const { contentLength, contentType, tempFilePath } = await this.getFileInfo(
      fileUrl,
      MAX_FILE_SIZE
    );

    if (contentLength > MAX_FILE_SIZE) {
      throw new BadRequestException(
        `File size exceeds the maximum limit of ${(MAX_FILE_SIZE / (1024 * 1024)).toFixed(2)} MB`
      );
    }

    const filename = this.getFilenameFromUrl(fileUrl);
    const { token, url } = await this.signature({
      type: UploadType.Table,
      contentLength,
      contentType,
      internal: true,
    });

    try {
      await this.uploadFileContent(url, tempFilePath, contentType, contentLength, fileUrl);
      return await this.notifyToAttachmentItem(token, filename);
    } catch (error) {
      console.error('uploadFromUrl:upload', error);
      throw new BadRequestException('Url reject');
    } finally {
      if (tempFilePath) {
        fs.unlinkSync(tempFilePath);
      }
    }
  }

  private async getFileInfo(
    fileUrl: string,
    maxFileSize: number
  ): Promise<{ contentLength: number; contentType: string; tempFilePath: string | null }> {
    let contentLength: number | undefined;
    let contentType: string | undefined;
    let tempFilePath: string | null = null;

    try {
      const headResponse = await axios.head(fileUrl);
      contentLength =
        headResponse.headers['content-length'] && parseInt(headResponse.headers['content-length']);
      contentType = headResponse.headers['content-type'];
      this.logger.log(
        `HEAD request successful. Content-Length: ${contentLength}, Content-Type: ${contentType}`
      );
    } catch (error) {
      console.warn('HEAD request failed, falling back to GET:', error);
    }

    if (!contentLength) {
      this.logger.log('Content length not available from HEAD request. Downloading file...');
      const tempFileName = `temp-${nanoid()}`;
      tempFilePath = join(tmpdir(), tempFileName);

      await this.downloadFile(fileUrl, tempFilePath, maxFileSize);
      contentLength = fs.statSync(tempFilePath).size;
      this.logger.log(`File downloaded. Size: ${contentLength} bytes`);

      if (!contentType) {
        contentType = mimeTypes.lookup(fileUrl) || 'application/octet-stream';
      }
    }

    return {
      contentLength,
      contentType: contentType as string,
      tempFilePath,
    };
  }

  private async uploadFileContent(
    url: string,
    tempFilePath: string | null,
    contentType: string,
    contentLength: number,
    fileUrl: string
  ): Promise<void> {
    if (tempFilePath) {
      await this.uploadStreamToStorage(
        url,
        fs.createReadStream(tempFilePath),
        contentType,
        contentLength
      );
      this.logger.log('Upload from temporary file completed');
    } else {
      this.logger.log(`Downloading and uploading from URL: ${fileUrl}`);
      const response = await axios.get(fileUrl, { responseType: 'stream' });
      await this.uploadStreamToStorage(url, response.data, contentType, contentLength);
    }
  }

  private async uploadStreamToStorage(
    url: string,
    stream: Readable,
    contentType: string,
    contentLength: number
  ): Promise<void> {
    await axios.put(url, stream, {
      headers: {
        'Content-Type': contentType,
        'Content-Length': contentLength,
      },
      maxBodyLength: Infinity,
      maxContentLength: Infinity,
    });
  }

  private getFilenameFromUrl(url: string): string {
    const urlParts = new URL(url);
    const pathParts = urlParts.pathname.split('/');
    return pathParts[pathParts.length - 1] || 'downloaded_file';
  }

  private async downloadFile(url: string, filePath: string, maxSize: number): Promise<void> {
    const writer = fs.createWriteStream(filePath);
    let downloadedBytes = 0;

    const response = await axios({
      method: 'get',
      url: url,
      responseType: 'stream',
    });

    return new Promise((resolve, reject) => {
      response.data.on('data', (chunk: Buffer) => {
        downloadedBytes += chunk.length;
        if (downloadedBytes > maxSize) {
          writer.close();
          reject(
            new BadRequestException(
              `File size exceeds the maximum limit of ${maxSize / (1024 * 1024)} MB`
            )
          );
        }
      });

      response.data.pipe(writer);

      writer.on('finish', resolve);
      writer.on('error', reject);
    });
  }
}