From a521e4514f2733da7a4a8577dc59687e5a484e78 Mon Sep 17 00:00:00 2001 From: ImShyMike <122023566+ImShyMike@users.noreply.github.com> Date: Tue, 7 Jan 2025 00:01:30 +0000 Subject: [PATCH] package manager backend & CLI + bump version --- eryx/__init__.py | 2 +- eryx/__main__.py | 77 +++++- eryx/packages/__init__.py | 0 eryx/packages/packages.json | 1 + eryx/packages/packages.py | 430 +++++++++++++++++++++++++++++++ eryx/runtime/interpreter.py | 44 +++- package-manager/.env.example | 7 + package-manager/main.py | 156 +++++++++-- package-manager/requirements.txt | 4 +- requirements.txt | 4 +- setup.cfg | 2 + 11 files changed, 688 insertions(+), 39 deletions(-) create mode 100644 eryx/packages/__init__.py create mode 100644 eryx/packages/packages.json create mode 100644 eryx/packages/packages.py create mode 100644 package-manager/.env.example diff --git a/eryx/__init__.py b/eryx/__init__.py index ef6fa63..c3fc4b2 100644 --- a/eryx/__init__.py +++ b/eryx/__init__.py @@ -1,3 +1,3 @@ """Version of the package.""" -CURRENT_VERSION = "0.2.11" +CURRENT_VERSION = "0.3.0" diff --git a/eryx/__main__.py b/eryx/__main__.py index 104ceb2..eea472a 100644 --- a/eryx/__main__.py +++ b/eryx/__main__.py @@ -7,9 +7,17 @@ from colorama import init from eryx.__init__ import CURRENT_VERSION +from eryx.packages.packages import ( + delete_package, + install, + list_packages, + uninstall, + upload_package, +) from eryx.runtime.repl import start_repl from eryx.runtime.runner import run_code from eryx.server.ide import start_ide +from eryx.packages.packages import DEFAULT_SERVER init(autoreset=True) current_path = os.path.dirname(os.path.abspath(__file__)) @@ -65,15 +73,58 @@ def main(): # 'server' command server_parser = subparsers.add_parser("server", help="Start the web IDE") - server_parser.add_argument("--port", type=int, help="Port number for the web IDE.") - server_parser.add_argument("--host", type=str, help="Host for the web IDE.") server_parser.add_argument( - "--no-file-io", action="store_true", help="Disable file I/O" + "--port", type=int, help="Port number for the web IDE.", default=80 + ) + server_parser.add_argument( + "--host", type=str, help="Host for the web IDE.", default="0.0.0.0" + ) + server_parser.add_argument( + "--no-file-io", action="store_true", help="Disable file I/O", default=False ) # 'test' command subparsers.add_parser("test", help="Run the test suite") + package_parser = subparsers.add_parser("package", help="Manage Eryx packages") + package_subparsers = package_parser.add_subparsers( + dest="package_command", help="Available package commands" + ) + install_parser = package_subparsers.add_parser("install", help="Install a package") + install_parser.add_argument("package", type=str, help="Package to install") + install_parser.add_argument( + "--upgrade", action="store_true", help="Upgrade package", default=False + ) + install_parser.add_argument( + "--server", + type=str, + help="Server to use", + default=DEFAULT_SERVER, + ) + + uninstall_parser = package_subparsers.add_parser("uninstall", help="Uninstall a package") + uninstall_parser.add_argument("package", type=str, help="Package to uninstall") + + package_subparsers.add_parser("list", help="List all installed packages") + + upload_parser = package_subparsers.add_parser("upload", help="Upload a package") + upload_parser.add_argument("package_folder", type=str, help="Package folder to upload") + upload_parser.add_argument( + "--server", + type=str, + help="Server to use", + default=DEFAULT_SERVER, + ) + + delete_parser = package_subparsers.add_parser("delete", help="Delete a package") + delete_parser.add_argument("package", type=str, help="Package to delete") + delete_parser.add_argument( + "--server", + type=str, + help="Server to use", + default=DEFAULT_SERVER, + ) + # Parse the arguments args = arg_parser.parse_args() @@ -96,12 +147,28 @@ def main(): ) elif args.command == "server": start_ide( - args.host or "0.0.0.0", - port=args.port or 80, + args.host, + port=args.port, disable_file_io=args.no_file_io, ) elif args.command == "test": pytest.main(["-v", os.path.join(current_path, "tests", "run_tests.py")]) + elif args.command == "package": + try: + if args.package_command == "install": + install(args.package, args.server, args.upgrade) + elif args.package_command == "uninstall": + uninstall(args.package) + elif args.package_command == "list": + list_packages() + elif args.package_command == "upload": + upload_package(args.package_folder, args.server) + elif args.package_command == "delete": + delete_package(args.package, args.server) + else: + package_parser.print_help() + except KeyboardInterrupt: + print("\nOperation cancelled.") elif args.command is None: arg_parser.print_help() else: diff --git a/eryx/packages/__init__.py b/eryx/packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eryx/packages/packages.json b/eryx/packages/packages.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/eryx/packages/packages.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/eryx/packages/packages.py b/eryx/packages/packages.py new file mode 100644 index 0000000..a07daf4 --- /dev/null +++ b/eryx/packages/packages.py @@ -0,0 +1,430 @@ +"""Module for managing Eryx packages.""" + +import io +import json +import os +import shutil +import zipfile + +import requests +import toml + +packages_dir = os.path.dirname(os.path.realpath(__file__)) + + +CFG_FILE = "packages.json" +INSTALLED_PACKAGES_LOC = "installed" +DEFAULT_SERVER = "https://eryx-packages.shymike.tech" + + +def get_config() -> dict: + """Initialize the package manager.""" + config_path = os.path.join(packages_dir, CFG_FILE) + if not os.path.exists(config_path): + with open(config_path, "w", encoding="utf8") as file: + file.write("{}") + + try: + with open(config_path, "r", encoding="utf8") as file: + config = json.load(file) + except json.JSONDecodeError: + config = {} + + return config + + +class Config: + """Class for managing the configuration file.""" + + def __init__(self): + self.config = get_config() + + if not self.config.get("installed_packages"): + self.config["installed_packages"] = {} + + def __getitem__(self, key): + return self.config[key] + + def __setitem__(self, key, value): + self.config[key] = value + with open(os.path.join(packages_dir, CFG_FILE), "w", encoding="utf8") as file: + json.dump(self.config, file) + + def save(self) -> None: + """Save the configuration file.""" + with open(os.path.join(packages_dir, CFG_FILE), "w", encoding="utf8") as file: + json.dump(self.config, file) + + +CONFIG = Config() + + +def unzip_safe_from_buffer( + path: str, zip_buffer: bytes, max_files: int = 100, max_size: int = 10**7 +): # 10MB + """Unzip a buffer safely from a buffer.""" + # Process the zip buffer + with zipfile.ZipFile(io.BytesIO(zip_buffer), "r") as zip_ref: + # Ensure that the number of files and the total uncompressed size are within limits + file_count = len(zip_ref.infolist()) + if file_count > max_files: + raise ValueError( + f"Too many files in the zip archive: {file_count} > {max_files}" + ) + + total_size = sum(file.file_size for file in zip_ref.infolist()) + if total_size > max_size: + raise ValueError( + f"Total size of uncompressed files exceeds limit: {total_size} > {max_size}" + ) + + # Extract files if safe + zip_ref.extractall(path) + + +def get_folder_size(path: str) -> int: + """Get the size of a folder.""" + total_size = 0 + for dirpath, _, filenames in os.walk(path): + for f in filenames: + fp = os.path.join(dirpath, f) + total_size += os.path.getsize(fp) + return total_size + + +def install(package: str, server: str, upgrade: bool) -> None: + """Install an Eryx package.""" + + if not server.startswith("http://") and not server.startswith("https://"): + server = "https://" + server + + if not server.endswith("/"): + server += "/" + + print(f"Installing package '{package}'") + + if "@" not in package: + package_name = package + if CONFIG["installed_packages"] and package in CONFIG["installed_packages"]: + if not upgrade: + print(f"Package '{package}' already installed") + return + + package_path = os.path.join( + packages_dir, INSTALLED_PACKAGES_LOC, package_name + ) + if upgrade and os.path.exists(package_path): + shutil.rmtree(package_path) + del CONFIG["installed_packages"][package_name] + + try: + if not os.path.exists( + os.path.join(packages_dir, INSTALLED_PACKAGES_LOC) + ): + os.mkdir(os.path.join(packages_dir, INSTALLED_PACKAGES_LOC)) + except FileNotFoundError: + pass + + try: + response = None + response = requests.get(f"{server}download/{package}", timeout=5) + response.raise_for_status() + version = ( + response.url.split("/")[-1].split("?")[0].rsplit(".", 1)[0] or "error" + ) + + package_file = response.content + except requests.RequestException as e: + if response: + if response.status_code == 404: + print(f"Package '{package}' not found") + else: + print(f"Error downloading package '{package}': {e}") + else: + print("Error downloading package: ", e) + return + + else: + package_name, version = package.split("@") + + if CONFIG["installed_packages"] and package in CONFIG["installed_packages"]: + if not upgrade: + print( + f"Package '{package}' already installed " + f"(installed version: {CONFIG['installed_packages'][package_name]})" + ) + return + + package_path = os.path.join( + packages_dir, INSTALLED_PACKAGES_LOC, package_name + ) + if upgrade and os.path.exists(package_path): + shutil.rmtree(package_path) + del CONFIG["installed_packages"][package_name] + + try: + if not os.path.exists( + os.path.join(packages_dir, INSTALLED_PACKAGES_LOC) + ): + os.mkdir(os.path.join(packages_dir, INSTALLED_PACKAGES_LOC)) + except FileNotFoundError: + pass + + try: + response = None + response = requests.get( + f"{server}download/{package_name}/{version}", timeout=5 + ) + response.raise_for_status() + package_file = response.content + except requests.RequestException as e: + if response: + if response.status_code == 404: + try: + versions_response = requests.get( + f"{server}api/versions/{package_name}", timeout=5 + ) + versions_response.raise_for_status() + versions = versions_response.json().get("versions", []) + print( + f"Version '{version}' not found for package '{package_name}'" + ) + print("Available versions:", ", ".join(versions)) + except requests.RequestException: + print(f"Package '{package_name}' not found") + else: + print(f"Error downloading package '{package_name}@{version}': {e}") + else: + print("Error downloading package: ", e) + return + + CONFIG["installed_packages"][package_name] = version + CONFIG.save() + + if not os.path.exists(os.path.join(packages_dir, INSTALLED_PACKAGES_LOC)): + os.mkdir(os.path.join(packages_dir, INSTALLED_PACKAGES_LOC)) + + unzip_safe_from_buffer( + os.path.join(packages_dir, INSTALLED_PACKAGES_LOC, package_name), package_file + ) + + print(f"Package '{package_name}@{version}' installed successfully") + + +def uninstall(package: str) -> None: + """Uninstall an Eryx package.""" + + if not CONFIG["installed_packages"]: + print("No packages installed") + return + + if "@" in package: + package_name, version = package.split("@") + else: + package_name = package + version = None + + print(f"Uninstalling package '{package}'") + + if package not in CONFIG["installed_packages"]: + if not version: + print(f"Package '{package}' not installed") + return + if ( + package_name in CONFIG["installed_packages"] + and CONFIG["installed_packages"][package_name] != version + ): + print(f"Package '{package_name}@{version}' not installed") + return + + del CONFIG["installed_packages"][package_name] + CONFIG.save() + + if not os.path.exists(os.path.join(packages_dir, INSTALLED_PACKAGES_LOC)): + os.mkdir(os.path.join(packages_dir, INSTALLED_PACKAGES_LOC)) + + try: + shutil.rmtree(os.path.join(packages_dir, INSTALLED_PACKAGES_LOC, package_name)) + except FileNotFoundError: + pass + + print(f"Package '{package}' uninstalled successfully") + + +def list_packages() -> None: + """List all installed packages.""" + if not CONFIG["installed_packages"]: + print("No packages installed") + return + + print("Installed packages:") + for package in CONFIG["installed_packages"]: + print(f" {package[0]}@{package[1]}") + + +def upload_package(package_folder: str, server: str) -> None: + """Upload a package to the server.""" + + if not server.startswith("http://") and not server.startswith("https://"): + server = "https://" + server + + if not server.endswith("/"): + server += "/" + + key = get_key(server) + + print(f"Uploading package from '{package_folder}'") + + if not os.path.exists(package_folder): + print(f"Directory '{package_folder}' does not exist") + return + + if not os.path.isdir(package_folder): + print(f"'{package_folder}' is not a directory") + return + + # Perform file checks + if not os.path.exists(os.path.join(package_folder, "package.toml")): + print("Missing 'package.toml' file") + return + + if not os.path.exists(os.path.join(package_folder, "main.eryx")): + print("Missing 'main.eryx' directory (package entry point)") + return + + if get_folder_size(package_folder) > 10**7: + print("Unzipped package too large: >10MB") + return + + with open( + os.path.join(package_folder, "package.toml"), "r", encoding="utf8" + ) as file: + try: + package_data = toml.load(file) + except toml.TomlDecodeError: + print("Error decoding 'package.toml'") + return + + if not package_data.get("package"): + print("Missing 'package' table in 'package.toml'") + return + + package_data = package_data["package"] + + package_name = str(package_data.get("name")) + if not package_name: + print("Missing 'name' field in 'package.toml'") + return + + if not package_name.isalnum(): + print("Invalid package name, must be alphanumeric") + return + + package_version = str(package_data.get("version")) + if not package_version: + print("Missing 'version' field in 'package.toml'") + return + + if not package_data.get("description"): + print("Missing 'description' field in 'package.toml'") + return + + # Zip the package + shutil.make_archive(os.path.join(packages_dir, "temp"), "zip", package_folder) + + with open(os.path.join(packages_dir, "temp.zip"), "rb") as package_file: + files = {"package_file": package_file.read()} + + if len(files["package_file"]) > 10**7: + print("Zipped package too large: >1MB") + return + + # Upload the package + try: + response = None + response = requests.post( + f"{server}/api/upload", + headers={"X-API-Key": str(key)}, + files=files, + timeout=5, + ) + response.raise_for_status() + print(f"Package '{package_name}@{package_version}' uploaded successfully") + except requests.RequestException: + if response: + if response.status_code in (400, 401, 403): + CONFIG["api_key"] = None + print("Invalid API key") + else: + try: + print(response.json()["error"]) + except json.JSONDecodeError: + print("Error uploading package: ", response.text) + else: + print("Error uploading package") + + os.remove(os.path.join(packages_dir, "temp.zip")) + + +def delete_package(package: str, server: str) -> None: + """Delete a package from the server.""" + + if not server.startswith("http://") and not server.startswith("https://"): + server = "https://" + server + + if not server.endswith("/"): + server += "/" + + key = get_key(server) + + while True: + answer = input( + f"Are you sure you want to delete '{package}'\n" + "THIS ACTION IS PERMANENT AND CANNOT BE UNDONE\n(y/N): " + ) + if answer.lower() in ["y", "yes"]: + break + if answer.lower() in ["n", "no", ""]: + print("Deletion cancelled") + return + print("Invalid input") + + try: + response = None + response = requests.post( + f"{server}/api/delete", + headers={"X-API-Key": str(key), "Content-Type": "application/json"}, + data=json.dumps({"package": package}).encode("utf-8"), + timeout=5, + ) + response.raise_for_status() + print(f"Package '{package}' deleted successfully") + except requests.RequestException: + if response: + if response.status_code == 401: + CONFIG["api_key"] = None + try: + print(response.json()["error"]) + except json.JSONDecodeError: + print("Error uploading package: ", response.text) + else: + print("Error uploading package") + return + + +def get_key(server: str) -> str | None: + """Get and save the API key.""" + + cfg = get_config() + + if cfg.get("api_key"): + return cfg["api_key"] + + print(f"\nPlease visit the following url to get your API key:\n{server}dashboard") + + key = input("\nAPI Key: ") + with open(os.path.join(packages_dir, CFG_FILE), "w", encoding="utf8") as file: + cfg["api_key"] = key + json.dump(cfg, file) + + return key diff --git a/eryx/runtime/interpreter.py b/eryx/runtime/interpreter.py index 452038d..4817e1c 100644 --- a/eryx/runtime/interpreter.py +++ b/eryx/runtime/interpreter.py @@ -1,5 +1,6 @@ """Interpreter for the runtime.""" +import json import os from eryx.frontend.ast import ( @@ -21,7 +22,8 @@ VariableDeclaration, ) from eryx.frontend.parser import Parser -from eryx.runtime.environment import Environment, BUILTINS +from eryx.packages.packages import CFG_FILE, INSTALLED_PACKAGES_LOC, packages_dir +from eryx.runtime.environment import BUILTINS, Environment from eryx.runtime.values import ( ArrayValue, BooleanValue, @@ -135,13 +137,41 @@ def eval_import_statement( else: raise RuntimeError(f"Error importing builtin '{module_name}'") else: - if not os.path.exists(module_name): - raise RuntimeError(f"File '{module_name}.eryx' does not exist.") + if module_name.endswith(".eryx"): + if not os.path.exists(module_name): + raise RuntimeError(f"File '{module_name}.eryx' does not exist.") + + # Import the file + file_path = module_name + with open(file_path + ".eryx", "r", encoding="utf8") as file: + source_code = file.read() + else: + try: + cfg_file_path = os.path.join(packages_dir, CFG_FILE) + with open(cfg_file_path, "r", encoding="utf8") as file: + cfg = json.load(file) + except (FileNotFoundError, json.JSONDecodeError) as e: + raise RuntimeError(f"Package '{module_name}' not found.") from e + + installed_packages = cfg.get("installed_packages", {}) + if not installed_packages or module_name not in installed_packages: + raise RuntimeError(f"Package '{module_name}' not found.") + + package_path = os.path.join( + packages_dir, INSTALLED_PACKAGES_LOC, module_name + ) + if not os.path.exists(package_path): + raise RuntimeError(f"Installed package '{module_name}' not found.") + + entrypoint = os.path.join(package_path, "main.eryx") + if not os.path.exists(entrypoint): + raise RuntimeError( + "Entrypoint 'main.eryx' not found in " + f"installed package '{module_name}'." + ) - # Import the file - file_path = module_name - with open(file_path + ".eryx", "r", encoding="utf8") as file: - source_code = file.read() + with open(entrypoint, "r", encoding="utf8") as file: + source_code = file.read() # Run the code new_environment = Environment(parent_env=environment) diff --git a/package-manager/.env.example b/package-manager/.env.example new file mode 100644 index 0000000..4f2c471 --- /dev/null +++ b/package-manager/.env.example @@ -0,0 +1,7 @@ +SECRET_KEY= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +DATABASE_URL= +MINIO_URL= +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= \ No newline at end of file diff --git a/package-manager/main.py b/package-manager/main.py index c7e3fd1..310f312 100644 --- a/package-manager/main.py +++ b/package-manager/main.py @@ -16,21 +16,38 @@ session, url_for, ) -from flask_caching import Cache from flask_dance.contrib.github import github, make_github_blueprint from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_sqlalchemy import SQLAlchemy from flask_talisman import Talisman from minio import Minio +from minio.deleteobjects import DeleteObject from minio.error import S3Error from packaging.version import InvalidVersion, Version +# from flask_caching import Cache load_dotenv() +WIPE_DATABASE = False # THIS WILL WIPE THE DATABASE AND BUCKET ON STARTUP + PACKAGES_BUCKET = "eryx-packages" + +class ForceHTTPS: # Needed on nest because of flask dance being dumb + """Fix for Flask Dance OAuth issues with HTTPS.""" + + def __init__(self, app): # pylint: disable=redefined-outer-name + self.app = app + + def __call__(self, environ, start_response): + # Force the URL scheme to HTTPS + environ["wsgi.url_scheme"] = "https" + return self.app(environ, start_response) + + app = Flask(__name__) +app.wsgi_app = ForceHTTPS(app.wsgi_app) app.secret_key = os.getenv("SECRET_KEY") app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=7) @@ -58,7 +75,15 @@ } # Create cache -cache = Cache(app, config={"CACHE_TYPE": "simple"}) +# cache = Cache(app, config={"CACHE_TYPE": "simple"}) + +csp = { + 'default-src': ["'self'"], + 'img-src': ["'self'", "https://avatars.githubusercontent.com"] +} + +# Enable HTTPS +Talisman(app, force_https=False, content_security_policy=csp) # Initialize SQLAlchemy db = SQLAlchemy(app) @@ -70,10 +95,6 @@ ) app.register_blueprint(github_blueprint, url_prefix="/login") -# Enable HTTPS -Talisman(app, content_security_policy=None) - - class User(db.Model): """User db table""" @@ -99,6 +120,7 @@ class Package(db.Model): @property def latest_release(self): + """Latest release for a package.""" return self.releases.order_by(Release.release_date.desc()).first() # type: ignore @@ -115,10 +137,12 @@ class Release(db.Model): file_path = db.Column(db.String(256), nullable=False) def update_parent_version(self): + """Update the parent package's latest version.""" self.package.latest_version = self.version # type: ignore db.session.commit() def increment_download_count(self): + """Increment the download count for a release.""" self.download_count += 1 self.package.download_count += 1 # type: ignore db.session.commit() @@ -176,12 +200,12 @@ def api_upload_package(): file = request.files.get("package_file") - file_stream = file.stream # type: ignore - file.seek(0) # type: ignore - if not file or not file.filename: return jsonify({"error": "Missing package file"}), 400 + file_stream = file.stream # type: ignore + file.seek(0) # type: ignore + files = read_files_from_zip(file, ["package.toml", "README.md", "main.eryx"]) package_toml, readme, entrypoint = ( files.get("package.toml"), @@ -221,13 +245,14 @@ def api_upload_package(): if not existing_package.author_id == user.id: return jsonify({"error": "You are not the owner of this package"}), 403 - if Version(existing_package.version) <= version: - return jsonify({"error": "Invalid package version, version too low"}), 409 + if Version(existing_package.latest_version) >= version: + return jsonify({"error": f"Can not update package, uploaded version <= current " \ + f"version ({version} <= {existing_package.latest_version})"}), 409 name = str(name).lower() - if not name.isalpha(): - return jsonify({"error": "Package name must be alphabetic"}), 400 + if not name.isalnum(): + return jsonify({"error": "Package name must be alphanumeric"}), 400 if (request.content_length or 1e10) > 1024 * 1024: # 1 MB return jsonify({"error": "Request size must be less than 1MB"}), 400 @@ -291,6 +316,59 @@ def api_upload_package(): return jsonify({"message": "Package uploaded successfully"}), 201 +@app.route("/api/versions/") +def package_versions(package_name): + """Package versions endpoint.""" + package = Package.query.filter_by(name=package_name).first() + if package: + releases = package.releases.all() + return jsonify({"versions": [r.version for r in releases]}) + return render_template("notfound.html"), 404 + + +@app.route("/api/delete", methods=["POST"]) +@limiter.limit("10 per hour") +def delete_package(): + """Endpoint to delete a package (and all its releases).""" + json_data = request.json + + if not json_data: + return jsonify({"error": "Missing JSON data"}), 400 + + package = json_data.get("package") + api_key = request.headers.get("X-API-Key") + + if not api_key: + return jsonify({"error": "Invalid API key"}), 401 + + user = User.query.filter_by(api_key=api_key).first() + if not user: + return jsonify({"error": "Invalid API key"}), 401 + + if not package: + return jsonify({"error": "Missing package name"}), 400 + + package = Package.query.filter_by(name=package).first() + if not package: + return jsonify({"error": "Package not found"}), 404 + + if not package.author_id == user.id: + return jsonify({"error": "You are not the owner of this package"}), 403 + + for release in package.releases.all(): + db.session.delete(release) + try: + client.remove_object(PACKAGES_BUCKET, release.file_path) + except S3Error as e: + print("Failed to delete file", e) + return jsonify({"error": "Failed to delete package"}), 500 + + db.session.delete(package) + db.session.commit() + + return jsonify({"message": "Package deleted successfully"}), 200 + + @app.route("/api/refresh-key", methods=["POST"]) @limiter.limit("10 per hour") def refresh_api_key(): @@ -308,7 +386,6 @@ def refresh_api_key(): @app.route("/package/") -@cache.cached(timeout=30) def package_detail(package_name): """Package details endpoint.""" package = Package.query.filter_by(name=package_name).first() @@ -341,9 +418,9 @@ def download_package(package_name): Release.increment_download_count(release) - return client.presigned_get_object( + return redirect(client.presigned_get_object( PACKAGES_BUCKET, release.file_path, expires=timedelta(minutes=1) - ) + )) return "Package not found", 404 @@ -358,14 +435,13 @@ def download_package_version(package_name, version): Release.increment_download_count(release) - return client.presigned_get_object( + return redirect(client.presigned_get_object( PACKAGES_BUCKET, release.file_path, expires=timedelta(minutes=1) - ) + )) return "Package not found", 404 @app.route("/") -@cache.cached(timeout=30) @limiter.limit("30 per minute") def home(): """Homepage endpoint.""" @@ -431,15 +507,49 @@ def staticfiles(filename): return app.send_static_file(filename) +@app.route("/ping", methods=["GET"]) +def ping(): + """Health check endpoint.""" + return "Server is running", 200 + + @app.context_processor def inject_github(): - """Inject github object into all templates""" + """Inject github object into all templates.""" return {"github": github} +def clear_bucket_batch(bucket_name): + """Clear all objects in a bucket in a batch.""" + try: + objects = client.list_objects(bucket_name) + delete_objects = [ + DeleteObject(obj.object_name) for obj in objects if obj.object_name + ] + + # Remove objects in batch + if delete_objects: + client.remove_objects(bucket_name, delete_objects) + print(f"All objects in bucket '{bucket_name}' have been deleted.") + else: + print("No objects found in the bucket.") + except S3Error as e: + print(f"Error occurred: {e}") + + if __name__ == "__main__": + if WIPE_DATABASE: + clear_bucket_batch(PACKAGES_BUCKET) + with app.app_context(): + db.drop_all() with app.app_context(): db.create_all() - if not client.bucket_exists(PACKAGES_BUCKET): - client.make_bucket(PACKAGES_BUCKET) - app.run(host="localhost", port=5000, ssl_context=("cert.pem", "key.pem")) + # if not client.bucket_exists(PACKAGES_BUCKET): + # client.make_bucket(PACKAGES_BUCKET) + app.run( + host="127.0.0.1", + port=5000, + ssl_context=("cert.pem", "key.pem"), # Local certs for testing + debug=False, + use_reloader=False, + ) diff --git a/package-manager/requirements.txt b/package-manager/requirements.txt index aed150b..e351f99 100644 --- a/package-manager/requirements.txt +++ b/package-manager/requirements.txt @@ -1,9 +1,9 @@ Flask -Flask-Caching Flask-Dance Flask-Limiter Flask-SQLAlchemy Flask-Talisman minio packaging -python-dotenv \ No newline at end of file +python-dotenv +toml \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 32fbe91..7d7d90a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ colorama flask -pytest \ No newline at end of file +pytest +toml +requests \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 7ccb483..50c7b1d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,8 @@ install_requires = colorama flask pytest + toml + requests include_package_data = True [options.entry_points]