1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
4
5
6import os
7import posixpath
8from contextlib import suppress
9from typing import (
10    Any, Generator, Iterable, List, NamedTuple, Optional, Tuple, cast
11)
12from urllib.parse import ParseResult, unquote, urlparse
13
14from .conf.utils import KeyAction, to_cmdline_implementation
15from .constants import config_dir
16from .guess_mime_type import guess_type
17from .options.utils import parse_key_action
18from .types import run_once
19from .typing import MatchType
20from .utils import expandvars, log_error
21
22
23class MatchCriteria(NamedTuple):
24    type: MatchType
25    value: str
26
27
28class OpenAction(NamedTuple):
29    match_criteria: Tuple[MatchCriteria, ...]
30    actions: Tuple[KeyAction, ...]
31
32
33def parse(lines: Iterable[str]) -> Generator[OpenAction, None, None]:
34    match_criteria: List[MatchCriteria] = []
35    actions: List[KeyAction] = []
36
37    for line in lines:
38        line = line.strip()
39        if line.startswith('#'):
40            continue
41        if not line:
42            if match_criteria and actions:
43                yield OpenAction(tuple(match_criteria), tuple(actions))
44            match_criteria = []
45            actions = []
46            continue
47        parts = line.split(maxsplit=1)
48        if len(parts) != 2:
49            continue
50        key, rest = parts
51        key = key.lower()
52        if key == 'action':
53            with to_cmdline_implementation.filter_env_vars('URL', 'FILE_PATH', 'FILE', 'FRAGMENT'):
54                x = parse_key_action(rest)
55            if x is not None:
56                actions.append(x)
57        elif key in ('mime', 'ext', 'protocol', 'file', 'path', 'url', 'fragment_matches'):
58            if key != 'url':
59                rest = rest.lower()
60            match_criteria.append(MatchCriteria(cast(MatchType, key), rest))
61        else:
62            log_error(f'Ignoring malformed open actions line: {line}')
63
64    if match_criteria and actions:
65        yield OpenAction(tuple(match_criteria), tuple(actions))
66
67
68def url_matches_criterion(purl: 'ParseResult', url: str, unquoted_path: str, mc: MatchCriteria) -> bool:
69    if mc.type == 'url':
70        import re
71        try:
72            pat = re.compile(mc.value)
73        except re.error:
74            return False
75        return pat.search(unquote(url)) is not None
76
77    if mc.type == 'mime':
78        import fnmatch
79        mt = guess_type(unquoted_path, allow_filesystem_access=True)
80        if not mt:
81            return False
82        mt = mt.lower()
83        for mpat in mc.value.split(','):
84            mpat = mpat.strip()
85            with suppress(Exception):
86                if fnmatch.fnmatchcase(mt, mpat):
87                    return True
88        return False
89
90    if mc.type == 'ext':
91        if not purl.path:
92            return False
93        path = unquoted_path.lower()
94        for ext in mc.value.split(','):
95            ext = ext.strip()
96            if path.endswith('.' + ext):
97                return True
98        return False
99
100    if mc.type == 'protocol':
101        protocol = (purl.scheme or 'file').lower()
102        for key in mc.value.split(','):
103            if key.strip() == protocol:
104                return True
105        return False
106
107    if mc.type == 'fragment_matches':
108        import re
109        try:
110            pat = re.compile(mc.value)
111        except re.error:
112            return False
113
114        return pat.search(unquote(purl.fragment)) is not None
115
116    if mc.type == 'path':
117        import fnmatch
118        try:
119            return fnmatch.fnmatchcase(unquoted_path.lower(), mc.value)
120        except Exception:
121            return False
122
123    if mc.type == 'file':
124        import fnmatch
125        import posixpath
126        try:
127            fname = posixpath.basename(unquoted_path)
128        except Exception:
129            return False
130        try:
131            return fnmatch.fnmatchcase(fname.lower(), mc.value)
132        except Exception:
133            return False
134
135
136def url_matches_criteria(purl: 'ParseResult', url: str, unquoted_path: str, criteria: Iterable[MatchCriteria]) -> bool:
137    for x in criteria:
138        try:
139            if not url_matches_criterion(purl, url, unquoted_path, x):
140                return False
141        except Exception:
142            return False
143    return True
144
145
146def actions_for_url_from_list(url: str, actions: Iterable[OpenAction]) -> Generator[KeyAction, None, None]:
147    try:
148        purl = urlparse(url)
149    except Exception:
150        return
151    path = unquote(purl.path)
152
153    env = {
154        'URL': url,
155        'FILE_PATH': path,
156        'FILE': posixpath.basename(path),
157        'FRAGMENT': unquote(purl.fragment)
158    }
159
160    def expand(x: Any) -> Any:
161        as_bytes = isinstance(x, bytes)
162        if as_bytes:
163            x = x.decode('utf-8')
164        if isinstance(x, str):
165            ans = expandvars(x, env, fallback_to_os_env=False)
166            if as_bytes:
167                return ans.encode('utf-8')
168            return ans
169        return x
170
171    for action in actions:
172        if url_matches_criteria(purl, url, path, action.match_criteria):
173            for ac in action.actions:
174                yield ac._replace(args=tuple(map(expand, ac.args)))
175            return
176
177
178@run_once
179def load_open_actions() -> Tuple[OpenAction, ...]:
180    try:
181        f = open(os.path.join(config_dir, 'open-actions.conf'))
182    except FileNotFoundError:
183        return ()
184    with f:
185        return tuple(parse(f))
186
187
188def actions_for_url(url: str, actions_spec: Optional[str] = None) -> Generator[KeyAction, None, None]:
189    if actions_spec is None:
190        actions = load_open_actions()
191    else:
192        actions = tuple(parse(actions_spec.splitlines()))
193    yield from actions_for_url_from_list(url, actions)
194