diff --git a/Makefile b/Makefile index 3dfea6a7a846f6f297572962aa6f6be986a4cf7b..808b8283bc4305e684c420fec727481b97b469fe 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,8 @@ list: @echo " phpcs > Run php code sniffer test suite" @echo " install_services > install services dependencies (virtualenv)" @echo " shell_services > Shell into python services" + @echo " install_tasks > install tasks dependencies (virtualenv)" + @echo " shell_tasks > Shell into python tasks" @echo " create-db > Create a database for dev only (need token_enabled=0)" @echo " remove-pgdata > Remove the anis-next database" @echo "" @@ -91,6 +93,12 @@ install_services: shell_services: @docker-compose exec services bash +install_tasks: + @docker run --init -it --rm --user $(UID):$(GID) -v $(CURDIR)/tasks:/project -w /project python:3.8 /bin/bash -c "python3 -m venv venv && source /project/venv/bin/activate && pip install -r requirements.txt" + +shell_tasks: + @docker-compose exec tasks bash + create-db: @docker-compose exec server sh /mnt/init-keycloak.sh @docker-compose exec server sh /mnt/create-db.sh diff --git a/client/src/app/instance/instance.reducer.ts b/client/src/app/instance/instance.reducer.ts index 062c160c5fe4f3fa84539d5e9665a2b5c299f5e5..ed942d2b4006a1d7be4b27325465d2089b8d506e 100644 --- a/client/src/app/instance/instance.reducer.ts +++ b/client/src/app/instance/instance.reducer.ts @@ -16,6 +16,7 @@ import * as samp from './store/reducers/samp.reducer'; import * as coneSearch from './store/reducers/cone-search.reducer'; import * as detail from './store/reducers/detail.reducer'; import * as svomJsonKw from './store/reducers/svom-json-kw.reducer'; +import * as downloadFile from './store/reducers/download-file.reducer'; /** * Interface for instance state. @@ -28,7 +29,8 @@ export interface State { samp: samp.State, coneSearch: coneSearch.State detail: detail.State, - svomJsonKw: svomJsonKw.State + svomJsonKw: svomJsonKw.State, + downloadFile: downloadFile.State } const reducers = { @@ -37,7 +39,8 @@ const reducers = { samp: samp.sampReducer, coneSearch: coneSearch.coneSearchReducer, detail: detail.detailReducer, - svomJsonKw: svomJsonKw.svomJsonKwReducer + svomJsonKw: svomJsonKw.svomJsonKwReducer, + downloadFile: downloadFile.fileReducer }; export const instanceReducer = combineReducers(reducers); diff --git a/client/src/app/instance/search/components/result/datatable-actions.component.html b/client/src/app/instance/search/components/result/datatable-actions.component.html index a1ff010a95fbcfa5304aadb79791f8841c341466..38e119dfcc5fcf3f7a52e8252ea369de276b1cfe 100644 --- a/client/src/app/instance/search/components/result/datatable-actions.component.html +++ b/client/src/app/instance/search/components/result/datatable-actions.component.html @@ -4,29 +4,29 @@ </button> <ul id="dropdown-basic" *dropdownMenu class="dropdown-menu" role="menu" aria-labelledby="button-basic"> <li *ngIf="getConfigDownloadResultFormat('download_csv')" role="menuitem"> - <a class="dropdown-item" [href]="getUrl('csv')" (click)="click($event, getUrl('csv'), 'csv')"> + <a class="dropdown-item" (click)="downloadResult('csv')"> <span class="fas fa-file-csv"></span> Download CSV </a> </li> <li *ngIf="getConfigDownloadResultFormat('download_ascii')" role="menuitem"> - <a class="dropdown-item" [href]="getUrl('ascii')" (click)="click($event, getUrl('ascii'), 'txt')"> + <a class="dropdown-item" (click)="downloadResult('ascii')"> <span class="fas fa-file"></span> Download ASCII </a> </li> <li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem"> - <a class="dropdown-item" [href]="getUrl('votable')" (click)="click($event, getUrl('votable'), 'xml')"> + <a class="dropdown-item" (click)="downloadResult('votable')"> <span class="fas fa-file"></span> VOtable </a> </li> - <li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem"> - <a class="dropdown-item" (click)="broadcastVotable()"> + <li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem" [class.disabled]="!sampRegistered"> + <a class="dropdown-item" [class.disabled]="!sampRegistered" (click)="broadcast.emit()"> <span class="fas fa-broadcast-tower"></span> Broadcast VOtable </a> </li> <li *ngIf="getConfigDownloadResultFormat('download_archive')" role="menuitem"> - <a class="dropdown-item" [href]="getUrlArchive()" (click)="click($event, getUrlArchive(), 'zip')"> + <a class="dropdown-item" (click)="downloadArchive()"> <span class="fas fa-archive"></span> Download files archive </a> </li> </ul> -</div> +</div> \ No newline at end of file diff --git a/client/src/app/instance/search/components/result/datatable-actions.component.ts b/client/src/app/instance/search/components/result/datatable-actions.component.ts index 5c29499606f57e2117b0fbec16fc780b7a23d630..73f55fd897f3a3f896422fe82f8efbe1e17a6d06 100644 --- a/client/src/app/instance/search/components/result/datatable-actions.component.ts +++ b/client/src/app/instance/search/components/result/datatable-actions.component.ts @@ -1,12 +1,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; - -import { ToastrService } from 'ngx-toastr'; import { Dataset, Attribute } from 'src/app/metamodel/models'; -import { Criterion, ConeSearch, criterionToString } from 'src/app/instance/store/models'; -import { getHost } from 'src/app/shared/utils'; -import { AppConfigService } from 'src/app/app-config.service'; +import { Criterion, ConeSearch } from 'src/app/instance/store/models'; @Component({ selector: 'app-datatable-actions', @@ -23,8 +18,8 @@ export class DatatableActionsComponent { @Input() dataLength: number; @Input() sampRegistered: boolean; @Output() broadcast: EventEmitter<string> = new EventEmitter(); - - constructor(private appConfig: AppConfigService, private http: HttpClient, private toastr: ToastrService) { } + @Output() startTaskCreateResult: EventEmitter<{ format: string, selectedData: boolean }> = new EventEmitter(); + @Output() startTaskCreateArchive: EventEmitter<{ selectedData: boolean }> = new EventEmitter(); /** * Checks if the download format is allowed by Anis Admin configuration. @@ -42,76 +37,21 @@ export class DatatableActionsComponent { return this.datasetList.find(d => d.name === this.datasetSelected); } - /** - * Returns URL to download file for the given format. - * - * @param {string} format - The file format to download. - * - * @return string - */ - getUrl(format: string): string { - let query: string = `${getHost(this.appConfig.apiUrl)}/search/${this.datasetSelected}?a=${this.outputList.join(';')}`; - if (this.criteriaList.length > 0) { - query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')};${this.getCriterionSelectedData()}`; - } else { - query += `&c=${this.getCriterionSelectedData()}`; - } - if (this.coneSearch) { - query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`; - } - query += `&f=${format}`; - return query; - } - getCriterionSelectedData() { const attributeId = this.attributeList.find(a => a.search_flag === 'ID'); return `${attributeId.id}::in::${this.selectedData.join('|')}`; } - /** - * Returns URL to download archive. - * - * @return boolean - */ - getUrlArchive(): string { - let query: string = `${getHost(this.appConfig.apiUrl)}/archive/${this.datasetSelected}?a=${this.outputList.join(';')}`; - if (this.criteriaList.length > 0) { - query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')};${this.getCriterionSelectedData()}`; - } else { - query += `&c=${this.getCriterionSelectedData()}`; - } - if (this.coneSearch) { - query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`; - } - return query; - } - - /** - * Emits event to action to broadcast data. - * - * @fires EventEmitter<string> - */ - broadcastVotable(): void { - this.broadcast.emit(this.getUrl('votable')); + downloadResult(format: string) { + this.startTaskCreateResult.emit({ + format, + selectedData: true + }); } - /** - * Allows to download file. - */ - click(event, href, extension): void { - event.preventDefault(); - - if (extension === 'zip') { - this.toastr.info('Achive is under construction, please wait', 'Download archive'); - } - - this.http.get(href, {responseType: "blob"}).subscribe( - data => { - let downloadLink = document.createElement('a'); - downloadLink.href = window.URL.createObjectURL(data); - downloadLink.setAttribute('download', `${this.datasetSelected}.${extension}`); - downloadLink.click(); - } - ); + downloadArchive() { + this.startTaskCreateArchive.emit({ + selectedData: true + }); } } diff --git a/client/src/app/instance/search/components/result/datatable-tab.component.html b/client/src/app/instance/search/components/result/datatable-tab.component.html index b0ad6b0c98a437955dc1be5f759df0ce1f640a94..3a9773c09cb7445b215159963028ddaeece775f7 100644 --- a/client/src/app/instance/search/components/result/datatable-tab.component.html +++ b/client/src/app/instance/search/components/result/datatable-tab.component.html @@ -18,22 +18,25 @@ [coneSearch]="coneSearch" [dataLength]="dataLength" [sampRegistered]="sampRegistered" - (broadcast)="broadcast.emit($event)"> + (broadcast)="broadcast.emit($event)" + (startTaskCreateResult)="startTaskCreateResult.emit($event)" + (startTaskCreateArchive)="startTaskCreateArchive.emit($event)"> </app-datatable-actions> <app-datatable - [dataset]="datasetList | datasetByName:datasetSelected" - [instance]="instance" - [attributeList]="attributeList" - [outputList]="outputList" - [queryParams]="queryParams" - [dataLength]="dataLength" - [data]="data" - [dataIsLoading]="dataIsLoading" - [dataIsLoaded]="dataIsLoaded" - [selectedData]="selectedData" - (retrieveData)="retrieveData.emit($event)" - (addSelectedData)="addSelectedData.emit($event)" - (deleteSelectedData)="deleteSelectedData.emit($event)"> + [dataset]="datasetList | datasetByName:datasetSelected" + [instance]="instance" + [attributeList]="attributeList" + [outputList]="outputList" + [queryParams]="queryParams" + [dataLength]="dataLength" + [data]="data" + [dataIsLoading]="dataIsLoading" + [dataIsLoaded]="dataIsLoaded" + [selectedData]="selectedData" + (retrieveData)="retrieveData.emit($event)" + (addSelectedData)="addSelectedData.emit($event)" + (deleteSelectedData)="deleteSelectedData.emit($event)" + (downloadFile)="downloadFile.emit($event)"> </app-datatable> </accordion-group> </accordion> diff --git a/client/src/app/instance/search/components/result/datatable-tab.component.ts b/client/src/app/instance/search/components/result/datatable-tab.component.ts index c70564ba46caa5d342e2c80c5516221d549dab58..2f4e3475b628d1e746d03f88ec93c27d5eef1cd5 100644 --- a/client/src/app/instance/search/components/result/datatable-tab.component.ts +++ b/client/src/app/instance/search/components/result/datatable-tab.component.ts @@ -40,4 +40,7 @@ export class DatatableTabComponent { @Output() addSelectedData: EventEmitter<number | string> = new EventEmitter(); @Output() deleteSelectedData: EventEmitter<number | string> = new EventEmitter(); @Output() broadcast: EventEmitter<string> = new EventEmitter(); + @Output() startTaskCreateResult: EventEmitter<{ format: string, selectedData: boolean }> = new EventEmitter(); + @Output() startTaskCreateArchive: EventEmitter<{ selectedData: boolean }> = new EventEmitter(); + @Output() downloadFile: EventEmitter<{url: string, fileId: string, datasetName: string, filename: string}> = new EventEmitter(); } diff --git a/client/src/app/instance/search/components/result/datatable.component.html b/client/src/app/instance/search/components/result/datatable.component.html index bbf61801f4fcfe35d312fcebf46867356116be76..0647cae6aa7c479d26a4a022839dca28464509f0 100644 --- a/client/src/app/instance/search/components/result/datatable.component.html +++ b/client/src/app/instance/search/components/result/datatable.component.html @@ -62,7 +62,8 @@ [value]="datum[attribute.label]" [datasetName]="dataset.name" [datasetPublic]="dataset.public" - [config]="getRendererConfig(attribute)"> + [config]="getRendererConfig(attribute)" + (downloadFile)="downloadFile.emit($event)"> </app-download-renderer> </div> <div *ngSwitchCase="'image'"> diff --git a/client/src/app/instance/search/components/result/datatable.component.ts b/client/src/app/instance/search/components/result/datatable.component.ts index bab2e9c71b6b4105fa0e39b3c018bee5f042e6a7..0718b70e9c2ef8024bd9fb4b35a568f53532852e 100644 --- a/client/src/app/instance/search/components/result/datatable.component.ts +++ b/client/src/app/instance/search/components/result/datatable.component.ts @@ -44,6 +44,7 @@ export class DatatableComponent implements OnInit { @Output() retrieveData: EventEmitter<Pagination> = new EventEmitter(); @Output() addSelectedData: EventEmitter<number | string> = new EventEmitter(); @Output() deleteSelectedData: EventEmitter<number | string> = new EventEmitter(); + @Output() downloadFile: EventEmitter<{url: string, fileId: string, datasetName: string, filename: string}> = new EventEmitter(); public page = 1; public nbItems = 10; diff --git a/client/src/app/instance/search/components/result/download-file-tab.component.html b/client/src/app/instance/search/components/result/download-file-tab.component.html new file mode 100644 index 0000000000000000000000000000000000000000..49f60cee2bf34aa7a4d379d4b12829c8b6b6bdee --- /dev/null +++ b/client/src/app/instance/search/components/result/download-file-tab.component.html @@ -0,0 +1,11 @@ +<div class="jumbotron mb-4 py-4"> + <div class="lead"> + Files downloaded : + <ul> + <li *ngFor="let downloadFile of downloadedFiles"> + {{ downloadFile.fileName }} : + <progressbar [value]="downloadFile.progress" [type]="getType(downloadFile.progress)" [animate]="true">{{ downloadFile.progress }}%</progressbar> + </li> + </ul> + </div> +</div> \ No newline at end of file diff --git a/client/src/app/instance/search/components/result/download-file-tab.component.scss b/client/src/app/instance/search/components/result/download-file-tab.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/client/src/app/instance/search/components/result/download-file-tab.component.ts b/client/src/app/instance/search/components/result/download-file-tab.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..95d1e5b6389f9ef6b91263070574821c752901d9 --- /dev/null +++ b/client/src/app/instance/search/components/result/download-file-tab.component.ts @@ -0,0 +1,33 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Component, Input } from '@angular/core'; + +import { DownloadFile } from 'src/app/instance/store/models'; + +/** + * @class + * @classdesc Search result reminder component. + */ +@Component({ + selector: 'app-download-file-tab', + templateUrl: 'download-file-tab.component.html', + styleUrls: ['download-file-tab.component.scss'] +}) +export class DownloadFileTabComponent { + @Input() downloadedFiles: DownloadFile[]; + + getType(value: number): 'success' | 'info' { + if (value < 100) { + return 'info'; + } + + return 'success'; + } +} diff --git a/client/src/app/instance/search/components/result/download.component.html b/client/src/app/instance/search/components/result/download.component.html index 24b48f2a7d503452bb2c9e1f82e65e92e0ccd57f..0c9c15a510fa3b62b7405b882a5d30a4068808dd 100644 --- a/client/src/app/instance/search/components/result/download.component.html +++ b/client/src/app/instance/search/components/result/download.component.html @@ -18,19 +18,19 @@ <p>Download results just here:</p> </div> <div class="col"> - <a *ngIf="getConfigDownloadResultFormat('download_csv')" [href]="getUrl('csv')" (click)="click($event, getUrl('csv'), 'csv')" class="btn btn-outline-primary" title="Download results in CSV format"> + <a *ngIf="getConfigDownloadResultFormat('download_csv')" (click)="downloadResult('csv')" class="btn btn-outline-primary" title="Download results in CSV format"> <span class="fas fa-file-csv"></span> CSV </a> - <a *ngIf="getConfigDownloadResultFormat('download_ascii')" [href]="getUrl('ascii')" (click)="click($event, getUrl('ascii'), 'txt')" class="btn btn-outline-primary" title="Download results in ASCII format"> + <a *ngIf="getConfigDownloadResultFormat('download_ascii')" (click)="downloadResult('ascii')" class="btn btn-outline-primary" title="Download results in ASCII format"> <span class="fas fa-file"></span> ASCII </a> - <a *ngIf="getConfigDownloadResultFormat('download_vo')" [href]="getUrl('votable')" (click)="click($event, getUrl('votable'), 'xml')" class="btn btn-outline-primary" title="Download results in VO format"> + <a *ngIf="getConfigDownloadResultFormat('download_vo')" (click)="downloadResult('votable')" class="btn btn-outline-primary" title="Download results in VO format"> <span class="fas fa-file"></span> VOtable </a> - <button *ngIf="getConfigDownloadResultFormat('download_vo')" [disabled]="!sampRegistered" (click)="broadcastVotable()" class="btn btn-outline-primary" title="Broadcast samp votable"> + <button *ngIf="getConfigDownloadResultFormat('download_vo')" [disabled]="!sampRegistered" (click)="downloadResult('votable')" class="btn btn-outline-primary" title="Broadcast samp votable"> <span class="fas fa-broadcast-tower"></span> Broadcast VOtable </button> </div> @@ -41,7 +41,7 @@ <p>Download archive files just here:</p> </div> <div class="col"> - <a [href]="getUrlArchive()" (click)="click($event, getUrlArchive(), 'zip')" class="btn btn-outline-primary" title="Download an archive with all files"> + <a (click)="downloadArchive()" class="btn btn-outline-primary" title="Download an archive with all files"> <span class="fas fa-archive"></span> Files archive </a> </div> diff --git a/client/src/app/instance/search/components/result/download.component.spec.ts b/client/src/app/instance/search/components/result/download.component.spec.ts index 840d91af39bb8e863a90a42c9dbc9792085a52b8..dace40ba4e39bd0d7205c26cb89181ae887b2d8b 100644 --- a/client/src/app/instance/search/components/result/download.component.spec.ts +++ b/client/src/app/instance/search/components/result/download.component.spec.ts @@ -6,7 +6,6 @@ import { AccordionModule } from 'ngx-bootstrap/accordion'; import { DownloadComponent } from './download.component'; import { AppConfigService } from '../../../../app-config.service'; -import { FieldCriterion } from '../../../store/models/criterion'; describe('[Instance][Search][Component][Result] DownloadComponent', () => { let component: DownloadComponent; @@ -380,40 +379,4 @@ describe('[Instance][Search][Component][Result] DownloadComponent', () => { expect(component.getConfigDownloadResultFormat('download_csv')).toBeTruthy(); expect(component.getConfigDownloadResultFormat('download_ascii')).toBeFalsy(); }); - - it('#getUrl(format) should construct url with format parameter', () => { - appConfigServiceStub.apiUrl = 'http://test.com'; - component.datasetSelected = 'myDataset'; - component.outputList = [1, 2, 3]; - component.criteriaList = []; - expect(component.getUrl('csv')).toBe('http://test.com/search/myDataset?a=1;2;3&f=csv'); - component.criteriaList = [ - {'id':1,'type':'field','operator':'eq','value':'one'} as FieldCriterion, - {'id':2,'type':'field','operator':'eq','value':'two'} as FieldCriterion - ]; - component.coneSearch = { ra: 4, dec: 5, radius: 6 }; - expect(component.getUrl('csv')).toBe('http://test.com/search/myDataset?a=1;2;3&c=1::eq::one;2::eq::two&cs=4:5:6&f=csv'); - }); - - it('#getUrlArchive() should construct url to access to archive', () => { - appConfigServiceStub.apiUrl = 'http://test.com'; - component.datasetSelected = 'myDataset'; - component.outputList = [1, 2, 3]; - component.criteriaList = [ - {'id':1,'type':'field','operator':'eq','value':'one'} as FieldCriterion, - {'id':2,'type':'field','operator':'eq','value':'two'} as FieldCriterion - ]; - component.coneSearch = { ra: 4, dec: 5, radius: 6 }; - expect(component.getUrlArchive()).toBe('http://test.com/archive/myDataset?a=1;2;3&c=1::eq::one;2::eq::two&cs=4:5:6'); - }); - - it('#broadcastVotable() should raise broadcast event when clicked', () => { - appConfigServiceStub.apiUrl = 'http://test.com'; - component.datasetSelected = 'myDataset'; - component.outputList = [1, 2, 3]; - component.criteriaList = []; - component.broadcast.subscribe((event: string) => expect(event).toEqual('toto')); - component.broadcastVotable(); - - }); }); diff --git a/client/src/app/instance/search/components/result/download.component.ts b/client/src/app/instance/search/components/result/download.component.ts index f7ca76a176db606ccccea47bf798de3dfc1599ea..e9e03f9256c88c0f53ee73a4c59b695d97e9a491 100644 --- a/client/src/app/instance/search/components/result/download.component.ts +++ b/client/src/app/instance/search/components/result/download.component.ts @@ -8,12 +8,9 @@ */ import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Criterion, ConeSearch, criterionToString } from '../../../store/models'; +import { Criterion, ConeSearch } from '../../../store/models'; import { Dataset } from 'src/app/metamodel/models'; -import { getHost } from 'src/app/shared/utils'; -import { AppConfigService } from 'src/app/app-config.service'; /** * @class @@ -32,8 +29,8 @@ export class DownloadComponent { @Input() dataLength: number; @Input() sampRegistered: boolean; @Output() broadcast: EventEmitter<string> = new EventEmitter(); - - constructor(private appConfig: AppConfigService, private http: HttpClient) { } + @Output() startTaskCreateResult: EventEmitter<{ format: string, selectedData: boolean }> = new EventEmitter(); + @Output() startTaskCreateArchive: EventEmitter<{ selectedData: boolean }> = new EventEmitter(); /** * Checks if download tab has to be display. @@ -67,63 +64,16 @@ export class DownloadComponent { return dataset.config.download[format]; } - /** - * Returns URL to download file for the given format. - * - * @param {string} format - The file format to download. - * - * @return string - */ - getUrl(format: string): string { - let query: string = `${getHost(this.appConfig.apiUrl)}/search/${this.datasetSelected}?a=${this.outputList.join(';')}`; - if (this.criteriaList.length > 0) { - query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')}`; - } - if (this.coneSearch) { - query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`; - } - query += `&f=${format}`; - return query; - } - - /** - * Returns URL to download archive. - * - * @return boolean - */ - getUrlArchive(): string { - let query: string = `${getHost(this.appConfig.apiUrl)}/archive/${this.datasetSelected}?a=${this.outputList.join(';')}`; - if (this.criteriaList.length > 0) { - query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')}`; - } - if (this.coneSearch) { - query += `&cs=${this.coneSearch.ra}:${this.coneSearch.dec}:${this.coneSearch.radius}`; - } - return query; - } - - /** - * Emits event to action to broadcast data. - * - * @fires EventEmitter<string> - */ - broadcastVotable(): void { - this.broadcast.emit(this.getUrl('votable')); + downloadResult(format: string) { + this.startTaskCreateResult.emit({ + format, + selectedData: false + }); } - /** - * Allows to download file. - */ - click(event, href, extension): void { - event.preventDefault(); - - this.http.get(href, {responseType: "blob"}).subscribe( - data => { - let downloadLink = document.createElement('a'); - downloadLink.href = window.URL.createObjectURL(data); - downloadLink.setAttribute('download', `${this.datasetSelected}.${extension}`); - downloadLink.click(); - } - ); + downloadArchive() { + this.startTaskCreateArchive.emit({ + selectedData: false + }); } } diff --git a/client/src/app/instance/search/components/result/index.ts b/client/src/app/instance/search/components/result/index.ts index fffbcf4e766b53c4b4788371155f5e08e7b22207..4fa8d96abe8417aebb5a490ea60e9744f7616cc6 100644 --- a/client/src/app/instance/search/components/result/index.ts +++ b/client/src/app/instance/search/components/result/index.ts @@ -5,6 +5,7 @@ import { SampComponent } from './samp.component'; import { UrlDisplayComponent } from './url-display.component'; import { DatatableComponent } from './datatable.component'; import { DatatableActionsComponent } from './datatable-actions.component'; +import { DownloadFileTabComponent } from './download-file-tab.component'; import { rendererComponents } from './renderer'; export const resultComponents = [ @@ -15,5 +16,6 @@ export const resultComponents = [ UrlDisplayComponent, DatatableComponent, DatatableActionsComponent, + DownloadFileTabComponent, rendererComponents ]; diff --git a/client/src/app/instance/search/components/result/renderer/download-renderer.component.ts b/client/src/app/instance/search/components/result/renderer/download-renderer.component.ts index db6f92c8d0abe1335808f6017f972f99a488ae90..9cdb554f3312fcc61eb373a1068e359ac3f00adf 100644 --- a/client/src/app/instance/search/components/result/renderer/download-renderer.component.ts +++ b/client/src/app/instance/search/components/result/renderer/download-renderer.component.ts @@ -7,8 +7,7 @@ * file that was distributed with this source code. */ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; import { DownloadRendererConfig } from 'src/app/metamodel/models/renderers/download-renderer-config.model'; import { getHost } from 'src/app/shared/utils'; @@ -28,8 +27,9 @@ export class DownloadRendererComponent { @Input() datasetName: string; @Input() datasetPublic: boolean; @Input() config: DownloadRendererConfig; + @Output() downloadFile: EventEmitter<{url: string, fileId: string, datasetName: string, filename: string}> = new EventEmitter(); - constructor(private appConfig: AppConfigService, private http: HttpClient) { } + constructor(private appConfig: AppConfigService) { } /** * Returns link href. @@ -50,21 +50,23 @@ export class DownloadRendererComponent { } /** - * Downloads file on click. + * Starts downloading file on click. */ click(event): void { event.preventDefault(); + + const url = this.getHref(); + const filename = url.substring(url.lastIndexOf('/') + 1); - const href = this.getHref(); - this.http.get(href, { responseType: "blob" }).subscribe( - data => { - const filename = href.substring(href.lastIndexOf('/') + 1); + const n = Math.floor(Math.random() * 11); + const k = Math.floor(Math.random() * 1000000); + const m = String.fromCharCode(n) + k; - let downloadLink = document.createElement('a'); - downloadLink.href = window.URL.createObjectURL(data); - downloadLink.setAttribute('download', filename); - downloadLink.click(); - } - ); + this.downloadFile.emit({ + url, + fileId: m, + datasetName: this.datasetName, + filename + }); } } diff --git a/client/src/app/instance/search/containers/result.component.html b/client/src/app/instance/search/containers/result.component.html index 6ab41d2cd824b5b049c07165a48da4f90bdd5428..d06e9f7f5d38fe71eb6878a08d112f32a7426ef1 100644 --- a/client/src/app/instance/search/containers/result.component.html +++ b/client/src/app/instance/search/containers/result.component.html @@ -25,6 +25,10 @@ selected with <span class="font-weight-bold">{{ dataLength | async }}</span> objects found. </div> </div> + <app-download-file-tab + *ngIf="(downloadedFiles | async).length > 0" + [downloadedFiles]="downloadedFiles | async"> + </app-download-file-tab> <app-download [datasetSelected]="datasetSelected | async" [datasetList]="datasetList | async" @@ -33,7 +37,9 @@ [coneSearch]="coneSearch | async" [dataLength]="dataLength | async" [sampRegistered]="sampRegistered | async" - (broadcast)="broadcastVotable($event)"> + (broadcast)="broadcastVotable($event)" + (startTaskCreateResult)="startTaskCreateResult($event)" + (startTaskCreateArchive)="startTaskCreateArchive($event)"> </app-download> <app-reminder [datasetSelected]="datasetSelected | async" @@ -77,7 +83,10 @@ (retrieveData)="retrieveData($event)" (addSelectedData)="addSearchData($event)" (deleteSelectedData)="deleteSearchData($event)" - (broadcast)="broadcastVotable($event)"> + (broadcast)="broadcastVotable($event)" + (startTaskCreateResult)="startTaskCreateResult($event)" + (startTaskCreateArchive)="startTaskCreateArchive($event)" + (downloadFile)="downloadFile($event)"> </app-datatable-tab> </ng-container> </div> diff --git a/client/src/app/instance/search/containers/result.component.ts b/client/src/app/instance/search/containers/result.component.ts index 599e5cb56a342dc18cb8fae409fc70da7e8a2831..cc058ea450f4445bd295363a7fe2b2a25dee5f34 100644 --- a/client/src/app/instance/search/containers/result.component.ts +++ b/client/src/app/instance/search/containers/result.component.ts @@ -13,13 +13,15 @@ import { Store } from '@ngrx/store'; import { Observable, Subscription } from 'rxjs'; import { AbstractSearchComponent } from './abstract-search.component'; -import { Pagination } from '../../store/models'; +import { Pagination, DownloadFile } from '../../store/models'; import { Instance } from 'src/app/metamodel/models'; import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector'; import * as searchActions from '../../store/actions/search.actions'; import * as searchSelector from '../../store/selectors/search.selector'; import * as sampActions from '../../store/actions/samp.actions'; import * as sampSelector from '../../store/selectors/samp.selector'; +import * as downloadFileActions from '../../store/actions/download-file.actions'; +import * as downloadFileSelector from '../../store/selectors/download-file.selector'; /** * @class @@ -42,6 +44,7 @@ export class ResultComponent extends AbstractSearchComponent { public dataIsLoaded: Observable<boolean>; public selectedData: Observable<any>; public sampRegistered: Observable<boolean>; + public downloadedFiles: Observable<DownloadFile[]>; public pristineSubscription: Subscription; constructor(protected store: Store<{ }>) { @@ -55,6 +58,7 @@ export class ResultComponent extends AbstractSearchComponent { this.dataIsLoaded = this.store.select(searchSelector.selectDataIsLoaded); this.selectedData = this.store.select(searchSelector.selectSelectedData); this.sampRegistered = this.store.select(sampSelector.selectRegistered); + this.downloadedFiles = this.store.select(downloadFileSelector.selectDownloadedFiles); } ngOnInit(): void { @@ -120,6 +124,32 @@ export class ResultComponent extends AbstractSearchComponent { this.store.dispatch(searchActions.deleteSelectedData({ id })); } + /** + * Dispatches action to starts downloading file. + * + * @param {url: string, fileId: string, datasetName: string, filename: string} download - Info about file to download + */ + downloadFile(download: {url: string, fileId: string, datasetName: string, filename: string}): void { + this.store.dispatch(downloadFileActions.downloadFile(download)); + } + + /** + * Dispatches action to starts task create result and download + * + * @param string format - Info about result format + */ + startTaskCreateResult(event: { format: string, selectedData: boolean }) { + this.store.dispatch(downloadFileActions.startTaskCreateResult(event)); + } + + /** + * Dispatches action to starts task create archive and download + * + */ + startTaskCreateArchive(event: { selectedData: boolean }) { + this.store.dispatch(downloadFileActions.startTaskCreateArchive(event)); + } + /** * Dispatches action to destroy search results. */ diff --git a/client/src/app/instance/search/services/download.service.ts b/client/src/app/instance/search/services/download.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fa8241df91a05991141027afa35385b51ee01f8 --- /dev/null +++ b/client/src/app/instance/search/services/download.service.ts @@ -0,0 +1,13 @@ +import { HttpClient } from "@angular/common/http"; +import { Observable } from "rxjs"; + +export class DownloadService { + constructor(private http: HttpClient) { } + + download(url: string): Observable<Blob> { + return this.http.get(url, { + reportProgress: true, + responseType: 'blob' + }); + } +} diff --git a/client/src/app/instance/store/actions/download-file.actions.ts b/client/src/app/instance/store/actions/download-file.actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..8368ed9207b145ecd1970f5d63569c06afd35270 --- /dev/null +++ b/client/src/app/instance/store/actions/download-file.actions.ts @@ -0,0 +1,28 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createAction, props } from '@ngrx/store'; + +export const startTaskCreateResult = createAction('[File] Start Task Create Result', props<{ format: string, selectedData: boolean }>()); +export const startTaskCreateResultSuccess = createAction('[File] Start Task Create Result Success', props<{ fileId: string, datasetName: string, filename: string }>()); +export const startTaskCreateResultFail = createAction('[File] Start Task Create Result Fail'); +export const isResultAvailable = createAction('[File] Is Result Available', props<{ fileId: string, datasetName: string, filename: string }>()); +export const isResultAvailableFail = createAction('[File] Is Result Available Fail'); + +export const startTaskCreateArchive = createAction('[File] Start Task Create Archive', props<{ selectedData: boolean }>()); +export const startTaskCreateArchiveSuccess = createAction('[File] Start Task Create Archive Success', props<{ fileId: string, datasetName: string, filename: string }>()); +export const startTaskCreateArchiveFail = createAction('[File] Start Task Create Archive Fail'); +export const isArchiveAvailable = createAction('[File] Is Archive Available', props<{ fileId: string, datasetName: string, filename: string }>()); +export const isArchiveAvailableFail = createAction('[File] Is Archive Available Fail'); + +export const downloadFile = createAction('[File] Download File', props<{ url: string, fileId: string, datasetName: string, filename: string }>()); + +export const startsDownloadingFile = createAction('[File] Starts Downloading File', props<{ url: string, filename: string, fileId: string }>()); +export const updateDownloadProgress = createAction('[File] Update Download Progress', props<{ progress: number, fileId: string }>()); +export const fileDownloaded = createAction('[File] File Downloaded', props<{ fileId: string }>()); diff --git a/client/src/app/instance/store/effects/detail.effects.ts b/client/src/app/instance/store/effects/detail.effects.ts index 941f318d5ab5563666b0bbbc78334f47de607d15..b1f2aa5f2bfec3d2c09c3aa7055ee309655cafff 100644 --- a/client/src/app/instance/store/effects/detail.effects.ts +++ b/client/src/app/instance/store/effects/detail.effects.ts @@ -10,7 +10,7 @@ import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType, concatLatestFrom } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; +import { Store } from '@ngrx/store'; import { of } from 'rxjs'; import { map, tap, mergeMap, catchError } from 'rxjs/operators'; import { ToastrService } from 'ngx-toastr'; diff --git a/client/src/app/instance/store/effects/download-file.effects.ts b/client/src/app/instance/store/effects/download-file.effects.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7caa91165f2ff0a6bc6144714109e2b8c1fe140 --- /dev/null +++ b/client/src/app/instance/store/effects/download-file.effects.ts @@ -0,0 +1,259 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Injectable } from '@angular/core'; +import { HttpEventType } from '@angular/common/http'; + +import { Actions, createEffect, ofType, concatLatestFrom } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { of, timer, mapTo, takeUntil, Subject } from 'rxjs'; +import { map, tap, mergeMap, switchMap, catchError } from 'rxjs/operators'; +import { ToastrService } from 'ngx-toastr'; + +import { AppConfigService } from 'src/app/app-config.service'; +import { DownloadFileService } from '../services/download-file.service'; +import * as downloadFileActions from '../actions/download-file.actions'; +import * as searchSelector from '../selectors/search.selector'; +import * as attributeSelector from 'src/app/metamodel/selectors/attribute.selector'; +import * as coneSearchSelector from '../selectors/cone-search.selector'; + +/** + * @class + * @classdesc File effects. + */ +@Injectable() +export class DownloadFileEffects { + startTaskCreateResult$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.startTaskCreateResult), + concatLatestFrom(() => [ + this.store.select(searchSelector.selectCurrentDataset), + this.store.select(attributeSelector.selectAllAttributes), + this.store.select(searchSelector.selectCriteriaListByRoute), + this.store.select(coneSearchSelector.selectConeSearchByRoute), + this.store.select(searchSelector.selectOutputListByRoute), + this.store.select(searchSelector.selectSelectedData) + ]), + mergeMap(([action, currentDataset, attributeList, criteriaList, coneSearch, outputList, selectedData]) => { + const attributeId = attributeList.find(a => a.search_flag === 'ID'); + let query: string = `${currentDataset}?a=${outputList}`; + if (criteriaList) { + query += `&c=${criteriaList}`; + if (selectedData && action.selectedData) { + query += `;${attributeId.id}::in::${selectedData.join('|')}`; + } + } else if (selectedData && action.selectedData) { + query += `&c=${attributeId.id}::in::${selectedData.join('|')}`; + } + if (coneSearch) { + query += `&cs=${coneSearch}`; + } + query += `&f=${action.format}`; + + return this.downloadFileService.startTaskCreateResult(query) + .pipe( + map((response) => downloadFileActions.startTaskCreateResultSuccess({ + fileId: response.file_id, + filename: response.file_name, + datasetName: currentDataset + })), + catchError(() => of(downloadFileActions.startTaskCreateResultFail())) + ) + }) + ) + ); + + startTaskCreateResultSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.startTaskCreateResultSuccess), + tap(action => this.kill$[action.fileId] = new Subject()), + switchMap(action => timer(0, 1000) + .pipe( + mapTo(downloadFileActions.isResultAvailable(action)), + takeUntil(this.kill$[action.fileId]) + ) + ) + ) + ); + + startTaskCreateResultFail$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.startTaskCreateResultFail), + tap(() => this.toastr.error('The creation of the result file failed', 'Start async task failed')) + ), { dispatch: false} + ); + + isResultAvailable$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.isResultAvailable), + switchMap(action => this.downloadFileService.isResultAvailable(action.fileId) + .pipe( + map(result => { + if (result.file_is_available) { + this.kill$[action.fileId].next({}); + this.kill$[action.fileId].unsubscribe(); + return downloadFileActions.startsDownloadingFile({ + fileId: action.fileId, + filename: action.filename, + url: `${this.config.apiUrl}/download-result/${action.datasetName}/${action.fileId}` + }); + } else { + return { type: '[No Action] Is Result Available' }; + } + }), + catchError(() => of(downloadFileActions.isResultAvailableFail())) + ) + ) + ) + ); + + isResultAvailableFail$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.isResultAvailableFail), + tap(() => this.toastr.error('The creation of the file has encountered a problem', 'File result download failed')) + ), { dispatch: false} + ); + + startTaskCreateArchive$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.startTaskCreateArchive), + concatLatestFrom(() => [ + this.store.select(searchSelector.selectCurrentDataset), + this.store.select(attributeSelector.selectAllAttributes), + this.store.select(searchSelector.selectCriteriaListByRoute), + this.store.select(coneSearchSelector.selectConeSearchByRoute), + this.store.select(searchSelector.selectOutputListByRoute), + this.store.select(searchSelector.selectSelectedData) + ]), + mergeMap(([action, currentDataset, attributeList, criteriaList, coneSearch, outputList, selectedData]) => { + const attributeId = attributeList.find(a => a.search_flag === 'ID'); + let query: string = `${currentDataset}?a=${outputList}`; + if (criteriaList) { + query += `&c=${criteriaList}`; + if (selectedData && action.selectedData) { + query += `;${attributeId.id}::in::${selectedData.join('|')}`; + } + } else if (selectedData && action.selectedData) { + query += `&c=${attributeId.id}::in::${selectedData.join('|')}`; + } + if (coneSearch) { + query += `&cs=${coneSearch}`; + } + + return this.downloadFileService.startTaskCreateArchive(query) + .pipe( + map((response) => downloadFileActions.startTaskCreateArchiveSuccess({ + fileId: response.archive_id, + filename: response.archive_name, + datasetName: currentDataset + })), + catchError(() => of(downloadFileActions.startTaskCreateArchiveFail())) + ) + }) + ) + ); + + startTaskCreateArchiveSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.startTaskCreateArchiveSuccess), + tap(action => this.kill$[action.fileId] = new Subject()), + switchMap(action => timer(0, 1000) + .pipe( + mapTo(downloadFileActions.isArchiveAvailable(action)), + takeUntil(this.kill$[action.fileId]) + ) + ) + ) + ); + + startTaskCreateArchiveFail$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.startTaskCreateArchiveFail), + tap(() => this.toastr.error('The creation of the archive file failed', 'Start async task failed')) + ), { dispatch: false} + ); + + isArchiveAvailable$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.isArchiveAvailable), + switchMap(action => this.downloadFileService.isArchiveAvailable(action.fileId) + .pipe( + map(result => { + if (result.archive_is_available) { + this.kill$[action.fileId].next({}); + this.kill$[action.fileId].unsubscribe(); + return downloadFileActions.startsDownloadingFile({ + fileId: action.fileId, + filename: action.filename, + url: `${this.config.apiUrl}/download-archive/${action.datasetName}/${action.fileId}` + }); + } else { + return { type: '[No Action] Is Archive Available' }; + } + }), + catchError(() => of(downloadFileActions.isArchiveAvailableFail())) + ) + ) + ) + ); + + isArchiveAvailableFail$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.isArchiveAvailableFail), + tap(() => this.toastr.error('The creation of the archive has encountered a problem', 'Archive result download failed')) + ), { dispatch: false} + ); + + downloadFile$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.downloadFile), + map(action => downloadFileActions.startsDownloadingFile({ + url: action.url, + fileId: action.fileId, + filename: action.filename + })) + ) + ); + + /** + * Calls actions to retrieve object. + */ + startsDownloadingFile$ = createEffect(() => + this.actions$.pipe( + ofType(downloadFileActions.startsDownloadingFile), + mergeMap((action) => this.downloadFileService.startsDownloadingFile(action.url) + .pipe( + map(event => { + if (event.type === HttpEventType.DownloadProgress) { + this.store.dispatch(downloadFileActions.updateDownloadProgress({ + progress: Math.round((100 * event.loaded) / event.total), + fileId: action.fileId + })); + } + + if (event.type === HttpEventType.Response) { + this.store.dispatch(downloadFileActions.fileDownloaded({ fileId: action.fileId })); + this.downloadFileService.saveDownloadedFile(event.body as Blob, action.filename); + } + }) + ) + ) + ), { dispatch: false } + ); + + private kill$ = []; + + constructor( + private actions$: Actions, + private downloadFileService: DownloadFileService, + private store: Store<{ }>, + private toastr: ToastrService, + private config: AppConfigService + ) {} +} diff --git a/client/src/app/instance/store/effects/index.ts b/client/src/app/instance/store/effects/index.ts index 69b9a3b26cc0cd09cc16e974cbb4ddc4102571b9..18cb99de4d7126d13194355146ddacf39b3c8ae5 100644 --- a/client/src/app/instance/store/effects/index.ts +++ b/client/src/app/instance/store/effects/index.ts @@ -4,6 +4,7 @@ import { SearchMultipleEffects } from './search-multiple.effects'; import { ConeSearchEffects } from './cone-search.effects'; import { DetailEffects } from './detail.effects'; import { SvomJsonKwEffects } from './svom-json-kw.effects'; +import { DownloadFileEffects } from './download-file.effects'; export const instanceEffects = [ SampEffects, @@ -11,5 +12,6 @@ export const instanceEffects = [ SearchMultipleEffects, ConeSearchEffects, DetailEffects, - SvomJsonKwEffects + SvomJsonKwEffects, + DownloadFileEffects ]; diff --git a/client/src/app/instance/store/models/download-file.model.ts b/client/src/app/instance/store/models/download-file.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d21ff574b88548eb8067f65b2b617377bba56f9 --- /dev/null +++ b/client/src/app/instance/store/models/download-file.model.ts @@ -0,0 +1,16 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export interface DownloadFile { + id: string; + datasetName: string; + fileName: string; + state: 'PENDING' | 'IN_PROGRESS' | 'DONE'; + progress: number; +} diff --git a/client/src/app/instance/store/models/index.ts b/client/src/app/instance/store/models/index.ts index 2570f1630cacd2de9afd9bda713f92230d6b8f22..3b2241bcfbdd3629726f8766f5220e0b74b9ccf1 100644 --- a/client/src/app/instance/store/models/index.ts +++ b/client/src/app/instance/store/models/index.ts @@ -7,4 +7,5 @@ export * from './cone-search.model'; export * from './resolver.model'; export * from './search-multiple-dataset-length'; export * from './search-multiple-dataset-data'; -export * from './svom-keyword.model'; \ No newline at end of file +export * from './svom-keyword.model'; +export * from './download-file.model'; diff --git a/client/src/app/instance/store/reducers/download-file.reducer.ts b/client/src/app/instance/store/reducers/download-file.reducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..64daf37e3bad68eede73d702c032511b9b7a43ae --- /dev/null +++ b/client/src/app/instance/store/reducers/download-file.reducer.ts @@ -0,0 +1,83 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createReducer, on } from '@ngrx/store'; + +import { DownloadFile } from '../models'; +import * as downloadFileActions from '../actions/download-file.actions'; + +/** + * Interface for file state. + * + * @interface State + */ +export interface State { + downloadedFiles: DownloadFile[] +} + +export const initialState: State = { + downloadedFiles: [] +}; + +export const fileReducer = createReducer( + initialState, + on(downloadFileActions.startTaskCreateResultSuccess, (state, {fileId, datasetName, filename }) => ({ + ...state, + downloadedFiles: [...state.downloadedFiles, { + id: fileId, + datasetName, + fileName: filename, + state: 'PENDING', + progress: 0 + }] + })), + on(downloadFileActions.startTaskCreateArchiveSuccess, (state, {fileId, datasetName, filename }) => ({ + ...state, + downloadedFiles: [...state.downloadedFiles, { + id: fileId, + datasetName, + fileName: filename, + state: 'PENDING', + progress: 0 + }] + })), + on(downloadFileActions.downloadFile, (state, {fileId, datasetName, filename }) => ({ + ...state, + downloadedFiles: [...state.downloadedFiles, { + id: fileId, + datasetName, + fileName: filename, + state: 'PENDING', + progress: 0 + }] + })), + on(downloadFileActions.updateDownloadProgress, (state, { progress, fileId }) => ({ + ...state, + downloadedFiles: [ + ...state.downloadedFiles.filter(f => f.id !== fileId), + { + ...state.downloadedFiles.find(f => f.id === fileId), + progress: progress + } + ] + })), + on(downloadFileActions.fileDownloaded, (state, { fileId }) => ({ + ...state, + downloadedFiles: [ + ...state.downloadedFiles.filter(f => f.id !== fileId), + { + ...state.downloadedFiles.find(f => f.id === fileId), + state: 'DONE', + progress: 100 + } + ] + })) +); + +export const selectDownloadedFiles = (state: State) => state.downloadedFiles; \ No newline at end of file diff --git a/client/src/app/instance/store/selectors/download-file.selector.ts b/client/src/app/instance/store/selectors/download-file.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..b955521d86de25bbf5c6eef631a4b77e4f2bd982 --- /dev/null +++ b/client/src/app/instance/store/selectors/download-file.selector.ts @@ -0,0 +1,23 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createSelector } from '@ngrx/store'; + +import * as reducer from '../../instance.reducer'; +import * as fromDownloadFile from '../reducers/download-file.reducer'; + +export const selectDownloadFileState = createSelector( + reducer.getInstanceState, + (state: reducer.State) => state.downloadFile +); + +export const selectDownloadedFiles = createSelector( + selectDownloadFileState, + fromDownloadFile.selectDownloadedFiles +); diff --git a/client/src/app/instance/store/services/download-file.service.ts b/client/src/app/instance/store/services/download-file.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..25c26182e9f69d10ee75b025fde4cbed781d3a91 --- /dev/null +++ b/client/src/app/instance/store/services/download-file.service.ts @@ -0,0 +1,61 @@ +/** + * This file is part of Anis Client. + * + * @copyright Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Injectable } from '@angular/core'; +import { HttpClient, HttpRequest } from '@angular/common/http'; + +import { AppConfigService } from 'src/app/app-config.service'; + +@Injectable({providedIn: 'root'}) +export class DownloadFileService { + constructor(private http: HttpClient, private config: AppConfigService) { } + + /** + * Returns file name and file ID + * + * @param {string} query - The query. + * + * @return Observable<{"file_name": string, "file_id": string}> + */ + startTaskCreateResult(query: string) { + return this.http.get<{"file_name": string, "file_id": string}>(`${this.config.apiUrl}/start-task-create-result/${query}`); + } + + isResultAvailable(id: string) { + return this.http.get<{"file_is_available": boolean}>(`${this.config.apiUrl}/is-result-available/${id}`); + } + + startTaskCreateArchive(query: string) { + return this.http.get<{"archive_name": string, "archive_id": string}>(`${this.config.apiUrl}/start-task-create-archive/${query}`); + } + + isArchiveAvailable(id: string) { + return this.http.get<{"archive_is_available": boolean}>(`${this.config.apiUrl}/is-archive-available/${id}`); + } + + startsDownloadingFile(url: string) { + const request = new HttpRequest( + "GET", + url, + {}, + { reportProgress: true, responseType: 'blob' } + ); + + return this.http.request(request); + } + + saveDownloadedFile(body: Blob, filename: string) { + let downloadLink = document.createElement('a'); + const url = window.URL.createObjectURL(body); + downloadLink.href = url; + downloadLink.setAttribute('download', filename); + downloadLink.click(); + window.URL.revokeObjectURL(url); + } +} \ No newline at end of file diff --git a/client/src/app/instance/store/services/index.ts b/client/src/app/instance/store/services/index.ts index 795315e994b19b011ba88241a4428380e6932c09..d70ddbf82870dc1059472f410111eb4979b00052 100644 --- a/client/src/app/instance/store/services/index.ts +++ b/client/src/app/instance/store/services/index.ts @@ -3,11 +3,13 @@ import { SampService } from './samp.service'; import { ConeSearchService } from './cone-search.service'; import { DetailService } from './detail.service'; import { SvomJsonKwService } from './svom-json-kw.service'; +import { DownloadFileService } from './download-file.service'; export const instanceServices = [ SearchService, SampService, ConeSearchService, DetailService, - SvomJsonKwService + SvomJsonKwService, + DownloadFileService ]; diff --git a/client/src/app/instance/store/services/samp.service.ts b/client/src/app/instance/store/services/samp.service.ts index ebc95cbed6a0e343ba7bd671ba38256c39b96ea3..3dea465b139563c25674109a46b0d6cc5b179324 100644 --- a/client/src/app/instance/store/services/samp.service.ts +++ b/client/src/app/instance/store/services/samp.service.ts @@ -24,7 +24,10 @@ export class SampService { private connector = null; constructor(private config: AppConfigService) { - const baseUrl = `${window.location.protocol}//${window.location.host}${this.config.baseHref}`; + let baseUrl = `${window.location.protocol}//${window.location.host}`; + if (this.config.baseHref !== '/') { + baseUrl += this.config.baseHref; + } const meta = { "samp.name": "ANIS", "samp.description.text": "AstroNomical Information System", diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index bfb8594e449030fea934eab9a1143c8ead325adb..5455dacaf03013492277fba8166c698db0219f76 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -20,6 +20,7 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip'; import { BsDatepickerModule } from 'ngx-bootstrap/datepicker'; import { TabsModule } from 'ngx-bootstrap/tabs'; import { PaginationModule } from 'ngx-bootstrap/pagination'; +import { ProgressbarModule } from 'ngx-bootstrap/progressbar'; import { NgSelectModule } from '@ng-select/ng-select'; import { NgxJsonViewerModule } from 'ngx-json-viewer'; @@ -48,6 +49,7 @@ import { sharedPipes } from './pipes'; BsDatepickerModule.forRoot(), TabsModule.forRoot(), PaginationModule.forRoot(), + ProgressbarModule.forRoot(), NgSelectModule, NgxJsonViewerModule ], @@ -63,6 +65,7 @@ import { sharedPipes } from './pipes'; BsDatepickerModule, TabsModule, PaginationModule, + ProgressbarModule, NgSelectModule, NgxJsonViewerModule, sharedComponents, diff --git a/conf-dev/dev-php.ini b/conf-dev/dev-php.ini index b874d37d810aa500eff9bafac028c145ddbac783..df289dd8b59301ac4f8ad6f4147b2a102e3e4a41 100644 --- a/conf-dev/dev-php.ini +++ b/conf-dev/dev-php.ini @@ -12,7 +12,7 @@ max_execution_time = 30 max_input_time = 60 [xdebug] -zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20200930/xdebug.so +zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20210902/xdebug.so xdebug.mode=debug xdebug.client_host=127.0.0.1 xdebug.client_port="9003" diff --git a/docker-compose.yml b/docker-compose.yml index c9609ca0e93056d9ca5801bb0646fa50c8321637..463a5f3c4884b73bbc4071b189d108caa672c1c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,10 @@ services: TOKEN_ENABLED: 0 TOKEN_JWKS_URL: "http://keycloak:8180/auth/realms/anis/protocol/openid-connect/certs" TOKEN_ADMIN_ROLES: anis_admin,superuser + RMQ_HOST: rmq + RMQ_PORT: 5672 + RMQ_USER: admin + RMQ_PASSWORD: admin ports: - 8080:80 volumes: @@ -59,6 +63,24 @@ services: - ./services:/project - ./data:/data command: /bin/bash -c "source /project/venv/bin/activate && exec python src/anis_services/app.py" + tasks: + image: python:3.8 + # stdin_open: true + # tty: true + # command: sleep infinity + environment: + PYTHONPATH: "/project/src" + DATA_PATH: /data + SERVER_URL: http://server + RMQ_HOST: rmq + RMQ_PORT: 5672 + RMQ_USER: admin + RMQ_PASSWORD: admin + working_dir: /project + volumes: + - ./tasks:/project + - ./data:/data + command: /bin/bash -c "source /project/venv/bin/activate && exec python src/anis_tasks/app.py" keycloak: image: jboss/keycloak environment: @@ -84,6 +106,13 @@ services: - pgdata:/var/lib/postgresql/data - ./conf-dev/data_test.sql:/sql/data_test.sql - ./conf-dev/init-postgres.sh:/docker-entrypoint-initdb.d/init-postgres.sh + rmq: + image: rabbitmq:3-management + environment: + RABBITMQ_DEFAULT_USER: admin + RABBITMQ_DEFAULT_PASS: admin + ports: + - 15672:15672 mailer: image: djfarrelly/maildev ports: diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index a8d54a7eb2f5ed8a2ad4649f6c0509f76a3350cb..d770328e0b8bb0879c8936be5777582e0d68d75c 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -3,7 +3,7 @@ FROM php:8.1.3-apache # Install modules RUN apt-get update \ && apt-get install -y zlib1g zlib1g-dev libpq-dev libpq5 libzip-dev zip unzip jq \ - && docker-php-ext-install pgsql pdo_pgsql zip bcmath + && docker-php-ext-install pgsql pdo_pgsql zip bcmath sockets # Install pecl modules RUN pecl install xdebug && rm -rf /tmp/pear diff --git a/server/app/dependencies.php b/server/app/dependencies.php index 9fe363d692e79627b5dc6aea9e37acf9bce6371c..c8d86647e3a01033a1a103d15181b68dc6a72539 100644 --- a/server/app/dependencies.php +++ b/server/app/dependencies.php @@ -51,6 +51,17 @@ $container->set('logger', function (ContainerInterface $c) { return $logger; }); +// RabbitMQ connection factory +$container->set('rmq', function (ContainerInterface $c) { + $rmqSettings = $c->get('settings')['rmq']; + return new \PhpAmqpLib\Connection\AMQPStreamConnection( + $rmqSettings['host'], + $rmqSettings['port'], + $rmqSettings['user'], + $rmqSettings['password'] + ); +}); + // Actions $container->set('App\Action\RootAction', function () { return new App\Action\RootAction(); @@ -200,22 +211,28 @@ $container->set('App\Action\SearchAction', function (ContainerInterface $c) { ); }); -$container->set('App\Action\ArchiveAction', function (ContainerInterface $c) { - $anisQueryBuilder = (new App\Search\Query\AnisQueryBuilder()) - ->addQueryPart(new App\Search\Query\From()) - ->addQueryPart(new App\Search\Query\Select()) - ->addQueryPart(new App\Search\Query\ConeSearch()) - ->addQueryPart(new App\Search\Query\Where(new App\Search\Query\Operator\OperatorFactory())) - ->addQueryPart(new App\Search\Query\Order()) - ->addQueryPart(new App\Search\Query\Limit()); +$container->set('App\Action\StartTaskCreateResultAction', function (ContainerInterface $c) { + return new App\Action\StartTaskCreateResultAction($c->get('em'), $c->get('rmq'), $c->get(SETTINGS)['token']); +}); - return new App\Action\ArchiveAction( - $c->get('em'), - $c->get('settings')['data_path'], - new App\Search\DBALConnectionFactory(), - $anisQueryBuilder, - $c->get(SETTINGS)['token'] - ); +$container->set('App\Action\IsResultAvailableAction', function (ContainerInterface $c) { + return new App\Action\IsResultAvailableAction($c->get('em'), $c->get('settings')['data_path'], $c->get(SETTINGS)['token']); +}); + +$container->set('App\Action\DownloadResultAction', function (ContainerInterface $c) { + return new App\Action\DownloadResultAction($c->get('em'), $c->get('settings')['data_path'], $c->get(SETTINGS)['token']); +}); + +$container->set('App\Action\StartTaskCreateArchiveAction', function (ContainerInterface $c) { + return new App\Action\StartTaskCreateArchiveAction($c->get('em'), $c->get('rmq'), $c->get(SETTINGS)['token']); +}); + +$container->set('App\Action\IsArchiveAvailableAction', function (ContainerInterface $c) { + return new App\Action\IsArchiveAvailableAction($c->get('em'), $c->get('settings')['data_path'], $c->get(SETTINGS)['token']); +}); + +$container->set('App\Action\DownloadArchiveAction', function (ContainerInterface $c) { + return new App\Action\DownloadArchiveAction($c->get('em'), $c->get('settings')['data_path'], $c->get(SETTINGS)['token']); }); $container->set('App\Action\DatasetFileExplorerAction', function (ContainerInterface $c) { diff --git a/server/app/routes.php b/server/app/routes.php index 75d7a4fe2173c45f1d8aa8707587883a4dbb1e9f..a8aa5536e8b9fdb2cbf044ff00082091a3b224e0 100644 --- a/server/app/routes.php +++ b/server/app/routes.php @@ -15,6 +15,7 @@ use Slim\Routing\RouteCollectorProxy; $app->get('/', App\Action\RootAction::class); $app->get('/client-settings', App\Action\ClientSettingsAction::class); +// Metamodel actions $app->group('', function (RouteCollectorProxy $group) { $group->map([OPTIONS, GET, POST], '/select', App\Action\SelectListAction::class); $group->map([OPTIONS, GET, PUT, DELETE], '/select/{name}', App\Action\SelectAction::class); @@ -30,6 +31,7 @@ $app->group('', function (RouteCollectorProxy $group) { explode(',', $container->get(SETTINGS)['token']['admin_roles']) )); +// Metamodel actions $app->group('', function (RouteCollectorProxy $group) { $group->map([OPTIONS, GET, POST], '/survey', App\Action\SurveyListAction::class); $group->map([OPTIONS, GET, PUT, DELETE], '/survey/{name}', App\Action\SurveyAction::class); @@ -67,8 +69,19 @@ $app->group('', function (RouteCollectorProxy $group) { explode(',', $container->get(SETTINGS)['token']['admin_roles']) )); +// Search actions $app->get('/search/{dname}', App\Action\SearchAction::class); -$app->get('/archive/{dname}', App\Action\ArchiveAction::class); + +$app->get('/start-task-create-result/{dname}', App\Action\StartTaskCreateResultAction::class); +$app->get('/is-result-available/{id}', App\Action\IsResultAvailableAction::class); +$app->get('/download-result/{dname}/{id}', App\Action\DownloadResultAction::class); + +// Archive actions +$app->get('/start-task-create-archive/{dname}', App\Action\StartTaskCreateArchiveAction::class); +$app->get('/is-archive-available/{id}', App\Action\IsArchiveAvailableAction::class); +$app->get('/download-archive/{dname}/{id}', App\Action\DownloadArchiveAction::class); + +// Explore and download individual files $app->get('/dataset-file-explorer/{dname}[{fpath:.*}]', App\Action\DatasetFileExplorerAction::class); $app->get('/download-instance-file/{iname}/[{fpath:.*}]', App\Action\DownloadInstanceFileAction::class); $app->get('/download-file/{dname}/[{fpath:.*}]', App\Action\DownloadFileAction::class); diff --git a/server/app/settings.php b/server/app/settings.php index 879fe9dba2e308f1718d682d40e43fe2b5198d1f..b48111067021ea61b843264f4d3f18a5a3e2926f 100644 --- a/server/app/settings.php +++ b/server/app/settings.php @@ -42,5 +42,11 @@ return [ 'enabled' => getenv('TOKEN_ENABLED'), 'jwks_url' => getenv('TOKEN_JWKS_URL'), 'admin_roles' => getenv('TOKEN_ADMIN_ROLES') + ], + 'rmq' => [ + 'host' => getenv('RMQ_HOST'), + 'port' => getenv('RMQ_PORT'), + 'user' => getenv('RMQ_USER'), + 'password' => getenv('RMQ_PASSWORD') ] ]; diff --git a/server/composer.json b/server/composer.json index 714fa1091c7e8b5c96f8f213cf815b628e80b5ec..97ffeff07e9b3a987862f1426fd48902bc287e09 100644 --- a/server/composer.json +++ b/server/composer.json @@ -26,7 +26,8 @@ "doctrine/orm": "^2.11", "doctrine/annotations": "^1.13", "symfony/cache": "^6.0", - "firebase/php-jwt": "^5.5" + "firebase/php-jwt": "^5.5", + "php-amqplib/php-amqplib": "^3.1" }, "require-dev": { "phpunit/phpunit": "^9.5" diff --git a/server/composer.lock b/server/composer.lock index a7b99df9c6b59e4f00ed6284b553bc5c31b583d7..7ad0d075d61012c6b4c58df93d14064ad59e7c53 100644 --- a/server/composer.lock +++ b/server/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "72ae7c376a55611a0ac3fdd5ba134695", + "content-hash": "59a7af8c3869283b682223ccfc159cb8", "packages": [ { "name": "doctrine/annotations", @@ -1421,6 +1421,204 @@ }, "time": "2022-01-27T09:35:39+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.5.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "9229e15f2e6ba772f0c55dd6986c563b937170a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/9229e15f2e6ba772f0c55dd6986c563b937170a8", + "reference": "9229e15f2e6ba772f0c55dd6986c563b937170a8", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2022-01-17T05:32:27+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "php-amqplib/php-amqplib", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/php-amqplib/php-amqplib.git", + "reference": "e8aba06c4e1c467612f2d99304f672f2660e8492" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-amqplib/php-amqplib/zipball/e8aba06c4e1c467612f2d99304f672f2660e8492", + "reference": "e8aba06c4e1c467612f2d99304f672f2660e8492", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-sockets": "*", + "php": "^7.1||^8.0", + "phpseclib/phpseclib": "^2.0|^3.0" + }, + "conflict": { + "php": "7.4.0 - 7.4.1" + }, + "replace": { + "videlalvaro/php-amqplib": "self.version" + }, + "require-dev": { + "ext-curl": "*", + "nategood/httpful": "^0.2.20", + "phpunit/phpunit": "^7.5|^9.5", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpAmqpLib\\": "PhpAmqpLib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Alvaro Videla", + "role": "Original Maintainer" + }, + { + "name": "Raúl Araya", + "email": "nubeiro@gmail.com", + "role": "Maintainer" + }, + { + "name": "Luke Bakken", + "email": "luke@bakken.io", + "role": "Maintainer" + }, + { + "name": "Ramūnas Dronga", + "email": "github@ramuno.lt", + "role": "Maintainer" + } + ], + "description": "Formerly videlalvaro/php-amqplib. This library is a pure PHP implementation of the AMQP protocol. It's been tested against RabbitMQ.", + "homepage": "https://github.com/php-amqplib/php-amqplib/", + "keywords": [ + "message", + "queue", + "rabbitmq" + ], + "support": { + "issues": "https://github.com/php-amqplib/php-amqplib/issues", + "source": "https://github.com/php-amqplib/php-amqplib/tree/v3.1.2" + }, + "time": "2022-01-18T17:08:01+00:00" + }, { "name": "php-di/invoker", "version": "2.3.3", @@ -1648,6 +1846,117 @@ }, "time": "2015-12-19T14:08:53+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.13", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "1443ab79364eea48665fa8c09ac67f37d1025f7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/1443ab79364eea48665fa8c09ac67f37d1025f7e", + "reference": "1443ab79364eea48665fa8c09ac67f37d1025f7e", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phing/phing": "~2.7", + "phpunit/phpunit": "^5.7|^6.0|^9.4", + "squizlabs/php_codesniffer": "~2.0" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.13" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2022-01-30T08:50:05+00:00" + }, { "name": "psr/cache", "version": "3.0.0", diff --git a/server/src/Action/AbstractAction.php b/server/src/Action/AbstractAction.php index 894633a79f047e80993c76798eaed7fd30dc81a0..97d759652de9587d40658990d75d8678c91766cf 100644 --- a/server/src/Action/AbstractAction.php +++ b/server/src/Action/AbstractAction.php @@ -82,7 +82,7 @@ abstract class AbstractAction } } - private function isAdmin(array $adminRoles, $roles) + protected function isAdmin(array $adminRoles, $roles) { $admin = false; for ($i = 0; $i < count($adminRoles); $i++) { diff --git a/server/src/Action/ArchiveAction.php b/server/src/Action/ArchiveAction.php deleted file mode 100644 index b1c8548733f2a75f9efcb98e05bb223e9fae808d..0000000000000000000000000000000000000000 --- a/server/src/Action/ArchiveAction.php +++ /dev/null @@ -1,175 +0,0 @@ -<?php - -/* - * This file is part of Anis Server. - * - * (c) Laboratoire d'Astrophysique de Marseille / CNRS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -declare(strict_types=1); - -namespace App\Action; - -use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Http\Message\ResponseInterface as Response; -use Slim\Exception\HttpBadRequestException; -use Slim\Exception\HttpNotFoundException; -use Slim\Exception\HttpInternalServerErrorException; -use Doctrine\ORM\EntityManagerInterface; -use Nyholm\Psr7\Factory\Psr17Factory; -use App\Search\DBALConnectionFactory; -use App\Search\Query\AnisQueryBuilder; -use App\Search\Response\IResponseFactory; -use App\Search\SearchException; - -/** - * @author François Agneray <francois.agneray@lam.fr> - * @package App\Action - */ -final class ArchiveAction extends AbstractAction -{ - /** - * Contains anis-server data path - * - * @var string - */ - private $dataPath; - - /** - * @var DBALConnectionFactory - */ - private $connectionFactory; - - /** - * @var AnisQueryBuilder - */ - private $anisQueryBuilder; - - /** - * Contains settings to handle Json Web Token (app/settings.php) - * - * @var array - */ - private $settings; - - /** - * Create the classe before call __invoke to execute the action - * - * @param EntityManagerInterface $em Doctrine Entity Manager Interface - * @param DBALConnectionFactory $connectionFactory Factory used to construct connection to business database - * @param AnisQueryBuilder $anisQueryBuilder Object used to wrap the Doctrine DBAL Query Builder - * @param IResponseFactory $responseFactory Contains the factory used to return formatted response - * @param array $settings Settings about token - */ - public function __construct( - EntityManagerInterface $em, - string $dataPath, - DBALConnectionFactory $connectionFactory, - AnisQueryBuilder $anisQueryBuilder, - array $settings - ) { - parent::__construct($em); - $this->dataPath = $dataPath; - $this->connectionFactory = $connectionFactory; - $this->anisQueryBuilder = $anisQueryBuilder; - $this->settings = $settings; - } - - /** - * `GET` Returns the file found - * - * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request - * @param ResponseInterface $response PSR-7 This object represents the HTTP response - * @param string[] $args This table contains information transmitted in the URL (see routes.php) - * - * @return ResponseInterface - */ - public function __invoke(Request $request, Response $response, array $args): Response - { - if ($request->getMethod() === OPTIONS) { - return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - } - - // Search the correct dataset with primary key - $datasetName = $args['dname']; - $dataset = $this->em->find('App\Entity\Dataset', $datasetName); - - // If dataset is not found 404 - if (is_null($dataset)) { - throw new HttpNotFoundException( - $request, - 'Dataset with name ' . $datasetName . ' is not found' - ); - } - - // If dataset is private and authorization enabled - if (!$dataset->getPublic() && boolval($this->settings['enabled'])) { - $this->verifyDatasetAuthorization( - $request, - $dataset->getName(), - explode(',', $this->settings['admin_roles']) - ); - } - - $queryParams = $request->getQueryParams(); - - // The parameter "a" is mandatory - if (!array_key_exists('a', $queryParams)) { - throw new HttpBadRequestException( - $request, - 'Param a is required for this request' - ); - } - - try { - // Configure the Anis Query Builder - $connection = $this->connectionFactory->create($dataset->getSurvey()->getDatabase()); - $this->anisQueryBuilder->setDoctrineQueryBuilder($connection->createQueryBuilder()); - $this->anisQueryBuilder->setDatasetSelected($dataset); - - // Build the SQL request - $this->anisQueryBuilder->build($queryParams); - } catch (SearchException $e) { - throw new HttpBadRequestException( - $request, - $e->getMessage() - ); - } - - $zipFile = '/tmp/archive_' . $dataset->getName() . '_' . (new \DateTime())->format('Y-m-d\TH:i:s') . '.zip'; - $zip = new \ZipArchive(); - - if ($zip->open($zipFile, \ZipArchive::CREATE) !== true) { - throw new HttpInternalServerErrorException( - $request, - 'Unable to open the file ' . $zipFile - ); - } - - // Attributes with search_flag = File - $attributesSelected = $this->anisQueryBuilder->getAttributesSelected(); - $stmt = $this->anisQueryBuilder->getDoctrineQueryBuilder()->execute(); - while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - foreach ($attributesSelected as $attribute) { - $attributeLabel = $attribute->getLabel(); - $filePath = $this->dataPath . $dataset->getFullDataPath() . DIRECTORY_SEPARATOR . $row[$attributeLabel]; - if (file_exists($filePath) && is_file($filePath)) { - $zip->addFile($filePath, $row[$attributeLabel]); - } - } - } - - $zip->close(); - - // Stream created ZIP file - $psr17Factory = new Psr17Factory(); - $stream = $psr17Factory->createStreamFromFile($zipFile, 'r'); - - return $response->withBody($stream) - ->withHeader('Content-Disposition', 'attachment; filename=' . basename($zipFile) . ';') - ->withHeader('Content-Type', mime_content_type($zipFile)) - ->withHeader('Content-Length', filesize($zipFile)); - } -} diff --git a/server/src/Action/DatasetListByInstanceAction.php b/server/src/Action/DatasetListByInstanceAction.php index 127477909c668ea636c056f05ea4a085809695b8..efa9f06a45fd57b7bc3bcd167d2630bd5cfe8625 100644 --- a/server/src/Action/DatasetListByInstanceAction.php +++ b/server/src/Action/DatasetListByInstanceAction.php @@ -81,8 +81,9 @@ final class DatasetListByInstanceAction extends AbstractAction // If user is not connected return public datasets $qb->andWhere($qb->expr()->eq('d.public', 'true')); } else { + $adminRoles = explode(',', $this->settings['admin_roles']); $roles = $token->realm_access->roles; - if (!in_array($this->settings['admin_roles'], $roles)) { + if (!$this->isAdmin($adminRoles, $roles)) { // If user is not an admin return public datasets // And returns datasets from user's groups $qb->andWhere($qb->expr()->eq('d.public', 'true')); diff --git a/server/src/Action/DownloadArchiveAction.php b/server/src/Action/DownloadArchiveAction.php new file mode 100644 index 0000000000000000000000000000000000000000..b514baae074461c85c34952de4be6ea0fc573f51 --- /dev/null +++ b/server/src/Action/DownloadArchiveAction.php @@ -0,0 +1,111 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Doctrine\ORM\EntityManagerInterface; +use Slim\Exception\HttpNotFoundException; +use Nyholm\Psr7\Factory\Psr17Factory; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class DownloadArchiveAction extends AbstractAction +{ + /** + * Contains anis-server data path + * + * @var string + */ + private $dataPath; + + /** + * Contains settings to handle Json Web Token + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param string $dataPath Contains anis-server data path + * @param array $settings Settings about token + */ + public function __construct(EntityManagerInterface $em, string $dataPath, array $settings) + { + parent::__construct($em); + $this->dataPath = $dataPath; + $this->settings = $settings; + } + + /** + * `GET` Returns the file found + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + // Search the correct dataset with primary key + $dataset = $this->em->find('App\Entity\Dataset', $args['dname']); + + // If dataset is not found 404 + if (is_null($dataset)) { + throw new HttpNotFoundException( + $request, + 'Dataset with name ' . $args['dname'] . ' is not found' + ); + } + + // If dataset is private and authorization enabled + if (!$dataset->getPublic() && boolval($this->settings['enabled'])) { + $this->verifyDatasetAuthorization( + $request, + $dataset->getName(), + explode(',', $this->settings['admin_roles']) + ); + } + + // Search the file + $archiveId = $args['id']; + $filePath = $this->dataPath . '/ARCHIVE/' . $archiveId . '.zip'; + + // If the file not found 404 + if (!file_exists($filePath)) { + throw new HttpNotFoundException( + $request, + 'Archive file with name ' . $archiveId . '.zip is not found' + ); + } + + // If the file found so stream it + $psr17Factory = new Psr17Factory(); + $stream = $psr17Factory->createStreamFromFile($filePath, 'r'); + + return $response->withBody($stream) + ->withHeader('Content-Disposition', 'attachment; filename=' . basename($filePath) . ';') + ->withHeader('Content-Type', mime_content_type($filePath)) + ->withHeader('Content-Length', filesize($filePath)); + } +} diff --git a/server/src/Action/DownloadResultAction.php b/server/src/Action/DownloadResultAction.php new file mode 100644 index 0000000000000000000000000000000000000000..2413fa29744c139ec1be2e0097ff3d6335ac4581 --- /dev/null +++ b/server/src/Action/DownloadResultAction.php @@ -0,0 +1,111 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Doctrine\ORM\EntityManagerInterface; +use Slim\Exception\HttpNotFoundException; +use Nyholm\Psr7\Factory\Psr17Factory; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class DownloadResultAction extends AbstractAction +{ + /** + * Contains anis-server data path + * + * @var string + */ + private $dataPath; + + /** + * Contains settings to handle Json Web Token + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param string $dataPath Contains anis-server data path + * @param array $settings Settings about token + */ + public function __construct(EntityManagerInterface $em, string $dataPath, array $settings) + { + parent::__construct($em); + $this->dataPath = $dataPath; + $this->settings = $settings; + } + + /** + * `GET` Returns the file found + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + // Search the correct dataset with primary key + $dataset = $this->em->find('App\Entity\Dataset', $args['dname']); + + // If dataset is not found 404 + if (is_null($dataset)) { + throw new HttpNotFoundException( + $request, + 'Dataset with name ' . $args['dname'] . ' is not found' + ); + } + + // If dataset is private and authorization enabled + if (!$dataset->getPublic() && boolval($this->settings['enabled'])) { + $this->verifyDatasetAuthorization( + $request, + $dataset->getName(), + explode(',', $this->settings['admin_roles']) + ); + } + + // Search the file + $fileId = $args['id']; + $filePath = $this->dataPath . '/RESULT/' . $fileId; + + // If the file not found 404 + if (!file_exists($filePath)) { + throw new HttpNotFoundException( + $request, + 'Result file with name ' . $fileId . ' is not found' + ); + } + + // If the file found so stream it + $psr17Factory = new Psr17Factory(); + $stream = $psr17Factory->createStreamFromFile($filePath, 'r'); + + return $response->withBody($stream) + ->withHeader('Content-Disposition', 'attachment; filename=' . basename($filePath) . ';') + ->withHeader('Content-Type', mime_content_type($filePath)) + ->withHeader('Content-Length', filesize($filePath)); + } +} diff --git a/server/src/Action/IsArchiveAvailableAction.php b/server/src/Action/IsArchiveAvailableAction.php new file mode 100644 index 0000000000000000000000000000000000000000..b993e1d59446f3bee241a096da1b867c516dc11b --- /dev/null +++ b/server/src/Action/IsArchiveAvailableAction.php @@ -0,0 +1,84 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Doctrine\ORM\EntityManagerInterface; +use Slim\Exception\HttpNotFoundException; +use Nyholm\Psr7\Factory\Psr17Factory; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class IsArchiveAvailableAction extends AbstractAction +{ + /** + * Contains anis-server data path + * + * @var string + */ + private $dataPath; + + /** + * Contains settings to handle Json Web Token + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param string $dataPath Contains anis-server data path + * @param array $settings Settings about token + */ + public function __construct(EntityManagerInterface $em, string $dataPath, array $settings) + { + parent::__construct($em); + $this->dataPath = $dataPath; + $this->settings = $settings; + } + + /** + * `GET` Returns the file found + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + $archiveId = $args['id']; + + // Search the file + $filePath = $this->dataPath . '/ARCHIVE/' . $archiveId . '.zip'; + + $isAvailable = false; + if (file_exists($filePath)) { + $isAvailable = true; + } + + $payload = json_encode(array('archive_is_available' => $isAvailable)); + $response->getBody()->write($payload); + return $response; + } +} diff --git a/server/src/Action/IsResultAvailableAction.php b/server/src/Action/IsResultAvailableAction.php new file mode 100644 index 0000000000000000000000000000000000000000..64bba6e7ca1a12e3e023bd655397d7ab3ab036e0 --- /dev/null +++ b/server/src/Action/IsResultAvailableAction.php @@ -0,0 +1,84 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Doctrine\ORM\EntityManagerInterface; +use Slim\Exception\HttpNotFoundException; +use Nyholm\Psr7\Factory\Psr17Factory; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class IsResultAvailableAction extends AbstractAction +{ + /** + * Contains anis-server data path + * + * @var string + */ + private $dataPath; + + /** + * Contains settings to handle Json Web Token + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param string $dataPath Contains anis-server data path + * @param array $settings Settings about token + */ + public function __construct(EntityManagerInterface $em, string $dataPath, array $settings) + { + parent::__construct($em); + $this->dataPath = $dataPath; + $this->settings = $settings; + } + + /** + * `GET` Returns the file found + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + $fileId = $args['id']; + + // Search the file + $filePath = $this->dataPath . '/RESULT/' . $fileId; + + $isAvailable = false; + if (file_exists($filePath)) { + $isAvailable = true; + } + + $payload = json_encode(array('file_is_available' => $isAvailable)); + $response->getBody()->write($payload); + return $response; + } +} diff --git a/server/src/Action/StartTaskCreateArchiveAction.php b/server/src/Action/StartTaskCreateArchiveAction.php new file mode 100644 index 0000000000000000000000000000000000000000..fd2d02711ada3bd236484a3f23b41ea0d5fbec2a --- /dev/null +++ b/server/src/Action/StartTaskCreateArchiveAction.php @@ -0,0 +1,136 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Slim\Exception\HttpBadRequestException; +use Slim\Exception\HttpNotFoundException; +use Slim\Exception\HttpInternalServerErrorException; +use Doctrine\ORM\EntityManagerInterface; +use Nyholm\Psr7\Factory\Psr17Factory; +use App\Search\DBALConnectionFactory; +use App\Search\Query\AnisQueryBuilder; +use App\Search\Response\IResponseFactory; +use App\Search\SearchException; +use PhpAmqpLib\Connection\AbstractConnection; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Message\AMQPMessage; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class StartTaskCreateArchiveAction extends AbstractAction +{ + /** + * Contains RabbitMQ connection socket + * + * @var AbstractConnection + */ + private $rmq; + + /** + * Contains settings to handle Json Web Token (app/settings.php) + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param AbstractConnection $rmq RabbitMQ connection socket + * @param array $settings Settings about token + */ + public function __construct( + EntityManagerInterface $em, + AbstractConnection $rmq, + array $settings + ) { + parent::__construct($em); + $this->rmq = $rmq; + $this->settings = $settings; + } + + /** + * `GET` Starts an asynchronous task, through rabbitmq, to build an archive + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + // Search the correct dataset with primary key + $datasetName = $args['dname']; + $dataset = $this->em->find('App\Entity\Dataset', $datasetName); + + // If dataset is not found 404 + if (is_null($dataset)) { + throw new HttpNotFoundException( + $request, + 'Dataset with name ' . $datasetName . ' is not found' + ); + } + + // If dataset is private and authorization enabled + $token = ''; + if (!$dataset->getPublic() && boolval($this->settings['enabled'])) { + $this->verifyDatasetAuthorization( + $request, + $dataset->getName(), + explode(',', $this->settings['admin_roles']) + ); + $token = $request->getHeader('Authorization')[0]; + } + + $queryParams = $request->getQueryParams(); + + // The parameter "a" is mandatory + if (!array_key_exists('a', $queryParams)) { + throw new HttpBadRequestException( + $request, + 'Param a is required for this request' + ); + } + + // Create the name of the future archive + $archiveName = 'archive_' . $dataset->getName() . '_' . (new \DateTime())->format('Y-m-d\TH:i:s') . '.zip'; + $archiveId = uniqid(); + + // Publish message in the archive queue + $channel = $this->rmq->channel(); + $channel->queue_declare('archive', false, false, false, false); + $msg = new AMQPMessage(json_encode(array( + 'archive_id' => $archiveId, + 'dataset_name' => $datasetName, + 'query' => $request->getUri()->getQuery(), + 'param_a' => $queryParams['a'], + 'token' => $token + ))); + $channel->basic_publish($msg, '', 'archive'); + + // Just returns the future archive name + $payload = json_encode(array('archive_name' => $archiveName, 'archive_id' => $archiveId)); + $response->getBody()->write($payload); + return $response; + } +} diff --git a/server/src/Action/StartTaskCreateResultAction.php b/server/src/Action/StartTaskCreateResultAction.php new file mode 100644 index 0000000000000000000000000000000000000000..329e00c0331453c1352f824eabf5c8086663b050 --- /dev/null +++ b/server/src/Action/StartTaskCreateResultAction.php @@ -0,0 +1,147 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Slim\Exception\HttpBadRequestException; +use Slim\Exception\HttpNotFoundException; +use Slim\Exception\HttpInternalServerErrorException; +use Doctrine\ORM\EntityManagerInterface; +use Nyholm\Psr7\Factory\Psr17Factory; +use App\Search\DBALConnectionFactory; +use App\Search\Query\AnisQueryBuilder; +use App\Search\Response\IResponseFactory; +use App\Search\SearchException; +use PhpAmqpLib\Connection\AbstractConnection; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Message\AMQPMessage; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class StartTaskCreateResultAction extends AbstractAction +{ + /** + * Contains RabbitMQ connection socket + * + * @var AbstractConnection + */ + private $rmq; + + /** + * Contains settings to handle Json Web Token (app/settings.php) + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param AbstractConnection $rmq RabbitMQ connection socket + * @param array $settings Settings about token + */ + public function __construct( + EntityManagerInterface $em, + AbstractConnection $rmq, + array $settings + ) { + parent::__construct($em); + $this->rmq = $rmq; + $this->settings = $settings; + } + + /** + * `GET` Starts an asynchronous task, through rabbitmq, to build an archive + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + // Search the correct dataset with primary key + $datasetName = $args['dname']; + $dataset = $this->em->find('App\Entity\Dataset', $datasetName); + + // If dataset is not found 404 + if (is_null($dataset)) { + throw new HttpNotFoundException( + $request, + 'Dataset with name ' . $datasetName . ' is not found' + ); + } + + // If dataset is private and authorization enabled + $token = ''; + if (!$dataset->getPublic() && boolval($this->settings['enabled'])) { + $this->verifyDatasetAuthorization( + $request, + $dataset->getName(), + explode(',', $this->settings['admin_roles']) + ); + $token = $request->getHeader('Authorization')[0]; + } + + $queryParams = $request->getQueryParams(); + + // The parameter "a" is mandatory + if (!array_key_exists('a', $queryParams)) { + throw new HttpBadRequestException( + $request, + 'Param a is required for this request' + ); + } + + // Search extension + if ($queryParams['f'] === 'csv') { + $extension = '.csv'; + } elseif ($queryParams['f'] === 'ascii') { + $extension = '.txt'; + } elseif ($queryParams['f'] === 'votable') { + $extension = '.xml'; + } else { + $extension = '.json'; + } + + // Create the name of the future archive + $fileName = 'result_' . $dataset->getName() . '_' . (new \DateTime())->format('Y-m-d\TH:i:s') . $extension; + $fileId = uniqid(); + + // Publish message in the archive queue + $channel = $this->rmq->channel(); + $channel->queue_declare('archive', false, false, false, false); + + $msg = new AMQPMessage(json_encode(array( + 'file_id' => $fileId, + 'dataset_name' => $datasetName, + 'query' => $request->getUri()->getQuery(), + 'token' => $token + ))); + $channel->basic_publish($msg, '', 'result'); + + // Just returns the future archive name + $payload = json_encode(array('file_name' => $fileName, 'file_id' => $fileId)); + $response->getBody()->write($payload); + return $response; + } +} diff --git a/tasks/.gitignore b/tasks/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..9bb092ad02e650dc5a3f7f8c1dc8a05b0b262470 --- /dev/null +++ b/tasks/.gitignore @@ -0,0 +1,58 @@ +# compiled output +dist +/tmp +/out-tsc +var +coverage +data + +# Only exists if Bazel was run +/bazel-out + +# dependencies +node_modules + +# profiling files +chrome-profiler-events*.json +speed-measure-plugin*.json + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +# Python stuff # +################ +env +eggs/ +.eggs/ +*.egg-info/ +*.egg +__pycache__/ +*.pyc diff --git a/tasks/Dockerfile b/tasks/Dockerfile new file mode 100755 index 0000000000000000000000000000000000000000..848cfb600d1e9337dce1f480d0a738decaa49ab6 --- /dev/null +++ b/tasks/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.8 + +WORKDIR /project + +COPY requirements.txt ./ +COPY src src +RUN pip install --no-cache-dir -r requirements.txt + +CMD ["python3.8", "src/anis_tasks/app.py"] diff --git a/tasks/requirements.txt b/tasks/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..1bb63938e72440bceb9408c5496f18a608ae2b71 --- /dev/null +++ b/tasks/requirements.txt @@ -0,0 +1,9 @@ +certifi==2021.10.8 +charset-normalizer==2.0.12 +decorator==5.1.1 +idna==3.3 +pika==1.2.0 +py==1.11.0 +requests==2.27.1 +retry==0.9.2 +urllib3==1.26.8 diff --git a/tasks/src/anis_tasks/app.py b/tasks/src/anis_tasks/app.py new file mode 100644 index 0000000000000000000000000000000000000000..5dcd1b275d1b211f412bc2350b1b630993ccc4e0 --- /dev/null +++ b/tasks/src/anis_tasks/app.py @@ -0,0 +1,44 @@ +# Standard library imports +import logging, sys + +# Third party imports +import pika +from retry import retry + +# Local application imports +from anis_tasks import utils, archive, result + +@retry(pika.exceptions.AMQPConnectionError, delay=5, jitter=(1, 3)) +def run(): + try: + # Check config variables + utils.check_config() + + # Connect to the rabbitMQ server + credentials = pika.PlainCredentials(utils.get_rmq_user(), utils.get_rmq_password()) + connection = pika.BlockingConnection(pika.ConnectionParameters(host=utils.get_rmq_host(), port=utils.get_rmq_port(), credentials=credentials)) + channel = connection.channel() + + # Add archive task handler + channel.queue_declare(queue='archive') + channel.basic_consume(queue='archive', on_message_callback=archive.archive_handler, auto_ack=True) + + # Add result task handler + channel.queue_declare(queue='result') + channel.basic_consume(queue='result', on_message_callback=result.result_handler, auto_ack=True) + + # Start + logging.info("ANIS tasks started") + channel.start_consuming() + except utils.ConfigKeyNotFound as e: + logging.error("Config error") + logging.error(e) + return + +if __name__ == '__main__': + logging.basicConfig( + stream=sys.stderr, + level=logging.INFO, + format='[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s' + ) + run() \ No newline at end of file diff --git a/tasks/src/anis_tasks/archive.py b/tasks/src/anis_tasks/archive.py new file mode 100644 index 0000000000000000000000000000000000000000..2e93e38974915857c1a2037c2814f2bf269cccc9 --- /dev/null +++ b/tasks/src/anis_tasks/archive.py @@ -0,0 +1,53 @@ +# Standard library imports +import logging, json, os +from zipfile import ZipFile + +# Local application imports +from anis_tasks import utils + +def archive_handler(ch, method, properties, body): + logging.info("Processing a new archive message") + + # Decode JSON + message = json.loads(body) + + # Retrieve metadata information + dataset = utils.get_dataset(message["dataset_name"]) + attributes = utils.get_attributes(message["dataset_name"]) + attributes_selected = get_attributes_selected(attributes, message["param_a"]) + + # Retrieve data + data = utils.search_data(message["dataset_name"], message["query"], message["token"]) + + # create a ZipFile object + data_path = utils.get_data_path() + zip_path = data_path + "/ARCHIVE/" + message["archive_id"] + ".zip" + zip = ZipFile(zip_path + ".tmp", 'w') + + # Search files + for row in data.json(): + files_added = [] + for attribute in attributes_selected: + attribute_label = attribute["label"] + file_path = utils.get_data_path() + dataset["full_data_path"] + "/" + str(row[attribute_label]) + if (os.path.exists(file_path) and os.path.isfile(file_path) and file_path not in files_added): + # Adds file to the zip archive + files_added.append(file_path) + zip.write(file_path, row[attribute_label]) + + # close the Zip File + zip.close() + + # Rename the tmp zip file with the correct archive name + os.rename(zip_path + ".tmp", zip_path) + + logging.info("Zip created: " + zip_path) + +def get_attributes_selected(attributes, param_a): + attributes_selected = [] + ids = param_a.split(";") + for attribute in attributes: + if (str(attribute["id"]) in ids): + attributes_selected.append(attribute) + + return attributes_selected \ No newline at end of file diff --git a/tasks/src/anis_tasks/result.py b/tasks/src/anis_tasks/result.py new file mode 100644 index 0000000000000000000000000000000000000000..27ac73dfe913594e8aea32f5feb13ccc532c7a70 --- /dev/null +++ b/tasks/src/anis_tasks/result.py @@ -0,0 +1,31 @@ +# Standard library imports +import logging, json, os + +# Local application imports +from anis_tasks import utils + +def result_handler(ch, method, properties, body): + logging.info("Processing a new result message") + + # Decode JSON + message = json.loads(body) + + # Retrieve data + logging.info(message["token"]) + data = utils.search_data(message["dataset_name"], message["query"], message["token"]) + + # create a File object + data_path = utils.get_data_path() + file_path = data_path + "/RESULT/" + message["file_id"] + file = open(file_path + ".tmp", "w") + + # Write data + file.write(data.text) + + # close the File + file.close() + + # Rename the tmp file with the correct filename (id) + os.rename(file_path + ".tmp", file_path) + + logging.info("File created: " + file_path) diff --git a/tasks/src/anis_tasks/utils.py b/tasks/src/anis_tasks/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..ae43a76b3cce6464f101e372e553cd505916312d --- /dev/null +++ b/tasks/src/anis_tasks/utils.py @@ -0,0 +1,125 @@ +# Standard library imports +import os + +# Third party imports +import requests + +def check_config(): + """ + Check Config variables + """ + check_keys = { + 'DATA_PATH', + 'SERVER_URL', + 'RMQ_HOST', + 'RMQ_PORT', + 'RMQ_USER', + 'RMQ_PASSWORD' + } + for value in check_keys: + if value not in os.environ.keys(): + raise ConfigKeyNotFound(value) + +def get_data_path(): + """ + Get ANIS data path + """ + return os.environ['DATA_PATH'] + +def get_server_url(): + """ + Get ANIS server URL + """ + return os.environ['SERVER_URL'] + +def get_rmq_host(): + """ + Get RabbitMQ hostname + """ + return os.environ["RMQ_HOST"] + +def get_rmq_port(): + """ + Get RabbitMQ port + """ + return os.environ["RMQ_PORT"] + +def get_rmq_user(): + """ + Get RabbitMQ user + """ + return os.environ["RMQ_USER"] + +def get_rmq_password(): + """ + Get RabbitMQ password + """ + return os.environ["RMQ_PASSWORD"] + +def get_dataset(dname): + server_url = os.environ["SERVER_URL"] + + r = requests.get(server_url + "/dataset/" + dname) + + if (r.status_code == 404): + raise DatasetNotFound(dname) + if (r.status_code == 500): + raise AnisServerError(r.json()["message"]) + + return r.json() + +def get_attributes(dname): + server_url = os.environ["SERVER_URL"] + + r = requests.get(server_url + "/dataset/" + dname + "/attribute") + + if (r.status_code == 404): + raise DatasetNotFound(dname) + if (r.status_code == 500): + raise AnisServerError(r.json()["message"]) + + return r.json() + +def search_data(dname, query, token): + server_url = os.environ["SERVER_URL"] + + headers = {} + + if (token): + headers = { "Authorization": token } + + r = requests.get(server_url + "/search/" + dname + "?" + query, headers=headers) + + if (r.status_code == 404): + raise DatasetNotFound(dname) + if (r.status_code == 500): + raise AnisServerError(r.json()["message"]) + + return r + +class ConfigKeyNotFound(Exception): + """ + Config Key Not Found + """ + def __init__(self, value): + Exception.__init__(self, value) + self.value = value + + def __str__(self): + return f"{self.value} was not found in the environment variables" + +class DatasetNotFound(Exception): + def __init__(self, dname): + Exception.__init__(self, dname) + self.dname = dname + + def __str__(self): + return f"Dataset {self.dname} was not found" + +class AnisServerError(Exception): + def __init__(self, message): + Exception.__init__(self, message) + self.message = message + + def __str__(self): + return f"Anis-server error: {self.message}" \ No newline at end of file