diff --git a/frontend/src/app/pages/cloud/cloud.component.html b/frontend/src/app/pages/cloud/cloud.component.html
index 68947300..1eec0883 100644
--- a/frontend/src/app/pages/cloud/cloud.component.html
+++ b/frontend/src/app/pages/cloud/cloud.component.html
@@ -17,6 +17,18 @@
Cloud Page
+
Cloud Page
ariaLabel="Create share link"
>
{
expect(component.isEditorDirty).toBeFalse();
});
});
+
+ describe('folder download', () => {
+ let component: CloudComponent;
+ let cloudMock: jasmine.SpyObj;
+ let toastMock: jasmine.SpyObj;
+
+ beforeEach(() => {
+ jasmine.clock().uninstall();
+ cloudMock = jasmine.createSpyObj('CloudService', [
+ 'getFolderArchive',
+ ]);
+ toastMock = jasmine.createSpyObj('UiToastService', [
+ 'success',
+ 'info',
+ 'warn',
+ 'error',
+ ]);
+ component = new CloudComponent(
+ cloudMock,
+ {} as never,
+ toastMock as never,
+ {} as never,
+ {} as never,
+ );
+ component.rootPath = '/root';
+ component.currentFolder = { path: '/root/sub', name: 'sub' } as never;
+ });
+
+ it('downloads the folder as a zip archive and triggers browser download', () => {
+ const mockBlob = new Blob(['zip content'], { type: 'application/zip' });
+ cloudMock.getFolderArchive.and.returnValue(of(mockBlob));
+
+ const mockAnchor = jasmine.createSpyObj(
+ 'HTMLAnchorElement',
+ ['click'],
+ );
+ spyOn(document, 'createElement').and.returnValue(mockAnchor as any);
+ spyOn(URL, 'createObjectURL').and.returnValue('blob:mock-url');
+ spyOn(URL, 'revokeObjectURL');
+
+ component.downloadFolder('/root/sub', 'sub');
+
+ expect(cloudMock.getFolderArchive).toHaveBeenCalledWith('sub');
+ expect(document.createElement).toHaveBeenCalledWith('a');
+ expect(mockAnchor.href).toBe('blob:mock-url');
+ expect(mockAnchor.download).toBe('sub.zip');
+ expect(mockAnchor.click).toHaveBeenCalled();
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+ expect(toastMock.info).toHaveBeenCalledWith(
+ 'Download started',
+ '"sub.zip" is downloading.',
+ );
+ });
+
+ it('surfaces folder archive backend errors using toast notifications', (done) => {
+ const mockErrorResponse = {
+ error: new Blob([JSON.stringify({ message: 'Rate limit exceeded' })], {
+ type: 'application/json',
+ }),
+ };
+ spyOn(component, 'getErrorMessage').and.returnValue(
+ 'Rate limit exceeded',
+ );
+ cloudMock.getFolderArchive.and.returnValue(
+ throwError(() => mockErrorResponse),
+ );
+
+ component.downloadFolder('/root/sub', 'sub');
+
+ setTimeout(() => {
+ expect(toastMock.error).toHaveBeenCalledWith(
+ 'Download failed',
+ 'Rate limit exceeded',
+ );
+ done();
+ }, 50);
+ });
+
+ it('falls back to generic error message if error blob is not parseable', (done) => {
+ const mockErrorResponse = {
+ error: new Blob(['invalid json'], { type: 'application/json' }),
+ };
+ spyOn(component, 'getErrorMessage').and.returnValue(
+ 'Request failed. Please try again.',
+ );
+ cloudMock.getFolderArchive.and.returnValue(
+ throwError(() => mockErrorResponse),
+ );
+
+ component.downloadFolder('/root/sub', 'sub');
+
+ setTimeout(() => {
+ expect(toastMock.error).toHaveBeenCalledWith(
+ 'Download failed',
+ 'Request failed. Please try again.',
+ );
+ done();
+ }, 50);
+ });
+ });
});
describe('CloudComponent Secure Send Flow', () => {
diff --git a/frontend/src/app/pages/cloud/cloud.component.ts b/frontend/src/app/pages/cloud/cloud.component.ts
index a5bfbc74..01d0274e 100644
--- a/frontend/src/app/pages/cloud/cloud.component.ts
+++ b/frontend/src/app/pages/cloud/cloud.component.ts
@@ -31,7 +31,7 @@ import { ScanJobDto } from '../../models/dtos/ScanJobDto';
import { FileScanResultDto } from '../../models/dtos/FileScanResultDto';
import { SecureSendLinkDto } from '../../models/dtos/SecureSendLinkDto';
import { CloudService } from '../../services/cloud.service';
-import { finalize, firstValueFrom } from 'rxjs';
+import { finalize, firstValueFrom, Observable, of } from 'rxjs';
import { UiToastService } from '../../core/services/ui-toast.service';
interface Breadcrumb {
@@ -1030,6 +1030,77 @@ export class CloudComponent implements OnInit, OnDestroy {
this.downloadFile(this.toFileRef(path, name));
}
+ downloadFolder(folderPath: string, folderName: string) {
+ const pathKey = folderPath;
+ const relativePath = this.getRelativePath(folderPath);
+ this.downloadingPaths.add(pathKey);
+
+ this.cloudService
+ .getFolderArchive(relativePath)
+ .pipe(
+ finalize(() => {
+ this.downloadingPaths.delete(pathKey);
+ }),
+ )
+ .subscribe({
+ next: (blob) => {
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ const zipName = folderName ? `${folderName}.zip` : 'archive.zip';
+ a.download = zipName;
+ a.click();
+ window.URL.revokeObjectURL(url);
+ this.toast.info('Download started', `"${zipName}" is downloading.`);
+ },
+ error: (err) => {
+ this.parseBlobError(err).subscribe((msg) => {
+ this.toast.error('Download failed', msg);
+ });
+ },
+ });
+ }
+
+ downloadFolderByPath(path: string, name: string) {
+ this.downloadFolder(path, name);
+ }
+
+ downloadCurrentFolder() {
+ if (this.currentFolder) {
+ const folderName =
+ this.currentFolder.path === this.rootPath
+ ? 'cloud-root'
+ : this.getNameFromPath(this.currentFolder.path);
+ this.downloadFolder(this.currentFolder.path, folderName);
+ }
+ }
+
+ private parseBlobError(err: unknown): Observable {
+ const candidate = err as { error?: unknown };
+ if (candidate && candidate.error instanceof Blob) {
+ const reader = new FileReader();
+ return new Observable((observer) => {
+ reader.onload = () => {
+ try {
+ const parsed = JSON.parse(reader.result as string);
+ observer.next(
+ parsed.message || 'Request failed. Please try again.',
+ );
+ } catch {
+ observer.next('Request failed. Please try again.');
+ }
+ observer.complete();
+ };
+ reader.onerror = () => {
+ observer.next('Request failed. Please try again.');
+ observer.complete();
+ };
+ reader.readAsText(candidate.error as Blob);
+ });
+ }
+ return of(this.getErrorMessage(err));
+ }
+
previewFileByPath(path: string, name: string) {
this.previewFile(this.toFileRef(path, name));
}
diff --git a/frontend/src/app/services/cloud.service.spec.ts b/frontend/src/app/services/cloud.service.spec.ts
index 66c45df5..993f9d4f 100644
--- a/frontend/src/app/services/cloud.service.spec.ts
+++ b/frontend/src/app/services/cloud.service.spec.ts
@@ -101,4 +101,13 @@ describe('CloudService Secure Send', () => {
expect(req.request.method).toBe('DELETE');
req.flush(null);
});
+
+ it('should request folders/download with path query param for folder archive', () => {
+ service.getFolderArchive('test-folder').subscribe();
+
+ const req = httpMock.expectOne((r) => r.url.endsWith('/folders/download'));
+ expect(req.request.method).toBe('GET');
+ expect(req.request.params.get('path')).toBe('test-folder');
+ req.flush(new Blob(['zip'], { type: 'application/zip' }));
+ });
});
diff --git a/frontend/src/app/services/cloud.service.ts b/frontend/src/app/services/cloud.service.ts
index cedc446e..dac5f532 100644
--- a/frontend/src/app/services/cloud.service.ts
+++ b/frontend/src/app/services/cloud.service.ts
@@ -171,6 +171,14 @@ export class CloudService {
});
}
+ getFolderArchive(relativePath: string): Observable {
+ const path = this.normalizePath(relativePath);
+ return this.http.get(`${this.apiUrl}/folders/download`, {
+ params: new HttpParams().set('path', path),
+ responseType: 'blob',
+ });
+ }
+
getFileView(path: string) {
return this.http.get(`${this.apiUrl}/files/view`, {
params: { path },