diff --git a/easyinstall.sh b/easyinstall.sh index da4bab1b..f358fad1 100755 --- a/easyinstall.sh +++ b/easyinstall.sh @@ -82,7 +82,7 @@ function sudoCheck() { # install all dependency packages for RaSCSI Service function installPackages() { - sudo apt-get update && sudo apt-get install git libspdlog-dev libpcap-dev genisoimage python3 python3-venv python3-dev python3-pip nginx libpcap-dev protobuf-compiler bridge-utils libev-dev libevdev2 -y .+)".$' + unar_result_no_files = "No files extracted." + unar_file_extracted = \ + r"^ (?P.+). \(((?P[0-9]+) B)?(?P(dir)?(, )?(rsrc)?)\)\.\.\. (?P[A-Z]+)\.$" + + lines = process["stdout"].rstrip("\n").split("\n") + + if lines[-1] == unar_result_no_files: + raise UnarNoFilesExtractedError + + if match(unar_result_success, lines[-1]): + extracted_members = [] + + for line in lines[1:-1]: + if line_matches := match(unar_file_extracted, line): + matches = line_matches.groupdict() + member = { + "name": str(pathlib.PurePath(matches["path"]).name), + "path": matches["path"], + "size": matches["size"] or 0, + "is_dir": False, + "is_resource_fork": False, + "absolute_path": str(pathlib.PurePath(tmp_dir).joinpath(matches["path"])), + } + + member_types = matches.get("types", "").removeprefix(", ").split(", ") + + if "dir" in member_types: + member["is_dir"] = True + + if "rsrc" in member_types: + if not fork_output_type: + continue + + member["is_resource_fork"] = True + + # Update names/paths to match unar resource fork naming convention + if fork_output_type == FORK_OUTPUT_TYPE_HIDDEN: + member["name"] = f"._{member['name']}" + else: + member["name"] += ".rsrc" + member["path"] = str(pathlib.PurePath(member["path"]).parent.joinpath(member["name"])) + member["absolute_path"] = str(pathlib.PurePath(tmp_dir).joinpath(member["path"])) + + logging.debug("Extracted: %s -> %s", member['path'], member['absolute_path']) + extracted_members.append(member) + else: + raise UnarUnexpectedOutputError(f"Unexpected output: {line}") + + moved = [] + skipped = [] + for member in sorted(extracted_members, key=lambda m: m["path"]): + source_path = pathlib.Path(member["absolute_path"]) + target_path = pathlib.Path(output_dir).joinpath(member["path"]) + member["absolute_path"] = str(target_path) + + if target_path.exists(): + logging.info("Skipping temp file/dir as the target already exists: %s", target_path) + skipped.append(member) + continue + + if member["is_dir"]: + logging.debug("Creating empty dir: %s -> %s", source_path, target_path) + target_path.mkdir(parents=True, exist_ok=True) + moved.append(member) + continue + + # The parent dir may not be specified as a member, so ensure it exists + target_path.parent.mkdir(parents=True, exist_ok=True) + logging.debug("Moving temp file: %s -> %s", source_path, target_path) + source_path.rename(target_path) + moved.append(member) + + return { + "extracted": moved, + "skipped": skipped, + } + + raise UnarUnexpectedOutputError(lines[-1]) + + +def inspect_archive(file_path, **kwargs): + """ + Calls `lsar` to inspect the contents of an archive + Takes (str) file_path + Returns (dict) of (str) format, (list) members + """ + if not pathlib.Path(file_path): + raise FileNotFoundError(f"File {file_path} does not exist") + + process = run("lsar", ["-json", "--", file_path]) + + if process["returncode"] != 0: + raise LsarCommandError(f"Non-zero return code: {process['returncode']}") + + try: + archive_info = loads(process["stdout"]) + except JSONDecodeError as error: + raise LsarOutputError(f"Unable to read JSON output from lsar: {error.msg}") from error + + members = [{ + "name": pathlib.PurePath(member.get("XADFileName")).name, + "path": member.get("XADFileName"), + "size": member.get("XADFileSize"), + "is_dir": member.get("XADIsDirectory"), + "is_resource_fork": member.get("XADIsResourceFork"), + "raw": member, + } for member in archive_info.get("lsarContents", [])] + + return { + "format": archive_info.get("lsarFormatName"), + "members": members, + } + + +class UnarCommandError(Exception): + """ Command execution was unsuccessful """ + pass + + +class UnarNoFilesExtractedError(Exception): + """ Command completed, but no files extracted """ + + +class UnarUnexpectedOutputError(Exception): + """ Command output not recognized """ + + +class LsarCommandError(Exception): + """ Command execution was unsuccessful """ + + +class LsarOutputError(Exception): + """ Command output could not be parsed""" diff --git a/python/web/src/return_code_mapper.py b/python/web/src/return_code_mapper.py index 3f30ff91..94d9a5f3 100644 --- a/python/web/src/return_code_mapper.py +++ b/python/web/src/return_code_mapper.py @@ -37,6 +37,14 @@ class ReturnCodeMapper: _("Could not read properties from file: %(file_path)s"), ReturnCodes.ATTACHIMAGE_COULD_NOT_ATTACH: _("Cannot insert an image for %(device_type)s into a %(current_device_type)s device"), + ReturnCodes.EXTRACTIMAGE_SUCCESS: + _("Extracted %(count)s file(s)"), + ReturnCodes.EXTRACTIMAGE_NO_FILES_SPECIFIED: + _("Unable to extract archive: No files were specified"), + ReturnCodes.EXTRACTIMAGE_NO_FILES_EXTRACTED: + _("No files were extracted (existing files are skipped)"), + ReturnCodes.EXTRACTIMAGE_COMMAND_ERROR: + _("Unable to extract archive: %(error)s"), } @staticmethod diff --git a/python/web/src/settings.py b/python/web/src/settings.py index f7988200..5ce3fea4 100644 --- a/python/web/src/settings.py +++ b/python/web/src/settings.py @@ -12,8 +12,6 @@ AFP_DIR = f"{HOME_DIR}/afpshare" MAX_FILE_SIZE = getenv("MAX_FILE_SIZE", str(1024 * 1024 * 1024 * 4)) # 4gb -ARCHIVE_FILE_SUFFIX = "zip" - # The file name of the default config file that loads when rascsi-web starts DEFAULT_CONFIG = f"default.{rascsi.common_settings.CONFIG_FILE_SUFFIX}" # File containing canonical drive properties diff --git a/python/web/src/static/style.css b/python/web/src/static/style.css index cf7ebf5c..48fc91c6 100644 --- a/python/web/src/static/style.css +++ b/python/web/src/static/style.css @@ -35,12 +35,14 @@ table, tr, td { color: white; font-size:20px; background-color:red; + white-space: pre-line; } .message { color: white; font-size:20px; background-color:green; + white-space: pre-line; } td.inactive { diff --git a/python/web/src/templates/index.html b/python/web/src/templates/index.html index 49c2d096..fb984d71 100644 --- a/python/web/src/templates/index.html +++ b/python/web/src/templates/index.html @@ -185,41 +185,41 @@ - {% elif file["zip_members"] %} + {% elif file["archive_contents"] %}
{{ file["name"] }}
    - {% for member in file["zip_members"] %} - {% if not member.lower().endswith(PROPERTIES_SUFFIX) %} -
  • - {% if member + "." + PROPERTIES_SUFFIX in file["zip_members"] %} -
    {{ member }} -
    - - - -
    -
    -
      + {% for member in file["archive_contents"] %} + {% if not member["is_properties_file"] %}
    • - {{ member + "." + PROPERTIES_SUFFIX }} + {% if member["related_properties_file"] %} +
      + + +
      + + + +
      +
      +
        +
      • {{ member["related_properties_file"] }}
      • +
      +
      + {% else %} + +
      + + + +
      + {% endif %}
    • -
    -
    - {% else %} - -
    - - - -
    {% endif %} -
  • - {% endif %} - {% endfor %} + {% endfor %}
@@ -238,11 +238,12 @@ {{ _("Attached!") }} {% else %} - {% if file["name"].lower().endswith(ARCHIVE_FILE_SUFFIX) %} -
- - - + {% if file["archive_contents"] %} + + + {% set pipe = joiner("|") %} + +
{% else %}
diff --git a/python/web/src/web.py b/python/web/src/web.py index d5eb8848..c38a2b61 100644 --- a/python/web/src/web.py +++ b/python/web/src/web.py @@ -8,9 +8,9 @@ import argparse from pathlib import Path from functools import wraps from grp import getgrall -from ast import literal_eval import bjoern +from rascsi.return_codes import ReturnCodes from werkzeug.utils import secure_filename from simplepam import authenticate from flask_babel import Babel, Locale, refresh, _ @@ -37,6 +37,7 @@ from rascsi.common_settings import ( CFG_DIR, CONFIG_FILE_SUFFIX, PROPERTIES_SUFFIX, + ARCHIVE_FILE_SUFFIXES, RESERVATIONS, ) @@ -55,7 +56,6 @@ from web_utils import ( from settings import ( AFP_DIR, MAX_FILE_SIZE, - ARCHIVE_FILE_SUFFIX, DEFAULT_CONFIG, DRIVE_PROPERTIES_FILE, AUTH_GROUP, @@ -133,14 +133,13 @@ def index(): scsi_ids, recommended_id = get_valid_scsi_ids(devices["device_list"], reserved_scsi_ids) formatted_devices = sort_and_format_devices(devices["device_list"]) - valid_file_suffix = "."+", .".join( + valid_file_suffix = "." + ", .".join( server_info["sahd"] + server_info["schd"] + server_info["scrm"] + server_info["scmo"] + server_info["sccd"] + - [ARCHIVE_FILE_SUFFIX] - ) + ARCHIVE_FILE_SUFFIXES) if "username" in session: username = session["username"] @@ -182,7 +181,6 @@ def index(): mo_file_suffix=tuple(server_info["scmo"]), username=username, auth_active=auth_active(AUTH_GROUP)["status"], - ARCHIVE_FILE_SUFFIX=ARCHIVE_FILE_SUFFIX, PROPERTIES_SUFFIX=PROPERTIES_SUFFIX, REMOVABLE_DEVICE_TYPES=ractl_cmd.get_removable_device_types(), DISK_DEVICE_TYPES=ractl_cmd.get_disk_device_types(), @@ -945,33 +943,38 @@ def copy(): return redirect(url_for("index")) -@APP.route("/files/unzip", methods=["POST"]) +@APP.route("/files/extract_image", methods=["POST"]) @login_required -def unzip(): +def extract_image(): """ - Unzips all files in a specified zip archive, or a single file in the zip archive + Extracts all or a subset of files in the specified archive """ - zip_file = request.form.get("zip_file") - zip_member = request.form.get("zip_member") or False - zip_members = request.form.get("zip_members") or False + archive_file = request.form.get("archive_file") + archive_members_raw = request.form.get("archive_members") or None + archive_members = archive_members_raw.split("|") if archive_members_raw else None - if zip_members: - zip_members = literal_eval(zip_members) + extract_result = file_cmd.extract_image( + archive_file, + archive_members + ) - process = file_cmd.unzip_file(zip_file, zip_member, zip_members) - if process["status"]: - if not process["msg"]: - flash(_("Aborted unzip: File(s) with the same name already exists."), "error") - return redirect(url_for("index")) - flash(_("Unzipped the following files:")) - for msg in process["msg"]: - flash(msg) - if process["prop_flag"]: - flash(_("Properties file(s) have been moved to %(directory)s", directory=CFG_DIR)) - return redirect(url_for("index")) + if extract_result["return_code"] == ReturnCodes.EXTRACTIMAGE_SUCCESS: + flash(ReturnCodeMapper.add_msg(extract_result).get("msg")) + + for properties_file in extract_result["properties_files_moved"]: + if properties_file["status"]: + flash(_("Properties file %(file)s moved to %(directory)s", + file=properties_file['name'], + directory=CFG_DIR + )) + else: + flash(_("Failed to move properties file %(file)s to %(directory)s", + file=properties_file['name'], + directory=CFG_DIR + ), "error") + else: + flash(ReturnCodeMapper.add_msg(extract_result).get("msg"), "error") - flash(_("Failed to unzip %(zip_file)s", zip_file=zip_file), "error") - flash(process["msg"], "error") return redirect(url_for("index")) diff --git a/python/web/start.sh b/python/web/start.sh index a2013a7a..7954c0ea 100755 --- a/python/web/start.sh +++ b/python/web/start.sh @@ -25,6 +25,11 @@ if ! command -v unzip &> /dev/null ; then echo "Run 'sudo apt install unzip' to fix." ERROR=1 fi +if ! command -v unar &> /dev/null ; then + echo "unar could not be found" + echo "Run 'sudo apt install unar' to fix." + ERROR=1 +fi if [ $ERROR = 1 ] ; then echo echo "Fix errors and re-run ./start.sh"