diff --git a/pyrobird/pyproject.toml b/pyrobird/pyproject.toml index 32e5b61..ba9cae5 100644 --- a/pyrobird/pyproject.toml +++ b/pyrobird/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ - "click", "rich", "pyyaml" + "click", "rich", "pyyaml", "flask_cors" ] #[project.urls] diff --git a/pyrobird/src/pyrobird/__about__.py b/pyrobird/src/pyrobird/__about__.py index efda0a3..218fe4e 100644 --- a/pyrobird/src/pyrobird/__about__.py +++ b/pyrobird/src/pyrobird/__about__.py @@ -2,4 +2,4 @@ # This file is part of Firebird Event Display and is licensed under the LGPLv3. # See the LICENSE file in the project root for full license information. -__version__ = "0.0.6" +__version__ = "0.0.7" diff --git a/pyrobird/src/pyrobird/cli/serve/__init__.py b/pyrobird/src/pyrobird/cli/serve/__init__.py index ab95e07..eeb7fdc 100644 --- a/pyrobird/src/pyrobird/cli/serve/__init__.py +++ b/pyrobird/src/pyrobird/cli/serve/__init__.py @@ -3,18 +3,60 @@ import pyrobird.server # Configure logging -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +unsecure_files_help = ( + "Allow unrestricted access to download files in a system. " + "When enabled, the server allows downloads of all files which running user has access to. " + "When disabled only files in `work-path` and subdirectories are allowed. " + "This option could be safe on personal machines with one user, who runs the server in interactive terminal." + "(!) It is considered dangerous in all other cases: farms, interactive nodes, production environments, servers, etc. " +) + +allow_cors_help = ( + "Enable CORS for downloaded files. This option should be used if you need to support web " + "applications from different domains accessing the files. Such your server from central firebird server" +) + @click.command() -@click.option("--unsecure-files", "unsecure_files", is_flag=True, show_default=True, default=False, help="Allow unrestricted files download in a system") +@click.option("--allow-any-file", "unsecure_files", is_flag=True, show_default=True, default=False, help=unsecure_files_help) +@click.option("--allow-cors", "allow_cors", is_flag=True, show_default=True, default=False, help=allow_cors_help) +@click.option("--disable-files", "disable_download", is_flag=True, show_default=True, default=False, help="Disable all file downloads from the server") +@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.pass_context -def serve(ctx, unsecure_files): +def serve(ctx, unsecure_files, allow_cors, disable_download, work_path): """ - Operations with database (create tables, erase everything, etc) + Start the server that serves Firebird frontend and can communicate with it. + + This server allows firebird to work with local files and local file system as + well as to complement frontend features such as open xrootd files, etc. + + This command initializes the Flask server with specific settings for file handling + and cross-origin resource sharing, tailored to operational and security requirements. + + Examples: + - Start server with default settings, Firebird works with files in current directory: + fbd serve + - Enable unrestricted file downloads (absolute paths allowed) and CORS: + fbd serve --allow-any-file --allow-cors + - Set, where firebird will take files from + fbd serve --work-path=/home/username/datafiles + Now if you set file local://filename.root in Firebird UI, + the file /home/username/datafiles/filename.root will be opened """ - pyrobird.server.run(debug=True, config={"ALLOW_UNRESTRICTED_DOWNLOAD": unsecure_files}) + + # Log the state of each flag + logging.info(f"Unsecure Files Allowed: {unsecure_files}") + logging.info(f"CORS Allowed: {allow_cors}") + logging.info(f"File Download Disabled: {disable_download}") + logging.info(f"Work Path Set To: {work_path if work_path else 'Current Working Directory'}") + + pyrobird.server.run(debug=True, config={ + "DOWNLOAD_ALLOW_UNRESTRICTED": unsecure_files, + "DOWNLOAD_DISABLE": disable_download, + "DOWNLOAD_PATH": work_path, + "DOWNLOAD_ALLOW_CORS": allow_cors}) if __name__ == '__main__': diff --git a/pyrobird/src/pyrobird/server/__init__.py b/pyrobird/src/pyrobird/server/__init__.py index e772411..ed2c255 100644 --- a/pyrobird/src/pyrobird/server/__init__.py +++ b/pyrobird/src/pyrobird/server/__init__.py @@ -7,8 +7,8 @@ from csv import excel import werkzeug.exceptions -from flask import render_template, send_from_directory, Flask, send_file, abort, Config -from pandas.io.common import file_exists +from flask import render_template, send_from_directory, Flask, send_file, abort, Config, current_app +import flask # Configure logging logging.basicConfig(level=logging.INFO) @@ -17,39 +17,98 @@ server_dir = os.path.abspath(os.path.dirname(__file__)) static_dir = os.path.join(server_dir, "static") -app = Flask(__name__, static_folder='static') -app.config.update() +flask_app = Flask(__name__, static_folder='static') +flask_app.config.update() -@app.route('/download/') + +def _can_user_download_file(filename): + """ + Determine if the user is allowed to download the specified file based on application configuration. + + Parameters: + - filename (str): The path to the file that the user wants to download. Can be absolute or relative. + + Returns: + - bool: True if the file can be downloaded, False otherwise. + + Process: + - If downloading is globally disabled (DOWNLOAD_DISABLE=True), returns False. + - If unrestricted downloads are allowed (DOWNLOAD_ALLOW_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. + - Logs a warning and returns False if the file is outside the allowed download path or if downloading is disabled. + """ + + 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") + return False + + # If we allow any download + unrestricted_download = app.config.get("DOWNLOAD_ALLOW_UNRESTRICTED") is True + if unrestricted_download: + return True + + # if allowed/disable checks are done and we are here, + # if relative path is given, it will be joined with DOWNLOAD_PATH + if not os.path.isabs(filename): + return True + + # HERE we have and absolute path! Check if it is safe to download + + allowed_path = app.config.get("DOWNLOAD_PATH") + if not allowed_path: + allowed_path = os.getcwd() + + # Check file will be downloaded from safe folder + can_download = os.path.realpath(filename).startswith(os.path.realpath(allowed_path)) + if not can_download: + logger.warning("Can't download file. File is not in DOWNLOAD_PATH") + return False + + # All is fine! + return True + + +@flask_app.route('/download/') def download_file(filename): - # Ensure the path is safe and only files within a certain directory can be accessed - # For example, let's say you store your files in a 'files' directory within the server root - safe_path = filename - base_path = '' - if base_path: - safe_path = os.path.join(base_path, filename) - safe_path = os.path.abspath(safe_path) # Resolve any path traversal attempts - - if not safe_path.startswith(base_path): - # Security check failed + + # All checks and flags that user can download the file + if not _can_user_download_file(filename): abort(404) - if os.path.exists(safe_path) and os.path.isfile(safe_path): - return send_file(safe_path, as_attachment=True) + # If it is relative, combine it with DOWNLOAD_PATH + if not os.path.isabs(filename): + download_path = flask.current_app.config.get("DOWNLOAD_PATH") + if not download_path: + download_path = os.getcwd() + + # normalize the path + download_path = os.path.abspath(download_path) + + # combine the file path + filename = os.path.join(download_path, filename) + + # Check if the file exists and is a file + if os.path.exists(filename) and os.path.isfile(filename): + return send_file(filename, as_attachment=True, conditional=True) else: - abort(404) + logger.warning(f"Can't download file. File does not exist") + abort(404) # Return 404 if the file does not exist -@app.route('/') +@flask_app.route('/') def index(): return static_file("index.html") -@app.route('/') +@flask_app.route('/') def static_file(path): - if app.debug: + if flask_app.debug: print("Serve path:") print(" Server dir :", server_dir) print(" Static dir :", static_dir) @@ -58,7 +117,7 @@ def static_file(path): try: return send_from_directory(static_dir, path) except werkzeug.exceptions.NotFound as ex: - if app.debug: + if flask_app.debug: print("File is not found, assuming it is SPA and serving index.html") return send_from_directory(static_dir, "index.html") @@ -66,8 +125,15 @@ def static_file(path): def run(config=None, host=None, port=None, debug=True, load_dotenv=True): if config: if isinstance(config, Config) or isinstance(config, map): - app.config.from_mapping(config) + flask_app.config.from_mapping(config) else: - app.config.from_object(config) + flask_app.config.from_object(config) + + if flask_app.config and flask_app.config.get("DOWNLOAD_ALLOW_CORS") 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": "*"}}) + - app.run(host=host, port=port, debug=debug, load_dotenv=load_dotenv) + flask_app.run(host=host, port=port, debug=debug, load_dotenv=load_dotenv)