diff --git a/firebird-ng/src/app/pages/input-config/input-config.component.html b/firebird-ng/src/app/pages/input-config/input-config.component.html index dda39de..8cd1962 100644 --- a/firebird-ng/src/app/pages/input-config/input-config.component.html +++ b/firebird-ng/src/app/pages/input-config/input-config.component.html @@ -147,14 +147,6 @@
API URL:
- - Server Port - {{ firebirdConfig.serverPort }} - - - Server Host - {{ firebirdConfig.serverHost }} - Served by Pyrobird {{ firebirdConfig.servedByPyrobird ? 'Yes' : 'No' }} @@ -164,8 +156,8 @@
API URL:
{{ firebirdConfig.apiAvailable ? 'Yes' : 'No' }} - Use Authentication - {{ firebirdConfig.useAuthentication ? 'Yes' : 'No' }} + API Base URL + {{ firebirdConfig.apiBaseUrl }} Log Level diff --git a/firebird-ng/src/app/services/server-config.service.spec.ts b/firebird-ng/src/app/services/server-config.service.spec.ts index d7c002e..7fd3172 100644 --- a/firebird-ng/src/app/services/server-config.service.spec.ts +++ b/firebird-ng/src/app/services/server-config.service.spec.ts @@ -21,46 +21,19 @@ describe('FirebirdConfigService', () => { }); // it('should fetch and parse JSONC data correctly', () => { - const dummyConfig = { serverPort: 5001 }; + const dummyConfig = { apiBaseUrl: "http://localhost:5454" }; const jsoncData = '{ "key": "value" // comment }'; - service.loadConfig().then(()=>{ - expect(service.config.serverPort).toBe(5001); - }); - // Set up the HttpTestingController const req = httpMock.expectOne(service['configUrl']); expect(req.request.method).toBe('GET'); - req.flush('{ "serverPort": 5001, "useAuthentication": true, "logLevel": "info" }'); // Mock the HTTP response + req.flush('{ "apiBaseUrl": "http://localhost:5454", "logLevel": "info" }'); // Mock the HTTP response + + service.loadConfig().then(()=>{ + expect(service.config.apiBaseUrl).toBe("http://localhost:5454"); + }); }); - // - // it('should handle HTTP errors gracefully', () => { - // const errorResponse = new ErrorEvent('Network error'); - // - // service.getConfig().subscribe({ - // next: config => fail('should have failed with the network error'), - // error: error => expect(error).toBeTruthy(), - // complete: () => fail('The request should not complete successfully') - // }); - // - // }); }); -// import { TestBed } from '@angular/core/testing'; -// -// import { ServerConfigService } from './firebird-config.service'; -// -// describe('ServerConfigService', () => { -// let service: ServerConfigService; -// -// beforeEach(() => { -// TestBed.configureTestingModule({}); -// service = TestBed.inject(ServerConfigService); -// }); -// -// it('should be created', () => { -// expect(service).toBeTruthy(); -// }); -// }); diff --git a/firebird-ng/src/app/services/server-config.service.ts b/firebird-ng/src/app/services/server-config.service.ts index 8fa5fbc..8f2fb86 100644 --- a/firebird-ng/src/app/services/server-config.service.ts +++ b/firebird-ng/src/app/services/server-config.service.ts @@ -6,20 +6,16 @@ import {BehaviorSubject, Observable, catchError, map, of, firstValueFrom} from " export interface ServerConfig { - serverPort: number; - serverHost: string; servedByPyrobird: boolean; apiAvailable: boolean; - useAuthentication: boolean; + apiBaseUrl: string; logLevel: string; } export const defaultFirebirdConfig: ServerConfig = { - serverPort: 5454, - serverHost: "localhost", apiAvailable: false, + apiBaseUrl: "", servedByPyrobird: false, - useAuthentication: true, logLevel: 'info' }; diff --git a/firebird-ng/src/app/services/url.service.spec.ts b/firebird-ng/src/app/services/url.service.spec.ts index b6dd7ea..67601b0 100644 --- a/firebird-ng/src/app/services/url.service.spec.ts +++ b/firebird-ng/src/app/services/url.service.spec.ts @@ -32,8 +32,8 @@ describe('UrlService', () => { serverConfigService = { config: { servedByPyrobird: false, - serverHost: 'localhost', - serverPort: 5000 + apiAvailable: true, + apiBaseUrl: 'http://localhost:5454', } }; @@ -58,6 +58,7 @@ describe('UrlService', () => { const expectedUrl = 'http://localhost:5454/api/v1/download?f=%2Fpath%2Fto%2Ffile.root'; const resolvedUrl = service.resolveDownloadUrl(inputUrl); + expect(resolvedUrl).toBe(expectedUrl); })); diff --git a/firebird-ng/src/app/services/url.service.ts b/firebird-ng/src/app/services/url.service.ts index 669936e..439ea66 100644 --- a/firebird-ng/src/app/services/url.service.ts +++ b/firebird-ng/src/app/services/url.service.ts @@ -20,7 +20,6 @@ import { ServerConfigService } from "./server-config.service"; * - Standalone static site without backend API. * - Served by a Flask application (e.g., PyroBird) with a backend API. * - Served by another service with or without a backend API. - * - Users may configure a custom API endpoint via `UserConfigService`. * - The service needs to resolve URLs for files that may: * - Be accessible over HTTP/HTTPS. * - Be local files requiring backend API to serve them. @@ -28,7 +27,7 @@ import { ServerConfigService } from "./server-config.service"; * * ### Use Cases: * - **Case 1: Downloading Files** - * - **1.1**: Input URL starts with `http://` or `https://`. The URL is used as is. + * - **1.1**: Input URL starts with protocol like `root://`, `http://` or `https://`. The URL is used as is. * - **1.2**: Input URL has no protocol or is a local file. The service checks if a backend is available and constructs the download endpoint URL. * - **Case 2: Converting Files** * - **2.1 & 2.2**: Input URL is converted using the backend 'convert' endpoint, regardless of the original protocol. @@ -96,8 +95,7 @@ export class UrlService { this.isBackendAvailable = servedByPyrobird || userUseApi; if (servedByPyrobird) { - const protocol = window.location.protocol; // 'http:' or 'https:' - this.serverAddress = `${protocol}//${this.serverConfigService.config.serverHost}:${this.serverConfigService.config.serverPort}`; + this.serverAddress = this.serverConfigService.config.apiBaseUrl; } else if (userUseApi) { this.serverAddress = userServerUrl; } else { diff --git a/firebird-ng/src/app/services/user-config.service.spec.ts b/firebird-ng/src/app/services/user-config.service.spec.ts index bd06b9b..cc80a0f 100644 --- a/firebird-ng/src/app/services/user-config.service.spec.ts +++ b/firebird-ng/src/app/services/user-config.service.spec.ts @@ -27,8 +27,7 @@ describe('UrlService', () => { const mockServerConfigService = { config: { servedByPyrobird: false, - serverHost: 'localhost', - serverPort: 5000 + apiBaseUrl: 'http:/localhost:4545' } }; @@ -162,47 +161,4 @@ describe('UrlService', () => { expect(resolvedUrl).toBe(expectedUrl); }); }); - - describe('backend availability and server address', () => { - it('should use PyroBird server address if servedByPyrobird is true', () => { - // Simulate PyroBird server - serverConfigService.config.servedByPyrobird = true; - serverConfigService.config.serverHost = 'pyrobirdhost'; - serverConfigService.config.serverPort = 8080; - - const expectedServerAddress = `${window.location.protocol}//pyrobirdhost:8080`; - - // Reinitialize the service to pick up new config - service = new UrlService(userConfigService, serverConfigService); - - // Access private property via getter (assuming you add getters) - // expect(service.getServerAddress()).toBe(expectedServerAddress); - - // Or access private property directly for testing purposes - expect((service as any).serverAddress).toBe(expectedServerAddress); - }); - - it('should use user-configured server address if localServerUseApi is true', () => { - userConfigService.localServerUseApi.subject.next(true); - userConfigService.localServerUrl.subject.next('http://customserver:1234'); - - const expectedServerAddress = 'http://customserver:1234'; - - // Reinitialize the service to pick up new config - service = new UrlService(userConfigService, serverConfigService); - - expect((service as any).serverAddress).toBe(expectedServerAddress); - }); - - it('should have backend unavailable when neither PyroBird nor user API is configured', () => { - userConfigService.localServerUseApi.subject.next(false); - serverConfigService.config.servedByPyrobird = false; - - // Reinitialize the service to pick up new config - service = new UrlService(userConfigService, serverConfigService); - - expect((service as any).isBackendAvailable).toBe(false); - expect((service as any).serverAddress).toBe(''); - }); - }); }); diff --git a/pyrobird/README.md b/pyrobird/README.md index ef3bdf7..ae93c8f 100644 --- a/pyrobird/README.md +++ b/pyrobird/README.md @@ -134,9 +134,9 @@ This is technical explanation of what is under the hood of the server part ### Configuration Options - **DOWNLOAD_PATH**: `str[getcwd()]`, Specifies the directory from which files can be downloaded when using relative paths. -- **DOWNLOAD_DISABLE**: `bool[False]` If set to `True`, all download functionalities are disabled. +- **DOWNLOAD_IS_DISABLED**: `bool[False]` If set to `True`, all download functionalities are disabled. - **DOWNLOAD_IS_UNRESTRICTED**: `bool[False]`, allows unrestricted access to download any file, including sensitive ones. -- **DOWNLOAD_ALLOW_CORS**: `bool[False]`, If set to `True`, enables Cross-Origin Resource Sharing (CORS) for download routes. +- **CORS_IS_ALLOWED**: `bool[False]`, If set to `True`, enables Cross-Origin Resource Sharing (CORS) for download routes. diff --git a/pyrobird/example_wsgi.py b/pyrobird/example_wsgi.py index 6e2645d..6d8b6ea 100644 --- a/pyrobird/example_wsgi.py +++ b/pyrobird/example_wsgi.py @@ -1,7 +1,7 @@ config = { - "DOWNLOAD_DISABLE": True, + "DOWNLOAD_IS_DISABLED": True, "DOWNLOAD_IS_UNRESTRICTED": False, - "DOWNLOAD_ALLOW_CORS": True + "CORS_IS_ALLOWED": True } from pyrobird.server import flask_app as application diff --git a/pyrobird/src/pyrobird/cli/serve.py b/pyrobird/src/pyrobird/cli/serve.py index c1fa9b1..1181d3e 100644 --- a/pyrobird/src/pyrobird/cli/serve.py +++ b/pyrobird/src/pyrobird/cli/serve.py @@ -1,6 +1,8 @@ import logging import click import pyrobird.server +from pyrobird.server import CFG_DOWNLOAD_IS_UNRESTRICTED, CFG_DOWNLOAD_IS_DISABLED, CFG_DOWNLOAD_PATH, \ + CFG_CORS_IS_ALLOWED, CFG_API_BASE_URL, CFG_FIREBIRD_CONFIG_PATH # Configure logging logger = logging.getLogger(__name__) @@ -26,8 +28,10 @@ @click.option("--work-path", "work_path", show_default=True, default="", help="Set the base directory path for file downloads. Defaults to the current working directory.") @click.option("--host", "host", default="", help="Set the host for development server to listen to") @click.option("--port", "port", default="", help="Set the port for development server to listen to") +@click.option("--api-url", "api_url", default="", help="Force to use this address as backend API base URL. E.g. https://my-server:1234/") +@click.option("--config", "config_path", default="", help="Path to firebird config.jsonc if used a custom") @click.pass_context -def serve(ctx, unsecure_files, allow_cors, disable_download, work_path, host, port): +def serve(ctx, unsecure_files, allow_cors, disable_download, work_path, host, port, api_url, config_path): """ Start the server that serves Firebird frontend and can communicate with it. @@ -61,10 +65,12 @@ def serve(ctx, unsecure_files, allow_cors, disable_download, work_path, host, po port = 5454 pyrobird.server.run(debug=True, host=host, port=port, config={ - "DOWNLOAD_IS_UNRESTRICTED": unsecure_files, - "DOWNLOAD_DISABLE": disable_download, - "DOWNLOAD_PATH": work_path, - "DOWNLOAD_ALLOW_CORS": allow_cors}) + CFG_DOWNLOAD_IS_UNRESTRICTED: unsecure_files, + CFG_DOWNLOAD_IS_DISABLED: disable_download, + CFG_DOWNLOAD_PATH: work_path, + CFG_CORS_IS_ALLOWED: allow_cors, + CFG_API_BASE_URL: api_url, + CFG_FIREBIRD_CONFIG_PATH: config_path}) if __name__ == '__main__': diff --git a/pyrobird/src/pyrobird/server/__init__.py b/pyrobird/src/pyrobird/server/__init__.py index d156067..078489d 100644 --- a/pyrobird/src/pyrobird/server/__init__.py +++ b/pyrobird/src/pyrobird/server/__init__.py @@ -30,11 +30,21 @@ flask_app.config.update() # Compression config -# We want to use compression only transfering JSON for now... +# We want to use compression only transferring JSON for now... flask_app.config["COMPRESS_REGISTER"] = False # disable default compression of all requests compress = Compress() compress.init_app(flask_app) +# Config KEYS + +CFG_DOWNLOAD_IS_UNRESTRICTED = "DOWNLOAD_IS_UNRESTRICTED" +CFG_DOWNLOAD_IS_DISABLED = "DOWNLOAD_IS_DISABLED" +CFG_DOWNLOAD_PATH = "DOWNLOAD_PATH" +CFG_CORS_IS_ALLOWED = "DOWNLOAD_ALLOW_CORS" +CFG_API_BASE_URL = "API_BASE_URL" +CFG_FIREBIRD_CONFIG_PATH = "FIREBIRD_CONFIG_PATH" + + class ExcludeAPIConverter(BaseConverter): """ @@ -80,7 +90,7 @@ def _can_user_download_file(filename): - bool: True if the file can be downloaded, False otherwise. Process: - - If downloading is globally disabled (DOWNLOAD_DISABLE=True), returns False. + - If downloading is globally disabled (DOWNLOAD_IS_DISABLED=True), returns False. - If unrestricted downloads are allowed (DOWNLOAD_IS_UNRESTRICTED=True), returns True. - For relative paths, assumes that the download is allowable. - For absolute paths, checks that the file resides within the configured DOWNLOAD_PATH. @@ -90,12 +100,12 @@ def _can_user_download_file(filename): app = flask.current_app # If any downloads are disabled - if app.config.get("DOWNLOAD_DISABLE") is True: - logger.warning("Can't download file. DOWNLOAD_DISABLE=True") + if app.config.get(CFG_DOWNLOAD_IS_DISABLED) is True: + logger.warning("Can't download file. DOWNLOAD_IS_DISABLED=True") return False # If we allow any download - unrestricted_download = app.config.get("DOWNLOAD_IS_UNRESTRICTED") is True + unrestricted_download = app.config.get(CFG_DOWNLOAD_IS_UNRESTRICTED) is True if unrestricted_download: return True @@ -106,7 +116,7 @@ def _can_user_download_file(filename): # HERE we have and absolute path! Check if it is safe to download - allowed_path = app.config.get("DOWNLOAD_PATH") + allowed_path = app.config.get(CFG_DOWNLOAD_PATH) if not allowed_path: allowed_path = os.getcwd() @@ -138,7 +148,7 @@ def download_file(filename=None): # If it is relative, combine it with DOWNLOAD_PATH if not os.path.isabs(filename): - download_path = flask.current_app.config.get("DOWNLOAD_PATH") + download_path = flask.current_app.config.get(CFG_DOWNLOAD_PATH) if not download_path: download_path = os.getcwd() @@ -202,7 +212,7 @@ def open_edm4eic_file(filename=None, file_type="edm4eic", entries="0"): if not is_remote: # If it is relative, combine it with DOWNLOAD_PATH if not os.path.isabs(filename): - download_path = flask.current_app.config.get("DOWNLOAD_PATH") + download_path = flask.current_app.config.get(CFG_DOWNLOAD_PATH) if not download_path: download_path = os.getcwd() @@ -272,7 +282,9 @@ def asset_config(): It reads out existing file adding server info """ - config_path = 'assets/config.jsonc' + config_path = flask_app.config.get(CFG_FIREBIRD_CONFIG_PATH) + if not config_path: + config_path = 'assets/config.jsonc' config_dict = {} os_config_path = os.path.join(flask_app.static_folder, 'assets', 'config.jsonc') @@ -286,7 +298,7 @@ def asset_config(): except Exception as ex: logger.error(f"error opening {config_path}: {ex}") - host = 'unknown' + host = 'localhost' port = 80 tokens = request.host.split(':') @@ -309,6 +321,8 @@ def asset_config(): config_dict['serverHost'] = host config_dict['servedByPyrobird'] = True config_dict['apiAvailable'] = True + config_api_url = flask_app.config.get(CFG_API_BASE_URL) + config_dict['apiBaseUrl'] = config_api_url if config_api_url else f"{request.scheme}://{host}:{port}" # Convert the updated dictionary to JSON return jsonify(config_dict) @@ -344,18 +358,19 @@ def shutdown(): def run(config=None, host=None, port=5454, debug=False, load_dotenv=False): """Runs flask server""" if config: - if isinstance(config, Config) or isinstance(config, map): + if isinstance(config, flask.Config) or isinstance(config, map): flask_app.config.from_mapping(config) else: flask_app.config.from_object(config) - if flask_app.config and flask_app.config.get("DOWNLOAD_ALLOW_CORS") is True: + if flask_app.config and flask_app.config.get(CFG_CORS_IS_ALLOWED) is True: from flask_cors import CORS # Enable CORS for all routes and specify the domains and settings CORS(flask_app, resources={ r"/download/*": {"origins": "*"}, r"/api/v1/*": {"origins": "*"}, + r"/assets/config.jsonc": {"origins": "*"}, }) logger.debug("Serve path:") diff --git a/pyrobird/tests/test_server.py b/pyrobird/tests/test_server.py index 6d87fc0..71a7123 100644 --- a/pyrobird/tests/test_server.py +++ b/pyrobird/tests/test_server.py @@ -22,7 +22,7 @@ def client(): # Set the DOWNLOAD_PATH to the 'data' directory where test files are located flask_app.config['DOWNLOAD_PATH'] = os.path.abspath(TEST_ROOT_DATA_DIR) # Ensure downloads are allowed - flask_app.config['DOWNLOAD_DISABLE'] = False + flask_app.config['DOWNLOAD_IS_DISABLED'] = False flask_app.config['DOWNLOAD_IS_UNRESTRICTED'] = False return flask_app.test_client() @@ -58,7 +58,7 @@ def test_open_dangerous(client): filename = '/etc/passwd' # A file outside the allowed path event_number = 0 flask_app.config['DOWNLOAD_IS_UNRESTRICTED'] = True - flask_app.config['DOWNLOAD_DISABLE'] = False + flask_app.config['DOWNLOAD_IS_DISABLED'] = False response = client.get(f'/api/v1/download?filename={filename}') assert response.status_code == 200 # OK @@ -81,9 +81,9 @@ def test_open_edm4eic_file_nonexistent_file(client): assert response.status_code == 404 # Not Found -def test_open_edm4eic_file_download_disabled(client): +def test_open_edm4eic_file_DOWNLOAD_IS_DISABLEDd(client): # Test accessing a file when downloads are disabled - flask_app.config['DOWNLOAD_DISABLE'] = True + flask_app.config['DOWNLOAD_IS_DISABLED'] = True filename = 'reco_2024-09_craterlake_2evt.edm4eic.root' event_number = 0 @@ -92,7 +92,7 @@ def test_open_edm4eic_file_download_disabled(client): assert response.status_code == 403 # Forbidden # Re-enable downloads for other tests - flask_app.config['DOWNLOAD_DISABLE'] = False + flask_app.config['DOWNLOAD_IS_DISABLED'] = False def test_open_edm4eic_file_invalid_file(client):