core/templates/services/assets-backend-api.service.ts

Summary

Maintainability
C
1 day
Test Coverage
// Copyright 2020 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @fileoverview Service to serve as the interface for fetching and uploading
 * assets from Google Cloud Storage.
 */

import {HttpClient, HttpErrorResponse} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {downgradeInjectable} from '@angular/upgrade/static';

import {AppConstants} from 'app.constants';
import {AudioFile} from 'domain/utilities/audio-file.model';
import {FileDownloadRequest} from 'domain/utilities/file-download-request.model';
import {ImageFile} from 'domain/utilities/image-file.model';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
import {Observable} from 'rxjs';
import {CsrfTokenService} from 'services/csrf-token.service';

interface SaveAudioResponse {
  filename: string;
  duration_secs: number;
}

interface SaveImageResponse {
  filename: string;
}

@Injectable({
  providedIn: 'root',
})
export class AssetsBackendApiService {
  public readonly profileImagePngUrlTemplate: string;
  public readonly profileImageWebpUrlTemplate: string;
  private readonly downloadUrlTemplate: string;

  /** List of audio files that have been requested but have not returned. */
  private audioFileDownloadRequests: FileDownloadRequest[] = [];
  /** List of image files that have been requested but have not returned. */
  private imageFileDownloadRequests: FileDownloadRequest[] = [];
  /** Map from asset filename to asset blob. */
  private assetsCache: Map<string, Blob> = new Map();

  constructor(
    private csrfTokenService: CsrfTokenService,
    private http: HttpClient,
    private urlInterpolationService: UrlInterpolationService
  ) {
    let urlPrefix = '/assetsdevhandler';
    if (!AssetsBackendApiService.EMULATOR_MODE) {
      urlPrefix =
        'https://storage.googleapis.com/' +
        AssetsBackendApiService.GCS_RESOURCE_BUCKET_NAME;
    }
    this.downloadUrlTemplate =
      urlPrefix + '/<entity_type>/<entity_id>/assets/<asset_type>/<filename>';
    this.profileImagePngUrlTemplate =
      urlPrefix + '/user/<username>/assets/profile_picture.png';
    this.profileImageWebpUrlTemplate =
      urlPrefix + '/user/<username>/assets/profile_picture.webp';
  }

  static get EMULATOR_MODE(): boolean {
    return AppConstants.EMULATOR_MODE;
  }

  static get GCS_RESOURCE_BUCKET_NAME(): string {
    return AppConstants.GCS_RESOURCE_BUCKET_NAME;
  }

  async loadAudio(explorationId: string, filename: string): Promise<AudioFile> {
    let data = this.assetsCache.get(filename);
    if (this.isCached(filename) && data !== undefined) {
      return new AudioFile(filename, data);
    }
    return this.fetchFile(
      AppConstants.ENTITY_TYPE.EXPLORATION,
      explorationId,
      filename,
      AppConstants.ASSET_TYPE_AUDIO
    );
  }

  async loadImage(
    entityType: string,
    entityId: string,
    filename: string
  ): Promise<ImageFile> {
    let data = this.assetsCache.get(filename);
    if (this.isCached(filename) && data !== undefined) {
      return new ImageFile(filename, data);
    }
    return this.fetchFile(
      entityType,
      entityId,
      filename,
      AppConstants.ASSET_TYPE_IMAGE
    );
  }

  async saveAudio(
    explorationId: string,
    filename: string,
    rawAssetData: Blob
  ): Promise<SaveAudioResponse> {
    const form = new FormData();
    form.append('raw_audio_file', rawAssetData);
    form.append('payload', JSON.stringify({filename}));
    form.append('csrf_token', await this.csrfTokenService.getTokenAsync());
    try {
      return await this.http
        .post<SaveAudioResponse>(this.getAudioUploadUrl(explorationId), form)
        .toPromise();
      // We use unknown type because we are unsure of the type of error
      // that was thrown. Since the catch block cannot identify the
      // specific type of error, we are unable to further optimise the
      // code by introducing more types of errors.
    } catch (error: unknown) {
      if (error instanceof HttpErrorResponse) {
        return Promise.reject(error.error);
      }
      throw error;
    }
  }

  async saveMathExpressionImage(
    resampledFile: Blob,
    filename: string,
    entityType: string,
    entityId: string
  ): Promise<SaveImageResponse> {
    const form = new FormData();
    form.append('image', resampledFile);
    form.append(
      'payload',
      JSON.stringify({filename, filename_prefix: 'image'})
    );
    form.append('csrf_token', await this.csrfTokenService.getTokenAsync());
    try {
      return await this.http
        .post<SaveImageResponse>(
          this.getImageUploadUrl(entityType, entityId),
          form
        )
        .toPromise();
      // We use unknown type because we are unsure of the type of error
      // that was thrown. Since the catch block cannot identify the
      // specific type of error, we are unable to further optimise the
      // code by introducing more types of errors.
    } catch (error: unknown) {
      if (error instanceof HttpErrorResponse) {
        return Promise.reject(error.error);
      }
      throw error;
    }
  }

  postThumbnailFile(
    resampledFile: Blob,
    filename: string,
    entityType: string,
    entityId: string
  ): Observable<{filename: string}> {
    let form = new FormData();
    form.append('image', resampledFile);
    form.append(
      'payload',
      JSON.stringify({
        filename: filename,
        filename_prefix: 'thumbnail',
      })
    );
    let imageUploadUrlTemplate =
      '/createhandler/imageupload/' + '<entity_type>/<entity_id>';
    let thumbnailFileUrl = this.urlInterpolationService.interpolateUrl(
      imageUploadUrlTemplate,
      {
        entity_type: entityType,
        entity_id: entityId,
      }
    );
    return this.http.post<{filename: string}>(thumbnailFileUrl, form);
  }

  isCached(filename: string): boolean {
    return this.assetsCache.has(filename);
  }

  abortAllCurrentAudioDownloads(): void {
    this.abortAllCurrentDownloads(AppConstants.ASSET_TYPE_AUDIO);
  }

  abortAllCurrentImageDownloads(): void {
    this.abortAllCurrentDownloads(AppConstants.ASSET_TYPE_IMAGE);
  }

  getAssetsFilesCurrentlyBeingRequested(): {
    [assetType: string]: readonly FileDownloadRequest[];
  } {
    return {
      [AppConstants.ASSET_TYPE_AUDIO]: this.audioFileDownloadRequests,
      [AppConstants.ASSET_TYPE_IMAGE]: this.imageFileDownloadRequests,
    };
  }

  getAudioDownloadUrl(
    entityType: string,
    entityId: string,
    filename: string
  ): string {
    return this.getDownloadUrl(
      entityType,
      entityId,
      filename,
      AppConstants.ASSET_TYPE_AUDIO
    );
  }

  getImageUrlForPreview(
    entityType: string,
    entityId: string,
    filename: string
  ): string {
    return this.getDownloadUrl(
      entityType,
      entityId,
      filename,
      AppConstants.ASSET_TYPE_IMAGE
    );
  }

  getThumbnailUrlForPreview(
    entityType: string,
    entityId: string,
    filename: string
  ): string {
    return this.getDownloadUrl(
      entityType,
      entityId,
      filename,
      AppConstants.ASSET_TYPE_THUMBNAIL
    );
  }

  private getDownloadUrl(
    entityType: string,
    entityId: string,
    filename: string,
    assetType: string
  ): string {
    let downloadUrl = this.urlInterpolationService.interpolateUrl(
      this.downloadUrlTemplate,
      {
        entity_type: entityType,
        entity_id: entityId,
        asset_type: assetType,
        filename: filename,
      }
    );
    return downloadUrl;
  }

  private getFileDownloadRequestsByAssetType(
    assetType: string
  ): FileDownloadRequest[] {
    if (assetType === AppConstants.ASSET_TYPE_AUDIO) {
      return this.audioFileDownloadRequests;
    } else {
      return this.imageFileDownloadRequests;
    }
  }

  private async fetchFile(
    entityType: string,
    entityId: string,
    filename: string,
    assetType: string
  ): Promise<AudioFile | ImageFile> {
    let onResolve!: (_: Blob) => void;
    let onReject!: () => void;
    const blobPromise = new Promise<Blob>((resolve, reject) => {
      onResolve = resolve;
      onReject = reject;
    });

    const subscription = this.http
      .get(this.getDownloadUrl(entityType, entityId, filename, assetType), {
        responseType: 'blob',
      })
      .subscribe(onResolve, onReject);

    const fileDownloadRequests =
      this.getFileDownloadRequestsByAssetType(assetType);
    fileDownloadRequests.push(new FileDownloadRequest(filename, subscription));

    try {
      const blob = await blobPromise;
      this.assetsCache.set(filename, blob);
      if (assetType === AppConstants.ASSET_TYPE_AUDIO) {
        return new AudioFile(filename, blob);
      } else {
        return new ImageFile(filename, blob);
      }
    } catch {
      return Promise.reject(filename);
    } finally {
      const i = fileDownloadRequests.findIndex(r => r.filename === filename);
      if (i !== -1) {
        fileDownloadRequests.splice(i, 1);
      }
    }
  }

  private abortAllCurrentDownloads(assetType: string): void {
    const fileDownloadRequests =
      this.getFileDownloadRequestsByAssetType(assetType);
    fileDownloadRequests.forEach(r => r.subscription.unsubscribe());
    fileDownloadRequests.length = 0;
  }

  private getAudioUploadUrl(explorationId: string): string {
    let audioUploadUrl = this.urlInterpolationService.interpolateUrl(
      AppConstants.AUDIO_UPLOAD_URL_TEMPLATE,
      {
        exploration_id: explorationId,
      }
    );
    return audioUploadUrl;
  }

  private getImageUploadUrl(entityType: string, entityId: string): string {
    let imageUploadUrl = this.urlInterpolationService.interpolateUrl(
      AppConstants.IMAGE_UPLOAD_URL_TEMPLATE,
      {entity_type: entityType, entity_id: entityId}
    );
    return imageUploadUrl;
  }
}

angular
  .module('oppia')
  .factory(
    'AssetsBackendApiService',
    downgradeInjectable(AssetsBackendApiService)
  );