From ee0e7460627ae782940b598acbd05a2a65124a82 Mon Sep 17 00:00:00 2001 From: milanmajchrak <90026355+milanmajchrak@users.noreply.github.com> Date: Fri, 15 Dec 2023 14:05:11 +0100 Subject: [PATCH] ufal/fe-oversized-file-upload-message (#424) * If the file exceeds the upload max file size the uploading will be stopped before starting and the user will see proper error message. * Fixed unit tests - added configurationDataService --- ...my-dspace-new-submission.component.spec.ts | 7 ++ .../uploader/uploader.component.spec.ts | 7 ++ .../upload/uploader/uploader.component.ts | 64 +++++++++++++++++-- .../submission-upload-files.component.html | 2 +- .../submission-upload-files.component.ts | 8 ++- src/assets/i18n/cs.json5 | 3 + src/assets/i18n/en.json5 | 2 + 7 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts index ed61fab1d62..c85b5166c37 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -26,6 +26,8 @@ import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; import { getMockEntityTypeService } from './my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec'; import { EntityTypeDataService } from '../../core/data/entity-type-data.service'; +import { of } from 'rxjs'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; describe('MyDSpaceNewSubmissionComponent test', () => { @@ -35,6 +37,10 @@ describe('MyDSpaceNewSubmissionComponent test', () => { uploadAll: jasmine.createSpy('uploadAll').and.stub() }); + const configurationServiceSpy = jasmine.createSpyObj('configurationService', { + findByPropertyName: of({}), + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -64,6 +70,7 @@ describe('MyDSpaceNewSubmissionComponent test', () => { { provide: CookieService, useValue: new CookieServiceMock() }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: EntityTypeDataService, useValue: getMockEntityTypeService() }, + { provide: ConfigurationDataService, useValue: configurationServiceSpy }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/shared/upload/uploader/uploader.component.spec.ts b/src/app/shared/upload/uploader/uploader.component.spec.ts index 8ea23c8acbd..90762875ca1 100644 --- a/src/app/shared/upload/uploader/uploader.component.spec.ts +++ b/src/app/shared/upload/uploader/uploader.component.spec.ts @@ -14,6 +14,8 @@ import { HttpXsrfTokenExtractor } from '@angular/common/http'; import { CookieService } from '../../../core/services/cookie.service'; import { CookieServiceMock } from '../../mocks/cookie.service.mock'; import { HttpXsrfTokenExtractorMock } from '../../mocks/http-xsrf-token-extractor.mock'; +import { of } from 'rxjs'; +import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; describe('Chips component', () => { @@ -21,6 +23,10 @@ describe('Chips component', () => { let testFixture: ComponentFixture; let html; + const configurationServiceSpy = jasmine.createSpyObj('configurationService', { + findByPropertyName: of({}), + }); + // waitForAsync beforeEach beforeEach(waitForAsync(() => { @@ -40,6 +46,7 @@ describe('Chips component', () => { DragService, { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') }, { provide: CookieService, useValue: new CookieServiceMock() }, + { provide: ConfigurationDataService, useValue: configurationServiceSpy }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); diff --git a/src/app/shared/upload/uploader/uploader.component.ts b/src/app/shared/upload/uploader/uploader.component.ts index 14b1ca9b94f..c8963847e92 100644 --- a/src/app/shared/upload/uploader/uploader.component.ts +++ b/src/app/shared/upload/uploader/uploader.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostListener, Input, Output, ViewEncapsulation, } from '@angular/core'; -import { of as observableOf } from 'rxjs'; -import { FileUploader } from 'ng2-file-upload'; +import { firstValueFrom, Observable, of as observableOf } from 'rxjs'; +import { FileItem, FileUploader } from 'ng2-file-upload'; import uniqueId from 'lodash/uniqueId'; import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; @@ -12,7 +12,14 @@ import { HttpXsrfTokenExtractor } from '@angular/common/http'; import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from '../../../core/xsrf/xsrf.interceptor'; import { CookieService } from '../../../core/services/cookie.service'; import { DragService } from '../../../core/drag.service'; +import {ConfigurationDataService} from '../../../core/data/configuration-data.service'; +import { map } from 'rxjs/operators'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; +import { TranslateService } from '@ngx-translate/core'; +export const MAX_UPLOAD_FILE_SIZE_CFG_PROPERTY = 'spring.servlet.multipart.max-file-size'; @Component({ selector: 'ds-uploader', templateUrl: 'uploader.component.html', @@ -90,7 +97,9 @@ export class UploaderComponent { private scrollToService: ScrollToService, private dragService: DragService, private tokenExtractor: HttpXsrfTokenExtractor, - private cookieService: CookieService + private cookieService: CookieService, + private configurationService: ConfigurationDataService, + private translate: TranslateService ) { } @@ -129,7 +138,20 @@ export class UploaderComponent { if (isUndefined(this.onBeforeUpload)) { this.onBeforeUpload = () => {return;}; } - this.uploader.onBeforeUploadItem = (item) => { + this.uploader.onBeforeUploadItem = async (item) => { + // Check if the file size is within the maximum upload size + const canUpload = await this.checkFileSizeLimit(item); + // If the file size is too large, emit an error and cancel all uploads + if (!canUpload) { + this.onUploadError.emit({ + item: item, + response: this.translate.instant('submission.sections.upload.upload-failed.size-limit-exceeded'), + status: 400, + headers: {} + }); + this.uploader.cancelAll(); + return; + } if (item.url !== this.uploader.options.url) { item.url = this.uploader.options.url; } @@ -225,4 +247,38 @@ export class UploaderComponent { this.cookieService.set(XSRF_COOKIE, token); } + // Check if the file size is within the maximum upload size + private async checkFileSizeLimit(item: FileItem): Promise { + const maxFileUploadSize = await firstValueFrom(this.getMaxFileUploadSizeFromCfg()); + if (maxFileUploadSize) { + const maxSizeInGigabytes = parseInt(maxFileUploadSize?.[0], 10); + const maxSizeInBytes = this.gigabytesToBytes(maxSizeInGigabytes); + // If maxSizeInBytes is -1, it means the value in the config is invalid. The file won't be uploaded and the user + // will see error messages in the UI. + if (maxSizeInBytes === -1) { + return false; + } + return item?.file?.size <= maxSizeInBytes; + } + return false; + } + + // Convert gigabytes to bytes + private gigabytesToBytes(gigabytes: number): number { + if (typeof gigabytes !== 'number' || isNaN(gigabytes) || !isFinite(gigabytes) || gigabytes < 0) { + return -1; + } + return gigabytes * Math.pow(2, 30); // 2^30 bytes in a gigabyte + } + + // Get the maximum file upload size from the configuration + public getMaxFileUploadSizeFromCfg(): Observable { + return this.configurationService.findByPropertyName(MAX_UPLOAD_FILE_SIZE_CFG_PROPERTY).pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded ? propertyRD.payload.values : []; + }) + ); + } + } diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.html b/src/app/submission/form/submission-upload-files/submission-upload-files.component.html index dfad8c422ec..cf916fb413b 100644 --- a/src/app/submission/form/submission-upload-files/submission-upload-files.component.html +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.html @@ -5,4 +5,4 @@ [onBeforeUpload]="onBeforeUpload" [uploadFilesOptions]="uploadFilesOptions" (onCompleteItem)="onCompleteItem($event)" - (onUploadError)="onUploadError()"> + (onUploadError)="onUploadError($event)"> diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts b/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts index 721a6c108b6..d16500a8640 100644 --- a/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts @@ -155,8 +155,12 @@ export class SubmissionUploadFilesComponent implements OnChanges { /** * Show error notification on upload fails */ - public onUploadError() { - this.notificationsService.error(null, this.translate.get('submission.sections.upload.upload-failed')); + public onUploadError(event: any) { + const errorMessageUploadLimit = this.translate.instant('submission.sections.upload.upload-failed.size-limit-exceeded'); + const defaultErrorMessage = this.translate.instant('submission.sections.upload.upload-failed'); + const errorMessage = event?.response === errorMessageUploadLimit ? errorMessageUploadLimit : defaultErrorMessage; + + this.notificationsService.error(null, errorMessage); } /** diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index bbd929911e2..758f92a42bb 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -7500,6 +7500,9 @@ // "submission.sections.upload.upload-successful": "Upload successful", "submission.sections.upload.upload-successful" : "Úspěšné nahrání", + // "submission.sections.upload.upload-failed.size-limit-exceeded": "File size exceeds the maximum upload size", + "submission.sections.upload.upload-failed.size-limit-exceeded": "Soubor přesahuje maximální povolenou velikost", + // "submission.sections.accesses.form.discoverable-description": "When checked, this item will be discoverable in search/browse. When unchecked, the item will only be available via a direct link and will never appear in search/browse.", "submission.sections.accesses.form.discoverable-description" : "Pokud je tato položka zaškrtnuta, bude ji možné hledat ve vyhledávání/prohlížení. Pokud není zaškrtnuta, bude položka dostupná pouze prostřednictvím přímého odkazu a nikdy se nezobrazí ve vyhledávání/prohlížení.", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 88c9848be86..72a9eacb8a7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -5081,6 +5081,8 @@ "submission.sections.upload.upload-successful": "Upload successful", + "submission.sections.upload.upload-failed.size-limit-exceeded": "File size exceeds the maximum upload size", + "submission.sections.accesses.form.discoverable-description": "When checked, this item will be discoverable in search/browse. When unchecked, the item will only be available via a direct link and will never appear in search/browse.", "submission.sections.accesses.form.discoverable-label": "Discoverable",