view mime-support.sl @ 0:cdc3d19f5ba5 default tip

Initial revision
author Guido Berhoerster <guido+slrn@berhoerster.name>
date Sat, 21 May 2016 11:12:14 +0200
parents
children
line wrap: on
line source

% Copyright (C) 2013 Guido Berhoerster <guido+slrn@berhoerster.name>
%
% This file incorporates work from the file mime.sl distributed with slrn under
% the terms of the GNU General Public Licens version 2 or later.
%
% Copyright (C) 2012 John E. Davis <jed@jedsoft.org>
%
% This program is free software; you can redistribute it and/or modify
% it under the terms of the GNU General Public License as published by
% the Free Software Foundation; either version 2 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 General Public License for more details.
%
% You should have received a copy of the GNU General Public License along
% with this program; if not, write to the Free Software Foundation, Inc.,
% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

%open_log_file(make_home_filename("slrn-debug.log"));
%_traceback = 1;

require("rand");
require("mailcap");

implements("MIMESupport");

private variable FILENAME_CHARS =
        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private variable FILENAME_CHARS_LEN = strlen(FILENAME_CHARS);
private variable mime_save_dir = make_home_filename("");

private define quote_shell_arg(arg)
{
    variable c;
    variable result = "'";

    foreach c (arg) using ("bytes") {
        if (c == '\'')
            result += "'\"'\"'";
        else
            result += char(c);
    }
    result += "'";

    return result;
}

private define mkstemps(template, suffix_len)
{
    variable fd;
    variable temp_filename;
    variable suffix;
    variable template_len = strlen(@template);
    variable c;

    if (template_len < 6)
        return NULL;
    c = template_len - suffix_len - 6;
    suffix = substr(@template, template_len - suffix_len + 1, suffix_len);
    if (substr(@template, c + 1, 6) != "XXXXXX")
        return NULL;

    loop (10000) {
        temp_filename = substr(@template, 1, c);
        loop(6) {
            temp_filename += FILENAME_CHARS[[rand() mod FILENAME_CHARS_LEN]];
        }
        temp_filename += suffix;
        fd = open(temp_filename, O_CREAT|O_EXCL|O_RDWR, 0600);
        if (fd != NULL) {
            @template = temp_filename;
            break;
        }
    }

    return fd;
}

private define mkstemp(template)
{
    return mkstemps(template, 0);
}

private variable mime_save_charset = get_charset("display");
private variable raw_article;
private variable rendered_article;
private variable article;
private variable mime_object_list;
private variable tmpdir = getenv("TMPDIR");
if (tmpdir == NULL)
    tmpdir = "/tmp";
private variable pager_command = getenv("PAGER");
if (pager_command == NULL)
    pager_command = "more";
private variable auto_view_mailcap_entries = NULL;
static variable config = struct {
    auto_view = ["text/html"]
};

private define mime_set_save_charset(charset)
{
    mime_save_charset = charset;
}

private define mime_get_save_charset()
{
    return mime_save_charset;
}

private define mime_set_header_key(hash, name, value)
{
    hash[strlow(name)] = struct {
        name = name,
        value = strtrim(value),
    };
}

private define mime_get_header_key(hash, name, lowercase)
{
    try {
        variable h = hash[strlow(name)];
        return h.value;
    } catch AnyError;

    return "";
}

private define mime_split_article(art)
{
    variable ofs = is_substrbytes(art, "\n\n");

    if (ofs == 0) {
        throw DataError, "Unable to find the header separator";
    }

    variable header = substrbytes(art, 1, ofs - 1);
    (header,) = strreplace(header, "\n ", " ", strbytelen(header));
    (header,) = strreplace(header, "\n\t", " ", strbytelen(header));
    header = strchop(header, '\n', 0);

    variable hash = Assoc_Type[Struct_Type];
    _for (0, length(header) - 1, 1) {
        variable i = ();
        variable fields = strchop(header[i], ':', 0);
        mime_set_header_key(hash, fields[0], strjoin(fields[[1:]], ":"));
    }
    variable body = substrbytes(art, ofs + 2, -1);

    return hash, body;
}

private define mime_parse_subkeyword(key, word)
{
    variable val = string_matches(key, `\C` + word + ` *= *"\([^"]+\)"`);
    if (val == NULL) {
        val = string_matches(key, `\C` + word + ` *= *\([^; ]+\)`);
    }
    if (val == NULL) {
        return val;
    }

    return val[1];
}

private define get_multipart_boundary(header)
{
    variable ct = mime_get_header_key(header, "Content-Type", 0);
    if (ct == "") {
        return NULL;
    }

    ifnot (is_substr(strlow(ct), "multipart/")) {
        return NULL;
    }

    variable boundary = mime_parse_subkeyword(ct, "boundary");
    if (boundary == NULL) {
        return NULL;
    }

    return boundary;
}

% The idea here is to represent an article as a list of mime objects
% in the form of a tree.  For a non-multipart article, there is only
% one node.  For a multipart message, there will be a linked list of
% nodes, one for each subpart.  If the subpart is a multipart, a new
% subtree will begin.  For example, here is an article with a
% two-multiparts, with the second contained in the first.
%
%                  article
%                  /    \
%                       /\
%
private variable Mime_Node_Type = struct {
    mimetype,       % lowercase type/subtype, from content-type
    disposition,    % content-disposition header
    content_type,   %  full content-type header
    header,         %  assoc array of header keywords
    list,           %  non-null list of nodes if multipart
    message,        %  non-multipart decoded message
    charset,
    encoding
};

private define mime_parse_mime();
private define parse_multipart(node, body)
{
    variable boundary = get_multipart_boundary(node.header);
    if (boundary == NULL) {
        return;
    }

    boundary = "--" + boundary;
    variable blen = strbytelen(boundary);
    variable boundary_end = boundary + "--";
    variable blen_end = blen + 2;

    node.list = {};

    body = strchop(body, '\n', 0);
    variable i = 0;
    variable imax = length(body);
    while (i < imax) {
        if (strnbytecmp(body[i], boundary, blen)) {
            i++;
            continue;
        }

        if (strnbytecmp(body[i], boundary_end, blen_end) == 0) {
            break;
        }

        i++;
        variable i0 = i;
        if (i0 == imax) {
            break;
        }

        while (i < imax) {
            if (strnbytecmp(body[i], boundary, blen)) {
                i++;
                continue;
            }
            break;
        }
        variable new_node = mime_parse_mime(strjoin(body[[i0:i-1]], "\n"));
        if (new_node != NULL) {
            list_append(node.list, new_node);
        }
    }
}

private define mime_extract_mimetype(content_type)
{
   return strlow(strtrim(strchop(content_type, ';', 0)[0]));
}

private define mime_parse_mime(art)
{
    variable header, body;
    (header, body) = mime_split_article(art);

    variable node = @Mime_Node_Type;
    node.content_type = mime_get_header_key(header, "Content-Type", 1);
    node.disposition = mime_get_header_key(header, "Content-Disposition", 0);
    node.header = header;
    node.mimetype = mime_extract_mimetype(node.content_type);

    if (is_substr(node.mimetype, "multipart/")) {
        parse_multipart(node, body);
        return node;
    }

    node.message = body;

    variable encoding = mime_get_header_key(header,
            "Content-Transfer-Encoding", 1);
    encoding = strlow(encoding);
    if (is_substr(encoding, "base64")) {
        node.encoding = "base64";
    } else if (is_substr(encoding, "quoted-printable")) {
        node.encoding = "quoted-printable";
    }

    node.charset = mime_parse_subkeyword(node.content_type, "charset");

    return node;
}

private define mime_flatten_node_tree(node, leaves);    % recursive
private define mime_flatten_node_tree(node, leaves)
{
    if (node.list == NULL) {
        list_append(leaves, node);
        return;
    }

    foreach node (node.list) {
        mime_flatten_node_tree(node, leaves);
    }
}

% Returns NULL if the message is not Mime Encoded, otherwise it
% returns the value of the Content-Type header.
private define mime_is_mime_message()
{
    variable h = extract_article_header("Mime-Version");
    if ((h == NULL) || (h == "")) {
        return NULL;
    }

    h = extract_article_header("Content-Type");
    if (h == "") {
        return NULL;
    }
    return h;
}

private define mime_is_attachment(node)
{
    return is_substrbytes(strlow(node.disposition), "attachment");
}

private define mime_is_text(node)
{
    return is_substrbytes(node.mimetype, "text/");
}

private define mime_get_mime_filename(node)
{
    variable file = mime_parse_subkeyword(node.disposition, "filename");
    if (file != NULL) {
        return file;
    }
    file = mime_parse_subkeyword(node.content_type, "name");
    if (file != NULL) {
        return file;
    }

    return "";
}

private define mime_convert_mime_object(obj)
{
    variable str = obj.message;
    if (str == "") {
        return str;
    }

    if (obj.encoding == "base64") {
        str = decode_base64_string(str);
    } else if (obj.encoding == "quoted-printable") {
        str = decode_qp_string(str);
    }

    variable charset = obj.charset;
    if ((charset != NULL) && (charset != "") && (mime_save_charset != NULL) &&
            (strlow(charset) != strlow(mime_save_charset))) {
        str = charset_convert_string(str, charset, mime_save_charset, 0);
    }
    return str;
}

private define mime_save_mime_object(obj, fp)
{
    if (typeof(fp) == String_Type) {
        variable file = fp;
        fp = fopen(file, "w");
        if (fp == NULL) {
            throw OpenError, "Could not open $file for writing"$;
        }
    }

    variable str = mime_convert_mime_object(obj);

    () = fwrite(str, fp);
    () = fflush(fp);
}

private define find_filename_placeholder(template)
{
    variable i = 0;
    variable s;
    variable len = strbytelen(template);

    while (i + 1 < len) {
        s = template[[i:i + 1]];
        if (s == "\\%") {
            i += 2;
        } else if (s == "%s") {
            return i;
        } else {
            i++;
        }
    }

    return NULL;
}

private define mailcap_substitute(template, filename, content_type)
{
    variable mimetype = mime_extract_mimetype(content_type);
    variable i = 0;
    variable j;
    variable s;
    variable len = strbytelen(template);
    variable key;
    variable value;
    variable result = "";

    while (i < len) {
        if (i + 1 < len) {
            s = template[[i:i + 1]];
            switch(s)
            {
                case "\\%":
                result += "%";
                i += 2;
            }
            {
                case "%s":
                result += filename;
                i += 2;
            }
            {
                case "%t":
                result += mimetype;
                i += 2;
            }
            {
                case "%{":
                key = NULL;
                for (j = i + 2; j < len; j++) {
                    if (template[j] == '}') {
                        key = template[[i + 2:j -1]];
                        break;
                    } else ifnot (isalnum(template[j])) {
                        break;
                    }
                }
                if (key != NULL) {
                    if (key == "charset")
                        value = mime_get_save_charset();
                    else
                        value = mime_parse_subkeyword(content_type, key);
                    if (value == NULL)
                        value = "";
                    result += quote_shell_arg(value);
                    i = j + 1;
                } else {
                    result += template[[i]];
                    i++;
                }
            }
            {
                result += template[[i]];
                i++;
            }
        } else {
            result += template[[i]];
            i++;
        }
    }

    return result;
}

private define mailcap_view_part(mc_entry, data)
{
    variable filter = qualifier_exists("filter");
    variable lines;
    variable command;
    variable use_input_tmpfile;
    variable mask;
    variable fd = NULL;
    variable fp_in = NULL;
    variable fp_out = NULL;
    variable fp_pager = NULL;
    variable i;
    variable tmpfilename;
    variable suffixlen;
    variable command_status = 0;
    variable pager_status = 0;
    variable text;

    if (filter && (mc_entry._copiousoutput == 0))
        return NULL;

    try {
        command = mc_entry._command;
        use_input_tmpfile = (find_filename_placeholder(command) != NULL);
        if (use_input_tmpfile) {
            % the command reads the input from a temporary file
            tmpfilename = mc_entry._nametemplate;
            if ((tmpfilename == NULL) ||
                    (find_filename_placeholder(tmpfilename) == NULL))
                tmpfilename = "slrn%s";
            tmpfilename = path_concat(tmpdir, path_basename(tmpfilename));
            i = find_filename_placeholder(tmpfilename);
            suffixlen = strbytelen(tmpfilename) - (i + 2);
            tmpfilename = tmpfilename[[0:i - 1]] + "XXXXXX" +
                    tmpfilename[[i + 2:]];
            mask = umask(077);
            fd = mkstemps(&tmpfilename, suffixlen);
            if (fd == NULL)
                throw OpenError, "Could not create temporary file";
            fp_in = fdopen(fd, "w+");
            if (fp_in == NULL)
                throw OpenError, "Could not open temporary file";
            if (fwrite(data, fp_in) == -1) {
                throw WriteError,
                        "Failed to write to file \"$tmpfilename\": "$ +
                        errno_string(errno);
            }
            () = fflush(fp_in);

            command = mailcap_substitute(command, tmpfilename, mc_entry._type);
            if (mc_entry._copiousoutput) {
                % output is read back from the command's stdout
                fp_out = popen(command, "r");
                if (fp_out == NULL)
                    throw OSError, "Failed to execute $command"$;
            } else {
                system(command);
            }
        } else {
            % the command reads the input from its stdin
            command = mailcap_substitute(command, "", mc_entry._type);
            if (mc_entry._copiousoutput) {
                % create temporary file for the output if the command is
                % non-interactive
                tmpfilename = path_concat(tmpdir, "slrnXXXXXX");
                mask = umask(077);
                fd = mkstemp(&tmpfilename);
                if (fd == NULL)
                    throw OpenError, "Could not create temporary file";
                fp_out = fdopen(fd, "r+");
                if (fp_out == NULL)
                    throw OpenError, "Could not open temporary file";

                command += " > " + tmpfilename;
            }

            fp_in = popen(command, "w");
            if (fp_in == NULL)
                throw OSError, "Failed to execute $command"$;
            if (fputs(data, fp_in) == -1)
                throw WriteError,
                        "Failed to write to command \"$command\": "$ +
                        errno_string(errno);
            () = fflush(fp_in);
            command_status = pclose(fp_in);
            fp_in = NULL;
            ifnot (command_status == 0) {
                throw OSError,
                        "Command \"$command\" returned a non-zero exit "$ +
                        "status: " + string(command_status);
            }
        }

        % read back the output if the command is non-interactive
        if (mc_entry._copiousoutput) {
            lines = fgetslines(fp_out);
            if (lines == NULL)
                throw ReadError, "Failed to read output: " +
                        errno_string(errno);
            text = strjoin(lines, "");

            if (filter) {
                return text;
            } else {
                fp_pager = popen(pager_command, "w");
                if (fp_pager == NULL)
                    throw OSError, "Failed to execute $pager_command"$;
                if (fputs(text, fp_pager) == -1)
                    throw WriteError,
                            "Failed to write to command \"$command\": "$ +
                            errno_string(errno);
                () = fflush(fp_pager);
            }
        }

        return NULL;
    } finally {
        % remove temporary input or output file
        if (fd != NULL)
            () = remove(tmpfilename);

        if (use_input_tmpfile) {
            if (fp_in != NULL)
                () = fclose(fp_in);
            else if (fd != NULL)
                () = close(fd);

            if (fp_out != NULL)
                command_status = pclose(fp_out);
        } else {
            if (mc_entry._copiousoutput) {
                if (fp_out != NULL)
                    () = fclose(fp_out);
                else if (fd != NULL)
                    () = close(fd);
            }

            if (fp_in != NULL)
                command_status = pclose(fp_in);
        }

        if (fp_pager != NULL) {
            pager_status = pclose(fp_pager);
        }

        if (command_status != 0)
            throw OSError,
                    "Command \"$command\" returned a non-zero exit "$ +
                    "status: " + string(command_status);

        if (pager_status != 0)
            throw OSError,
                    "Command \"$pager_command\" returned a"$ +
                    "non-zero exit status: " + string(pager_status);
    }
}

private define render_part(node, rendered_message);
private define render_part(node, rendered_message)
{
    variable mc_entry;
    variable i;
    variable j;
    variable best_match_node = NULL;
    variable text_node = NULL;
    variable subnode;
    variable text;
    variable raw_message;
    variable header;
    variable value;

    if (node.mimetype == "multipart/alternative") {
        % select best match based on the order of the entries in
        % config.auto_view, text/plain is always preferred and the first text
        % part is used as a fallback in case there is no match
        j = length(auto_view_mailcap_entries);
        foreach subnode (node.list) {
            if (subnode.mimetype == "text/plain") {
                best_match_node = subnode;
                break;
            }
            for (i = 0; i < j; i++) {
                if (subnode.mimetype == auto_view_mailcap_entries[i]._type) {
                    best_match_node = subnode;
                    j = i;
                    break;
                } else if ((text_node == NULL) && mime_is_text(subnode)) {
                    text_node = subnode;
                }
            }
        }
        if (best_match_node != NULL) {
            render_part(best_match_node, rendered_message);
        } else if (text_node != NULL) {
            render_part(text_node, rendered_message);
        } else {
            @rendered_message += "[-- Unhandled MIME Alternative Parts --]\n\n";
        }
    } else if ((node.mimetype == "multipart/mixed") ||
            (node.mimetype == "multipart/digest") ||
            (node.mimetype == "multipart/related") ||
            (node.mimetype == "multipart/signed")) {
        foreach subnode (node.list) {
            render_part(subnode, rendered_message);
        }
    } else if (node.mimetype == "message/rfc822") {
        % inline message
        subnode = mime_parse_mime(node.message);

        @rendered_message += "[-- MIME Message (" + node.mimetype + ") --]\n\n";
        foreach header (strchop(get_visible_headers(), ',', 0)) {
            value = mime_get_header_key(subnode.header, strtrim_end(header, ":"),
                    1);
            ifnot (value == "") {
                @rendered_message += sprintf("%s %s\n", header, value);
            }
        }
        @rendered_message += "\n";

        if (subnode.mimetype != "") {
            render_part(subnode, rendered_message);
        } else {
            @rendered_message += subnode.message + "\n";
        }
    } else if (node.mimetype == "text/plain") {
        @rendered_message += mime_convert_mime_object(node) + "\n";
    } else {
        foreach mc_entry (auto_view_mailcap_entries) {
            % check if the MIME type is in config.auto_view and if a
            % corresponding mailcap entry exists
            if (node.mimetype == mc_entry._type) {
                @rendered_message += "[-- MIME Part (" + node.mimetype +
                        ") --]\n\n";
                text = mailcap_view_part(mc_entry,
                        mime_convert_mime_object(node); filter);
                if (text == NULL)
                    text = "[-- Failed to convert MIME Part to text/plain " +
                            "--]\n\n";
                @rendered_message += text + "\n";
                break;
            }
        } then {
            if (mime_is_text(node)) {
                % otherwise check if the part has a text MIME type and display
                % that as-is
                @rendered_message += "[-- MIME Part (" + node.mimetype +
                        ") --]\n\n" + mime_convert_mime_object(node) + "\n";
            } else {
                @rendered_message += "[-- Unhandled MIME Part (" +
                        node.mimetype + ") --]\n\n";
            }
        }
    }
}

static define mime_process_article()
{
    variable content_type = extract_article_header("Content-Type");
    variable mimetype;
    variable mc_entry;
    variable header;
    variable body = NULL;
    variable text;
    variable node;
    variable value;

    % initialize list of existing config.auto_view mailcap entries
    if (auto_view_mailcap_entries == NULL) {
        auto_view_mailcap_entries = {};
        foreach mimetype (config.auto_view) {
            mc_entry = mailcap_lookup_entry(mimetype);
            if ((mc_entry != NULL) && mc_entry._copiousoutput)
                list_append(auto_view_mailcap_entries, mc_entry);
        }
    }

    raw_article = raw_article_as_string();
    rendered_article = NULL;
    article = &raw_article;

    if (mime_is_mime_message() == NULL) {
        mime_object_list = NULL;
        mimetype = mime_extract_mimetype(content_type);

        % handle non-MIME-encoded articles with a Content-Type
        foreach mc_entry (auto_view_mailcap_entries) {
            % check if the MIME type is in config.auto_view and if a
            % corresponding mailcap entry exists
            if (mimetype == mc_entry._type) {
                (header, body) = mime_split_article(raw_article);

                rendered_article = "";
                foreach value (header) using ("values") {
                    rendered_article += sprintf("%s: %s\n", value.name,
                            value.value);
                }
                rendered_article += "\n";
                text = mailcap_view_part(mc_entry, body; filter);
                if (text != NULL) {
                    rendered_article += "[-- Content (" + mimetype +
                            ") --]\n\n" + text + "\n";
                } else {
                    rendered_article += "[-- Failed to convert content to " +
                            "text/plain --]\n\n";
                }
                break;
            }
        }

        return;
    }
    mime_object_list = {};
    node = mime_parse_mime(raw_article);

    mime_flatten_node_tree(node, mime_object_list);

    rendered_article = "";
    foreach value (node.header) using ("values") {
        rendered_article += sprintf("%s: %s\n", value.name, value.value);
    }
    rendered_article += "\n";

    render_part(node, &rendered_article);

    return;
}

static define mime_show_raw_article()
{
    if (article != &raw_article) {
        article = &raw_article;
        replace_article(raw_article);
        update();
    }
}

static define mime_show_rendered_article()
{
    if ((article != &rendered_article) && (rendered_article != NULL))  {
        article = &rendered_article;
        replace_article(rendered_article);
        update();
    }
}

static define mime_toggle_view()
{
    if (article == &raw_article)
        mime_show_rendered_article();
    else
        mime_show_raw_article();
}

static define mime_select_part(title)
{
    variable selection_list = {};
    variable i;
    variable len;
    variable selection;

    if (mime_object_list == NULL)
        return NULL;

    len = length(mime_object_list);
    for (i = 0; i < len; i++) {
        list_append(selection_list, sprintf("%d. %s", i + 1,
                mime_object_list[i].mimetype));
    }
    if (i < 1)
        return NULL;

    title;
    __push_list(selection_list);
    i;
    0;
    selection = select_list_box();
    if (selection == "")
        return NULL;

    return mime_object_list[integer(substr(selection, 1, 1)) - 1];
}

static define mime_save_part()
{
    variable node = mime_select_part("Select MIME part to save");
    variable filename;
    variable st;
    variable n;

    if (node == NULL)
        return;

    filename = path_basename(rfc1522_decode_string(mime_get_mime_filename(node)));
    filename = path_concat(mime_save_dir, filename);
    filename = strtrim(filename);
    forever {
        filename = read_mini_filename("Save to:", "", filename);
        filename = strtrim(filename);
        if ((filename == "") || (filename == mime_save_dir)) {
            message_now("Cancelled");
            return;
        }
        st = stat_file(filename);
        if (st != NULL) {
            if (stat_is("lnk", st.st_mode) || stat_is("reg", st.st_mode)) {
                n = get_yes_no_cancel("File '$filename' exists, Overwrite?"$,
                        0);
                if (n == 0)
                    continue;
                else if (n == -1)
                    message_now("Cancelled");
                    return;
            } else {
                throw OpenError, "Could not open '$filename' for writing"$;
            }
        }
        mime_save_mime_object(node, filename);
        message_now("Saved to '$filename'"$);
        mime_save_dir = path_dirname(filename);
        break;
    }
}

static define mime_view_part()
{
    variable mc;
    variable e;
    variable line;
    variable node = mime_select_part("Select MIME part to view");
    if (node == NULL)
        return;

    mc = mailcap_lookup_entry(node.content_type);
    if (mc == NULL)
        throw NotImplementedError, "No viewer for '" + node.mimetype +
                "' available";
    mc.view = &mailcap_view_part;

    try (e) {
        set_display_state(0);
        mc.view(mime_convert_mime_object(node));
    } catch OSError: {
        () = fprintf(stdout, "\n*** ERROR: %S\n\nPress enter to continue.",
                e.message);
        () = fgets(&line, stdin);
        throw;
    } finally {
        set_display_state(1);
    }
}

() = register_hook("read_article_hook",
        "MIMESupport->mime_show_rendered_article");
() = register_hook("read_article_hook", "MIMESupport->mime_process_article");