view sat_frontends/jp/cmd_file.py @ 3028:ab2696e34d29

Python 3 port: /!\ this is a huge commit /!\ starting from this commit, SàT is needs Python 3.6+ /!\ SàT maybe be instable or some feature may not work anymore, this will improve with time This patch port backend, bridge and frontends to Python 3. Roughly this has been done this way: - 2to3 tools has been applied (with python 3.7) - all references to python2 have been replaced with python3 (notably shebangs) - fixed files not handled by 2to3 (notably the shell script) - several manual fixes - fixed issues reported by Python 3 that where not handled in Python 2 - replaced "async" with "async_" when needed (it's a reserved word from Python 3.7) - replaced zope's "implements" with @implementer decorator - temporary hack to handle data pickled in database, as str or bytes may be returned, to be checked later - fixed hash comparison for password - removed some code which is not needed anymore with Python 3 - deactivated some code which needs to be checked (notably certificate validation) - tested with jp, fixed reported issues until some basic commands worked - ported Primitivus (after porting dependencies like urwid satext) - more manual fixes
author Goffi <goffi@goffi.org>
date Tue, 13 Aug 2019 19:08:41 +0200
parents 18a98a541f7a
children fee60f17ebac
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# jp: a SAT command line tool
# Copyright (C) 2009-2019 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


from . import base
import sys
import os
import os.path
import tarfile
from sat.core.i18n import _
from sat.tools.common import data_format
from sat_frontends.jp.constants import Const as C
from sat_frontends.jp import common
from sat_frontends.tools import jid
from sat.tools.common.ansi import ANSI as A
import tempfile
import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
from functools import partial
import json

__commands__ = ["File"]


class Send(base.CommandBase):
    def __init__(self, host):
        super(Send, self).__init__(
            host,
            "send",
            use_progress=True,
            use_verbose=True,
            help=_("send a file to a contact"),
        )
        self.need_loop = True

    def add_parser_options(self):
        self.parser.add_argument(
            "files", type=str, nargs="+", metavar="file", help=_("a list of file")
        )
        self.parser.add_argument(
            "jid", help=_("the destination jid")
        )
        self.parser.add_argument(
            "-b", "--bz2", action="store_true", help=_("make a bzip2 tarball")
        )
        self.parser.add_argument(
            "-d",
            "--path",
            help=("path to the directory where the file must be stored"),
        )
        self.parser.add_argument(
            "-N",
            "--namespace",
            help=("namespace of the file"),
        )
        self.parser.add_argument(
            "-n",
            "--name",
            default="",
            help=("name to use (DEFAULT: use source file name)"),
        )

    def start(self):
        """Send files to jabber contact"""
        self.send_files()

    def onProgressStarted(self, metadata):
        self.disp(_("File copy started"), 2)

    def onProgressFinished(self, metadata):
        self.disp(_("File sent successfully"), 2)

    def onProgressError(self, error_msg):
        if error_msg == C.PROGRESS_ERROR_DECLINED:
            self.disp(_("The file has been refused by your contact"))
        else:
            self.disp(_("Error while sending file: {}").format(error_msg), error=True)

    def gotId(self, data, file_):
        """Called when a progress id has been received

        @param pid(unicode): progress id
        @param file_(str): file path
        """
        # FIXME: this show progress only for last progress_id
        self.disp(_("File request sent to {jid}".format(jid=self.full_dest_jid)), 1)
        try:
            self.progress_id = data["progress"]
        except KeyError:
            # TODO: if 'xmlui' key is present, manage xmlui message display
            self.disp(
                _("Can't send file to {jid}".format(jid=self.full_dest_jid)), error=True
            )
            self.host.quit(2)

    def error(self, failure):
        self.disp(
            _("Error while trying to send a file: {reason}").format(reason=failure),
            error=True,
        )
        self.host.quit(1)

    def send_files(self):
        for file_ in self.args.files:
            if not os.path.exists(file_):
                self.disp(_("file [{}] doesn't exist !").format(file_), error=True)
                self.host.quit(1)
            if not self.args.bz2 and os.path.isdir(file_):
                self.disp(
                    _(
                        "[{}] is a dir ! Please send files inside or use compression"
                    ).format(file_)
                )
                self.host.quit(1)

        self.full_dest_jid = self.host.get_full_jid(self.args.jid)
        extra = {}
        if self.args.path:
            extra["path"] = self.args.path
        if self.args.namespace:
            extra["namespace"] = self.args.namespace

        if self.args.bz2:
            with tempfile.NamedTemporaryFile("wb", delete=False) as buf:
                self.host.addOnQuitCallback(os.unlink, buf.name)
                self.disp(_("bz2 is an experimental option, use with caution"))
                # FIXME: check free space
                self.disp(_("Starting compression, please wait..."))
                sys.stdout.flush()
                bz2 = tarfile.open(mode="w:bz2", fileobj=buf)
                archive_name = "{}.tar.bz2".format(
                    os.path.basename(self.args.files[0]) or "compressed_files"
                )
                for file_ in self.args.files:
                    self.disp(_("Adding {}").format(file_), 1)
                    bz2.add(file_)
                bz2.close()
                self.disp(_("Done !"), 1)

                self.host.bridge.fileSend(
                    self.full_dest_jid,
                    buf.name,
                    self.args.name or archive_name,
                    "",
                    extra,
                    self.profile,
                    callback=lambda pid, file_=buf.name: self.gotId(pid, file_),
                    errback=self.error,
                )
        else:
            for file_ in self.args.files:
                path = os.path.abspath(file_)
                self.host.bridge.fileSend(
                    self.full_dest_jid,
                    path,
                    self.args.name,
                    "",
                    extra,
                    self.profile,
                    callback=lambda pid, file_=file_: self.gotId(pid, file_),
                    errback=self.error,
                )


class Request(base.CommandBase):
    def __init__(self, host):
        super(Request, self).__init__(
            host,
            "request",
            use_progress=True,
            use_verbose=True,
            help=_("request a file from a contact"),
        )
        self.need_loop = True

    @property
    def filename(self):
        return self.args.name or self.args.hash or "output"

    def add_parser_options(self):
        self.parser.add_argument(
            "jid", help=_("the destination jid")
        )
        self.parser.add_argument(
            "-D",
            "--dest",
            help=_(
                "destination path where the file will be saved (default: [current_dir]/[name|hash])"
            ),
        )
        self.parser.add_argument(
            "-n",
            "--name",
            default="",
            help=_("name of the file"),
        )
        self.parser.add_argument(
            "-H",
            "--hash",
            default="",
            help=_("hash of the file"),
        )
        self.parser.add_argument(
            "-a",
            "--hash-algo",
            default="sha-256",
            help=_("hash algorithm use for --hash (default: sha-256)"),
        )
        self.parser.add_argument(
            "-d",
            "--path",
            help=("path to the directory containing the file"),
        )
        self.parser.add_argument(
            "-N",
            "--namespace",
            help=("namespace of the file"),
        )
        self.parser.add_argument(
            "-f",
            "--force",
            action="store_true",
            help=_("overwrite existing file without confirmation"),
        )

    def onProgressStarted(self, metadata):
        self.disp(_("File copy started"), 2)

    def onProgressFinished(self, metadata):
        self.disp(_("File received successfully"), 2)

    def onProgressError(self, error_msg):
        if error_msg == C.PROGRESS_ERROR_DECLINED:
            self.disp(_("The file request has been refused"))
        else:
            self.disp(_("Error while requesting file: {}").format(error_msg), error=True)

    def gotId(self, progress_id):
        """Called when a progress id has been received

        @param progress_id(unicode): progress id
        """
        self.progress_id = progress_id

    def error(self, failure):
        self.disp(
            _("Error while trying to send a file: {reason}").format(reason=failure),
            error=True,
        )
        self.host.quit(1)

    def start(self):
        if not self.args.name and not self.args.hash:
            self.parser.error(_("at least one of --name or --hash must be provided"))
        #  extra = dict(self.args.extra)
        if self.args.dest:
            path = os.path.abspath(os.path.expanduser(self.args.dest))
            if os.path.isdir(path):
                path = os.path.join(path, self.filename)
        else:
            path = os.path.abspath(self.filename)

        if os.path.exists(path) and not self.args.force:
            message = _("File {path} already exists! Do you want to overwrite?").format(
                path=path
            )
            confirm = input("{} (y/N) ".format(message).encode("utf-8"))
            if confirm not in ("y", "Y"):
                self.disp(_("file request cancelled"))
                self.host.quit(2)

        self.full_dest_jid = self.host.get_full_jid(self.args.jid)
        extra = {}
        if self.args.path:
            extra["path"] = self.args.path
        if self.args.namespace:
            extra["namespace"] = self.args.namespace
        self.host.bridge.fileJingleRequest(
            self.full_dest_jid,
            path,
            self.args.name,
            self.args.hash,
            self.args.hash_algo if self.args.hash else "",
            extra,
            self.profile,
            callback=self.gotId,
            errback=partial(
                self.errback,
                msg=_("can't request file: {}"),
                exit_code=C.EXIT_BRIDGE_ERRBACK,
            ),
        )


class Receive(base.CommandAnswering):
    def __init__(self, host):
        super(Receive, self).__init__(
            host,
            "receive",
            use_progress=True,
            use_verbose=True,
            help=_("wait for a file to be sent by a contact"),
        )
        self._overwrite_refused = False  # True when one overwrite as already been refused
        self.action_callbacks = {
            C.META_TYPE_FILE: self.onFileAction,
            C.META_TYPE_OVERWRITE: self.onOverwriteAction,
        }

    def onProgressStarted(self, metadata):
        self.disp(_("File copy started"), 2)

    def onProgressFinished(self, metadata):
        self.disp(_("File received successfully"), 2)
        if metadata.get("hash_verified", False):
            try:
                self.disp(
                    _("hash checked: {algo}:{checksum}").format(
                        algo=metadata["hash_algo"], checksum=metadata["hash"]
                    ),
                    1,
                )
            except KeyError:
                self.disp(_("hash is checked but hash value is missing", 1), error=True)
        else:
            self.disp(_("hash can't be verified"), 1)

    def onProgressError(self, error_msg):
        self.disp(_("Error while receiving file: {}").format(error_msg), error=True)

    def getXmluiId(self, action_data):
        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
        #        should be available in the futur
        # TODO: XMLUI module
        try:
            xml_ui = action_data["xmlui"]
        except KeyError:
            self.disp(_("Action has no XMLUI"), 1)
        else:
            ui = ET.fromstring(xml_ui.encode("utf-8"))
            xmlui_id = ui.get("submit")
            if not xmlui_id:
                self.disp(_("Invalid XMLUI received"), error=True)
            return xmlui_id

    def onFileAction(self, action_data, action_id, security_limit, profile):
        xmlui_id = self.getXmluiId(action_data)
        if xmlui_id is None:
            return self.host.quitFromSignal(1)
        try:
            from_jid = jid.JID(action_data["meta_from_jid"])
        except KeyError:
            self.disp(_("Ignoring action without from_jid data"), 1)
            return
        try:
            progress_id = action_data["meta_progress_id"]
        except KeyError:
            self.disp(_("ignoring action without progress id"), 1)
            return

        if not self.bare_jids or from_jid.bare in self.bare_jids:
            if self._overwrite_refused:
                self.disp(_("File refused because overwrite is needed"), error=True)
                self.host.bridge.launchAction(
                    xmlui_id, {"cancelled": C.BOOL_TRUE}, profile_key=profile
                )
                return self.host.quitFromSignal(2)
            self.progress_id = progress_id
            xmlui_data = {"path": self.path}
            self.host.bridge.launchAction(xmlui_id, xmlui_data, profile_key=profile)

    def onOverwriteAction(self, action_data, action_id, security_limit, profile):
        xmlui_id = self.getXmluiId(action_data)
        if xmlui_id is None:
            return self.host.quitFromSignal(1)
        try:
            progress_id = action_data["meta_progress_id"]
        except KeyError:
            self.disp(_("ignoring action without progress id"), 1)
            return
        self.disp(_("Overwriting needed"), 1)

        if progress_id == self.progress_id:
            if self.args.force:
                self.disp(_("Overwrite accepted"), 2)
            else:
                self.disp(_("Refused to overwrite"), 2)
                self._overwrite_refused = True

            xmlui_data = {"answer": C.boolConst(self.args.force)}
            self.host.bridge.launchAction(xmlui_id, xmlui_data, profile_key=profile)

    def add_parser_options(self):
        self.parser.add_argument(
            "jids",
            nargs="*",
            help=_("jids accepted (accept everything if none is specified)"),
        )
        self.parser.add_argument(
            "-m",
            "--multiple",
            action="store_true",
            help=_("accept multiple files (you'll have to stop manually)"),
        )
        self.parser.add_argument(
            "-f",
            "--force",
            action="store_true",
            help=_(
                "force overwritting of existing files (/!\\ name is choosed by sender)"
            ),
        )
        self.parser.add_argument(
            "--path",
            default=".",
            metavar="DIR",
            help=_("destination path (default: working directory)"),
        )

    def start(self):
        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
        self.path = os.path.abspath(self.args.path)
        if not os.path.isdir(self.path):
            self.disp(_("Given path is not a directory !", error=True))
            self.host.quit(2)
        if self.args.multiple:
            self.host.quit_on_progress_end = False
        self.disp(_("waiting for incoming file request"), 2)


class Upload(base.CommandBase):
    def __init__(self, host):
        super(Upload, self).__init__(
            host, "upload", use_progress=True, use_verbose=True, help=_("upload a file")
        )
        self.need_loop = True

    def add_parser_options(self):
        self.parser.add_argument("file", type=str, help=_("file to upload"))
        self.parser.add_argument(
            "jid",
            nargs="?",
            help=_("jid of upload component (nothing to autodetect)"),
        )
        self.parser.add_argument(
            "--ignore-tls-errors",
            action="store_true",
            help=_("ignore invalide TLS certificate"),
        )

    def onProgressStarted(self, metadata):
        self.disp(_("File upload started"), 2)

    def onProgressFinished(self, metadata):
        self.disp(_("File uploaded successfully"), 2)
        try:
            url = metadata["url"]
        except KeyError:
            self.disp("download URL not found in metadata")
        else:
            self.disp(_("URL to retrieve the file:"), 1)
            # XXX: url is display alone on a line to make parsing easier
            self.disp(url)

    def onProgressError(self, error_msg):
        self.disp(_("Error while uploading file: {}").format(error_msg), error=True)

    def gotId(self, data, file_):
        """Called when a progress id has been received

        @param pid(unicode): progress id
        @param file_(str): file path
        """
        try:
            self.progress_id = data["progress"]
        except KeyError:
            # TODO: if 'xmlui' key is present, manage xmlui message display
            self.disp(_("Can't upload file"), error=True)
            self.host.quit(2)

    def error(self, failure):
        self.disp(
            _("Error while trying to upload a file: {reason}").format(reason=failure),
            error=True,
        )
        self.host.quit(1)

    def start(self):
        file_ = self.args.file
        if not os.path.exists(file_):
            self.disp(_("file [{}] doesn't exist !").format(file_), error=True)
            self.host.quit(1)
        if os.path.isdir(file_):
            self.disp(_("[{}] is a dir! Can't upload a dir").format(file_))
            self.host.quit(1)

        self.full_dest_jid = (
            self.host.get_full_jid(self.args.jid) if self.args.jid is not None else ""
        )
        options = {}
        if self.args.ignore_tls_errors:
            options["ignore_tls_errors"] = C.BOOL_TRUE

        path = os.path.abspath(file_)
        self.host.bridge.fileUpload(
            path,
            "",
            self.full_dest_jid,
            options,
            self.profile,
            callback=lambda pid, file_=file_: self.gotId(pid, file_),
            errback=self.error,
        )


class ShareList(base.CommandBase):
    def __init__(self, host):
        extra_outputs = {"default": self.default_output}
        super(ShareList, self).__init__(
            host,
            "list",
            use_output=C.OUTPUT_LIST_DICT,
            extra_outputs=extra_outputs,
            help=_("retrieve files shared by an entity"),
            use_verbose=True,
        )
        self.need_loop = True

    def add_parser_options(self):
        self.parser.add_argument(
            "-d",
            "--path",
            default="",
            help=_("path to the directory containing the files"),
        )
        self.parser.add_argument(
            "jid",
            nargs="?",
            default="",
            help=_("jid of sharing entity (nothing to check our own jid)"),
        )

    def file_gen(self, files_data):
        for file_data in files_data:
            yield file_data["name"]
            yield file_data.get("size", "")
            yield file_data.get("hash", "")

    def _name_filter(self, name, row):
        if row.type == C.FILE_TYPE_DIRECTORY:
            return A.color(C.A_DIRECTORY, name)
        elif row.type == C.FILE_TYPE_FILE:
            return A.color(C.A_FILE, name)
        else:
            self.disp(_("unknown file type: {type}").format(type=row.type), error=True)
            return name

    def _size_filter(self, size, row):
        if not size:
            return ""
        size = int(size)
        #  cf. https://stackoverflow.com/a/1094933 (thanks)
        suffix = "o"
        for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
            if abs(size) < 1024.0:
                return A.color(A.BOLD, "{:.2f}".format(size), unit, suffix)
            size /= 1024.0

        return A.color(A.BOLD, "{:.2f}".format(size), "Yi", suffix)

    def default_output(self, files_data):
        """display files a way similar to ls"""
        files_data.sort(key=lambda d: d["name"].lower())
        show_header = False
        if self.verbosity == 0:
            headers = ("name", "type")
        elif self.verbosity == 1:
            headers = ("name", "type", "size")
        elif self.verbosity > 1:
            show_header = True
            headers = ("name", "type", "size", "hash")
        table = common.Table.fromDict(
            self.host,
            files_data,
            headers,
            filters={"name": self._name_filter, "size": self._size_filter},
            defaults={"size": "", "hash": ""},
        )
        table.display_blank(show_header=show_header, hide_cols=["type"])

    def _FISListCb(self, files_data):
        self.output(files_data)
        self.host.quit()

    def start(self):
        self.host.bridge.FISList(
            self.args.jid,
            self.args.path,
            {},
            self.profile,
            callback=self._FISListCb,
            errback=partial(
                self.errback,
                msg=_("can't retrieve shared files: {}"),
                exit_code=C.EXIT_BRIDGE_ERRBACK,
            ),
        )


class SharePath(base.CommandBase):
    def __init__(self, host):
        super(SharePath, self).__init__(
            host, "path", help=_("share a file or directory"), use_verbose=True
        )
        self.need_loop = True

    def add_parser_options(self):
        self.parser.add_argument(
            "-n",
            "--name",
            default="",
            help=_("virtual name to use (default: use directory/file name)"),
        )
        perm_group = self.parser.add_mutually_exclusive_group()
        perm_group.add_argument(
            "-j",
            "--jid",
            action="append",
            dest="jids",
            default=[],
            help=_("jid of contacts allowed to retrieve the files"),
        )
        perm_group.add_argument(
            "--public",
            action="store_true",
            help=_(
                "share publicly the file(s) (/!\\ *everybody* will be able to access them)"
            ),
        )
        self.parser.add_argument(
            "path",
            help=_("path to a file or directory to share"),
        )

    def _FISSharePathCb(self, name):
        self.disp(
            _('{path} shared under the name "{name}"').format(path=self.path, name=name)
        )
        self.host.quit()

    def start(self):
        self.path = os.path.abspath(self.args.path)
        if self.args.public:
            access = {"read": {"type": "public"}}
        else:
            jids = self.args.jids
            if jids:
                access = {"read": {"type": "whitelist", "jids": jids}}
            else:
                access = {}
        self.host.bridge.FISSharePath(
            self.args.name,
            self.path,
            json.dumps(access, ensure_ascii=False),
            self.profile,
            callback=self._FISSharePathCb,
            errback=partial(
                self.errback,
                msg=_("can't share path: {}"),
                exit_code=C.EXIT_BRIDGE_ERRBACK,
            ),
        )


class ShareInvite(base.CommandBase):
    def __init__(self, host):
        super(ShareInvite, self).__init__(
            host, "invite", help=_("send invitation for a shared repository")
        )
        self.need_loop = True

    def add_parser_options(self):
        self.parser.add_argument(
            "-n",
            "--name",
            default="",
            help=_("name of the repository"),
        )
        self.parser.add_argument(
            "-N",
            "--namespace",
            default="",
            help=_("namespace of the repository"),
        )
        self.parser.add_argument(
            "-P",
            "--path",
            help=_("path to the repository"),
        )
        self.parser.add_argument(
            "-t",
            "--type",
            choices=["files", "photos"],
            default="files",
            help=_("type of the repository"),
        )
        self.parser.add_argument(
            "-T",
            "--thumbnail",
            help=_("https URL of a image to use as thumbnail"),
        )
        self.parser.add_argument(
            "service",
            help=_("jid of the file sharing service hosting the repository"),
        )
        self.parser.add_argument(
            "jid",
            help=_("jid of the person to invite"),
        )

    def _FISInviteCb(self):
        self.disp(
            _('invitation sent to {entity}').format(entity=self.args.jid)
        )
        self.host.quit()

    def start(self):
        self.path = os.path.normpath(self.args.path) if self.args.path else ""
        extra = {}
        if self.args.thumbnail is not None:
            if not self.args.thumbnail.startswith('http'):
                self.parser.error(_("only http(s) links are allowed with --thumbnail"))
            else:
                extra['thumb_url'] = self.args.thumbnail
        self.host.bridge.FISInvite(
            self.args.jid,
            self.args.service,
            self.args.type,
            self.args.namespace,
            self.path,
            self.args.name,
            data_format.serialise(extra),
            self.profile,
            callback=self._FISInviteCb,
            errback=partial(
                self.errback,
                msg=_("can't send invitation: {}"),
                exit_code=C.EXIT_BRIDGE_ERRBACK,
            ),
        )


class Share(base.CommandBase):
    subcommands = (ShareList, SharePath, ShareInvite)

    def __init__(self, host):
        super(Share, self).__init__(
            host, "share", use_profile=False, help=_("files sharing management")
        )


class File(base.CommandBase):
    subcommands = (Send, Request, Receive, Upload, Share)

    def __init__(self, host):
        super(File, self).__init__(
            host, "file", use_profile=False, help=_("files sending/receiving/management")
        )