PFERD 3.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pferd-3.6.0/LICENSE +20 -0
- pferd-3.6.0/PFERD/__init__.py +0 -0
- pferd-3.6.0/PFERD/__main__.py +169 -0
- pferd-3.6.0/PFERD/auth/__init__.py +29 -0
- pferd-3.6.0/PFERD/auth/authenticator.py +80 -0
- pferd-3.6.0/PFERD/auth/credential_file.py +46 -0
- pferd-3.6.0/PFERD/auth/keyring.py +65 -0
- pferd-3.6.0/PFERD/auth/pass_.py +98 -0
- pferd-3.6.0/PFERD/auth/simple.py +62 -0
- pferd-3.6.0/PFERD/auth/tfa.py +30 -0
- pferd-3.6.0/PFERD/cli/__init__.py +14 -0
- pferd-3.6.0/PFERD/cli/command_ilias_web.py +56 -0
- pferd-3.6.0/PFERD/cli/command_kit_ilias_web.py +37 -0
- pferd-3.6.0/PFERD/cli/command_kit_ipd.py +54 -0
- pferd-3.6.0/PFERD/cli/command_local.py +70 -0
- pferd-3.6.0/PFERD/cli/common_ilias_args.py +104 -0
- pferd-3.6.0/PFERD/cli/parser.py +245 -0
- pferd-3.6.0/PFERD/config.py +193 -0
- pferd-3.6.0/PFERD/crawl/__init__.py +27 -0
- pferd-3.6.0/PFERD/crawl/crawler.py +369 -0
- pferd-3.6.0/PFERD/crawl/http_crawler.py +199 -0
- pferd-3.6.0/PFERD/crawl/ilias/__init__.py +9 -0
- pferd-3.6.0/PFERD/crawl/ilias/async_helper.py +39 -0
- pferd-3.6.0/PFERD/crawl/ilias/file_templates.py +201 -0
- pferd-3.6.0/PFERD/crawl/ilias/ilias_html_cleaner.py +91 -0
- pferd-3.6.0/PFERD/crawl/ilias/ilias_web_crawler.py +1009 -0
- pferd-3.6.0/PFERD/crawl/ilias/kit_ilias_html.py +1297 -0
- pferd-3.6.0/PFERD/crawl/ilias/kit_ilias_web_crawler.py +229 -0
- pferd-3.6.0/PFERD/crawl/kit_ipd_crawler.py +170 -0
- pferd-3.6.0/PFERD/crawl/local_crawler.py +117 -0
- pferd-3.6.0/PFERD/deduplicator.py +85 -0
- pferd-3.6.0/PFERD/limiter.py +97 -0
- pferd-3.6.0/PFERD/logging.py +291 -0
- pferd-3.6.0/PFERD/output_dir.py +518 -0
- pferd-3.6.0/PFERD/pferd.py +194 -0
- pferd-3.6.0/PFERD/report.py +238 -0
- pferd-3.6.0/PFERD/transformer.py +443 -0
- pferd-3.6.0/PFERD/utils.py +144 -0
- pferd-3.6.0/PFERD/version.py +2 -0
- pferd-3.6.0/PFERD.egg-info/PKG-INFO +10 -0
- pferd-3.6.0/PFERD.egg-info/SOURCES.txt +47 -0
- pferd-3.6.0/PFERD.egg-info/dependency_links.txt +1 -0
- pferd-3.6.0/PFERD.egg-info/entry_points.txt +2 -0
- pferd-3.6.0/PFERD.egg-info/requires.txt +5 -0
- pferd-3.6.0/PFERD.egg-info/top_level.txt +1 -0
- pferd-3.6.0/PKG-INFO +10 -0
- pferd-3.6.0/README.md +158 -0
- pferd-3.6.0/pyproject.toml +42 -0
- pferd-3.6.0/setup.cfg +4 -0
pferd-3.6.0/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2019-2024 Garmelon, I-Al-Istannen, danstooamerican, pavelzw,
|
|
2
|
+
TheChristophe, Scriptim, thelukasprobst, Toorero,
|
|
3
|
+
Mr-Pine, p-fruck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
File without changes
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import configparser
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .auth import AuthLoadError
|
|
9
|
+
from .cli import PARSER, ParserLoadError, load_default_section
|
|
10
|
+
from .config import Config, ConfigDumpError, ConfigLoadError, ConfigOptionError
|
|
11
|
+
from .logging import log
|
|
12
|
+
from .pferd import Pferd, PferdLoadError
|
|
13
|
+
from .transformer import RuleParseError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config_parser(args: argparse.Namespace) -> configparser.ConfigParser:
|
|
17
|
+
log.explain_topic("Loading config")
|
|
18
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
19
|
+
|
|
20
|
+
if args.command is None:
|
|
21
|
+
log.explain("No CLI command specified, loading config from file")
|
|
22
|
+
Config.load_parser(parser, path=args.config)
|
|
23
|
+
else:
|
|
24
|
+
log.explain("CLI command specified, loading config from its arguments")
|
|
25
|
+
if args.command:
|
|
26
|
+
args.command(args, parser)
|
|
27
|
+
|
|
28
|
+
load_default_section(args, parser)
|
|
29
|
+
|
|
30
|
+
return parser
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_config(args: argparse.Namespace) -> Config:
|
|
34
|
+
try:
|
|
35
|
+
return Config(load_config_parser(args))
|
|
36
|
+
except ConfigLoadError as e:
|
|
37
|
+
log.error(str(e))
|
|
38
|
+
log.error_contd(e.reason)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
except ParserLoadError as e:
|
|
41
|
+
log.error(str(e))
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def configure_logging_from_args(args: argparse.Namespace) -> None:
|
|
46
|
+
if args.explain is not None:
|
|
47
|
+
log.output_explain = args.explain
|
|
48
|
+
if args.status is not None:
|
|
49
|
+
log.output_status = args.status
|
|
50
|
+
if args.show_not_deleted is not None:
|
|
51
|
+
log.output_not_deleted = args.show_not_deleted
|
|
52
|
+
if args.report is not None:
|
|
53
|
+
log.output_report = args.report
|
|
54
|
+
|
|
55
|
+
# We want to prevent any unnecessary output if we're printing the config to
|
|
56
|
+
# stdout, otherwise it would not be a valid config file.
|
|
57
|
+
if args.dump_config_to == "-":
|
|
58
|
+
log.output_explain = False
|
|
59
|
+
log.output_status = False
|
|
60
|
+
log.output_report = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def configure_logging_from_config(args: argparse.Namespace, config: Config) -> None:
|
|
64
|
+
# In configure_logging_from_args(), all normal logging is already disabled
|
|
65
|
+
# whenever we dump the config. We don't want to override that decision with
|
|
66
|
+
# values from the config file.
|
|
67
|
+
if args.dump_config_to == "-":
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if args.explain is None:
|
|
72
|
+
log.output_explain = config.default_section.explain()
|
|
73
|
+
if args.status is None:
|
|
74
|
+
log.output_status = config.default_section.status()
|
|
75
|
+
if args.report is None:
|
|
76
|
+
log.output_report = config.default_section.report()
|
|
77
|
+
if args.show_not_deleted is None:
|
|
78
|
+
log.output_not_deleted = config.default_section.show_not_deleted()
|
|
79
|
+
except ConfigOptionError as e:
|
|
80
|
+
log.error(str(e))
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def dump_config(args: argparse.Namespace, config: Config) -> None:
|
|
85
|
+
log.explain_topic("Dumping config")
|
|
86
|
+
|
|
87
|
+
if args.dump_config and args.dump_config_to is not None:
|
|
88
|
+
log.error("--dump-config and --dump-config-to can't be specified at the same time")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
if args.dump_config:
|
|
93
|
+
config.dump()
|
|
94
|
+
elif args.dump_config_to == "-":
|
|
95
|
+
config.dump_to_stdout()
|
|
96
|
+
else:
|
|
97
|
+
config.dump(Path(args.dump_config_to))
|
|
98
|
+
except ConfigDumpError as e:
|
|
99
|
+
log.error(str(e))
|
|
100
|
+
log.error_contd(e.reason)
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> None:
|
|
105
|
+
args = PARSER.parse_args()
|
|
106
|
+
|
|
107
|
+
# Configuring logging happens in two stages because CLI args have
|
|
108
|
+
# precedence over config file options and loading the config already
|
|
109
|
+
# produces some kinds of log messages (usually only explain()-s).
|
|
110
|
+
configure_logging_from_args(args)
|
|
111
|
+
|
|
112
|
+
config = load_config(args)
|
|
113
|
+
|
|
114
|
+
# Now, after loading the config file, we can apply its logging settings in
|
|
115
|
+
# all places that were not already covered by CLI args.
|
|
116
|
+
configure_logging_from_config(args, config)
|
|
117
|
+
|
|
118
|
+
if args.dump_config or args.dump_config_to is not None:
|
|
119
|
+
dump_config(args, config)
|
|
120
|
+
sys.exit()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
pferd = Pferd(config, args.crawler, args.skip)
|
|
124
|
+
except PferdLoadError as e:
|
|
125
|
+
log.unlock()
|
|
126
|
+
log.error(str(e))
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
if os.name == "nt":
|
|
131
|
+
# A "workaround" for the windows event loop somehow crashing after
|
|
132
|
+
# asyncio.run() completes. See:
|
|
133
|
+
# https://bugs.python.org/issue39232
|
|
134
|
+
# https://github.com/encode/httpx/issues/914#issuecomment-780023632
|
|
135
|
+
# TODO Fix this properly
|
|
136
|
+
loop = asyncio.get_event_loop()
|
|
137
|
+
loop.run_until_complete(pferd.run(args.debug_transforms))
|
|
138
|
+
loop.run_until_complete(asyncio.sleep(1))
|
|
139
|
+
loop.close()
|
|
140
|
+
else:
|
|
141
|
+
asyncio.run(pferd.run(args.debug_transforms))
|
|
142
|
+
except (ConfigOptionError, AuthLoadError) as e:
|
|
143
|
+
log.unlock()
|
|
144
|
+
log.error(str(e))
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
except RuleParseError as e:
|
|
147
|
+
log.unlock()
|
|
148
|
+
e.pretty_print()
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
log.unlock()
|
|
152
|
+
log.explain_topic("Interrupted, exiting immediately")
|
|
153
|
+
log.explain("Open files and connections are left for the OS to clean up")
|
|
154
|
+
pferd.print_report()
|
|
155
|
+
# TODO Clean up tmp files
|
|
156
|
+
# And when those files *do* actually get cleaned up properly,
|
|
157
|
+
# reconsider if this should really exit with 1
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
except Exception:
|
|
160
|
+
log.unlock()
|
|
161
|
+
log.unexpected_exception()
|
|
162
|
+
pferd.print_report()
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
else:
|
|
165
|
+
pferd.print_report()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from configparser import SectionProxy
|
|
2
|
+
from typing import Callable, Dict
|
|
3
|
+
|
|
4
|
+
from ..config import Config
|
|
5
|
+
from .authenticator import Authenticator, AuthError, AuthLoadError, AuthSection # noqa: F401
|
|
6
|
+
from .credential_file import CredentialFileAuthenticator, CredentialFileAuthSection
|
|
7
|
+
from .keyring import KeyringAuthenticator, KeyringAuthSection
|
|
8
|
+
from .pass_ import PassAuthenticator, PassAuthSection
|
|
9
|
+
from .simple import SimpleAuthenticator, SimpleAuthSection
|
|
10
|
+
from .tfa import TfaAuthenticator
|
|
11
|
+
|
|
12
|
+
AuthConstructor = Callable[[
|
|
13
|
+
str, # Name (without the "auth:" prefix)
|
|
14
|
+
SectionProxy, # Authenticator's section of global config
|
|
15
|
+
Config, # Global config
|
|
16
|
+
], Authenticator]
|
|
17
|
+
|
|
18
|
+
AUTHENTICATORS: Dict[str, AuthConstructor] = {
|
|
19
|
+
"credential-file": lambda n, s, c:
|
|
20
|
+
CredentialFileAuthenticator(n, CredentialFileAuthSection(s), c),
|
|
21
|
+
"keyring": lambda n, s, c:
|
|
22
|
+
KeyringAuthenticator(n, KeyringAuthSection(s)),
|
|
23
|
+
"pass": lambda n, s, c:
|
|
24
|
+
PassAuthenticator(n, PassAuthSection(s)),
|
|
25
|
+
"simple": lambda n, s, c:
|
|
26
|
+
SimpleAuthenticator(n, SimpleAuthSection(s)),
|
|
27
|
+
"tfa": lambda n, s, c:
|
|
28
|
+
TfaAuthenticator(n),
|
|
29
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
|
|
4
|
+
from ..config import Section
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuthLoadError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthSection(Section):
|
|
16
|
+
def type(self) -> str:
|
|
17
|
+
value = self.s.get("type")
|
|
18
|
+
if value is None:
|
|
19
|
+
self.missing_value("type")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Authenticator(ABC):
|
|
24
|
+
def __init__(self, name: str) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Initialize an authenticator from its name and its section in the config
|
|
27
|
+
file.
|
|
28
|
+
|
|
29
|
+
If you are writing your own constructor for your own authenticator,
|
|
30
|
+
make sure to call this constructor first (via super().__init__).
|
|
31
|
+
|
|
32
|
+
May throw an AuthLoadError.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
self.name = name
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def credentials(self) -> Tuple[str, str]:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
async def username(self) -> str:
|
|
42
|
+
username, _ = await self.credentials()
|
|
43
|
+
return username
|
|
44
|
+
|
|
45
|
+
async def password(self) -> str:
|
|
46
|
+
_, password = await self.credentials()
|
|
47
|
+
return password
|
|
48
|
+
|
|
49
|
+
def invalidate_credentials(self) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Tell the authenticator that some or all of its credentials are invalid.
|
|
52
|
+
|
|
53
|
+
Authenticators should overwrite this function if they have a way to
|
|
54
|
+
deal with this issue that is likely to result in valid credentials
|
|
55
|
+
(e. g. prompting the user).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
raise AuthError("Invalid credentials")
|
|
59
|
+
|
|
60
|
+
def invalidate_username(self) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Tell the authenticator that specifically its username is invalid.
|
|
63
|
+
|
|
64
|
+
Authenticators should overwrite this function if they have a way to
|
|
65
|
+
deal with this issue that is likely to result in valid credentials
|
|
66
|
+
(e. g. prompting the user).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
raise AuthError("Invalid username")
|
|
70
|
+
|
|
71
|
+
def invalidate_password(self) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Tell the authenticator that specifically its password is invalid.
|
|
74
|
+
|
|
75
|
+
Authenticators should overwrite this function if they have a way to
|
|
76
|
+
deal with this issue that is likely to result in valid credentials
|
|
77
|
+
(e. g. prompting the user).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
raise AuthError("Invalid password")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
|
|
4
|
+
from ..config import Config
|
|
5
|
+
from ..utils import fmt_real_path
|
|
6
|
+
from .authenticator import Authenticator, AuthLoadError, AuthSection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CredentialFileAuthSection(AuthSection):
|
|
10
|
+
def path(self) -> Path:
|
|
11
|
+
value = self.s.get("path")
|
|
12
|
+
if value is None:
|
|
13
|
+
self.missing_value("path")
|
|
14
|
+
return Path(value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CredentialFileAuthenticator(Authenticator):
|
|
18
|
+
def __init__(self, name: str, section: CredentialFileAuthSection, config: Config) -> None:
|
|
19
|
+
super().__init__(name)
|
|
20
|
+
|
|
21
|
+
path = config.default_section.working_dir() / section.path()
|
|
22
|
+
try:
|
|
23
|
+
with open(path, encoding="utf-8") as f:
|
|
24
|
+
lines = list(f)
|
|
25
|
+
except UnicodeDecodeError:
|
|
26
|
+
raise AuthLoadError(f"Credential file at {fmt_real_path(path)} is not encoded using UTF-8")
|
|
27
|
+
except OSError as e:
|
|
28
|
+
raise AuthLoadError(f"No credential file at {fmt_real_path(path)}") from e
|
|
29
|
+
|
|
30
|
+
if len(lines) != 2:
|
|
31
|
+
raise AuthLoadError("Credential file must be two lines long")
|
|
32
|
+
[uline, pline] = lines
|
|
33
|
+
uline = uline[:-1] # Remove trailing newline
|
|
34
|
+
if pline.endswith("\n"):
|
|
35
|
+
pline = pline[:-1]
|
|
36
|
+
|
|
37
|
+
if not uline.startswith("username="):
|
|
38
|
+
raise AuthLoadError("First line must start with 'username='")
|
|
39
|
+
if not pline.startswith("password="):
|
|
40
|
+
raise AuthLoadError("Second line must start with 'password='")
|
|
41
|
+
|
|
42
|
+
self._username = uline[9:]
|
|
43
|
+
self._password = pline[9:]
|
|
44
|
+
|
|
45
|
+
async def credentials(self) -> Tuple[str, str]:
|
|
46
|
+
return self._username, self._password
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from typing import Optional, Tuple
|
|
2
|
+
|
|
3
|
+
import keyring
|
|
4
|
+
|
|
5
|
+
from ..logging import log
|
|
6
|
+
from ..utils import agetpass, ainput
|
|
7
|
+
from ..version import NAME
|
|
8
|
+
from .authenticator import Authenticator, AuthError, AuthSection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KeyringAuthSection(AuthSection):
|
|
12
|
+
def username(self) -> Optional[str]:
|
|
13
|
+
return self.s.get("username")
|
|
14
|
+
|
|
15
|
+
def keyring_name(self) -> str:
|
|
16
|
+
return self.s.get("keyring_name", fallback=NAME)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KeyringAuthenticator(Authenticator):
|
|
20
|
+
|
|
21
|
+
def __init__(self, name: str, section: KeyringAuthSection) -> None:
|
|
22
|
+
super().__init__(name)
|
|
23
|
+
|
|
24
|
+
self._username = section.username()
|
|
25
|
+
self._password: Optional[str] = None
|
|
26
|
+
self._keyring_name = section.keyring_name()
|
|
27
|
+
|
|
28
|
+
self._password_invalidated = False
|
|
29
|
+
self._username_fixed = section.username() is not None
|
|
30
|
+
|
|
31
|
+
async def credentials(self) -> Tuple[str, str]:
|
|
32
|
+
# Request the username
|
|
33
|
+
if self._username is None:
|
|
34
|
+
async with log.exclusive_output():
|
|
35
|
+
self._username = await ainput("Username: ")
|
|
36
|
+
|
|
37
|
+
# First try looking it up in the keyring.
|
|
38
|
+
# Do not look it up if it was invalidated - we want to re-prompt in this case
|
|
39
|
+
if self._password is None and not self._password_invalidated:
|
|
40
|
+
self._password = keyring.get_password(self._keyring_name, self._username)
|
|
41
|
+
|
|
42
|
+
# If that fails it wasn't saved in the keyring - we need to
|
|
43
|
+
# read it from the user and store it
|
|
44
|
+
if self._password is None:
|
|
45
|
+
async with log.exclusive_output():
|
|
46
|
+
self._password = await agetpass("Password: ")
|
|
47
|
+
keyring.set_password(self._keyring_name, self._username, self._password)
|
|
48
|
+
|
|
49
|
+
self._password_invalidated = False
|
|
50
|
+
return self._username, self._password
|
|
51
|
+
|
|
52
|
+
def invalidate_credentials(self) -> None:
|
|
53
|
+
if not self._username_fixed:
|
|
54
|
+
self.invalidate_username()
|
|
55
|
+
self.invalidate_password()
|
|
56
|
+
|
|
57
|
+
def invalidate_username(self) -> None:
|
|
58
|
+
if self._username_fixed:
|
|
59
|
+
raise AuthError("Configured username is invalid")
|
|
60
|
+
else:
|
|
61
|
+
self._username = None
|
|
62
|
+
|
|
63
|
+
def invalidate_password(self) -> None:
|
|
64
|
+
self._password = None
|
|
65
|
+
self._password_invalidated = True
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import List, Tuple
|
|
4
|
+
|
|
5
|
+
from ..logging import log
|
|
6
|
+
from .authenticator import Authenticator, AuthError, AuthSection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PassAuthSection(AuthSection):
|
|
10
|
+
def passname(self) -> str:
|
|
11
|
+
if (value := self.s.get("passname")) is None:
|
|
12
|
+
self.missing_value("passname")
|
|
13
|
+
return value
|
|
14
|
+
|
|
15
|
+
def username_prefixes(self) -> List[str]:
|
|
16
|
+
value = self.s.get("username_prefixes", "login,username,user")
|
|
17
|
+
return [prefix.lower() for prefix in value.split(",")]
|
|
18
|
+
|
|
19
|
+
def password_prefixes(self) -> List[str]:
|
|
20
|
+
value = self.s.get("password_prefixes", "password,pass,secret")
|
|
21
|
+
return [prefix.lower() for prefix in value.split(",")]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PassAuthenticator(Authenticator):
|
|
25
|
+
PREFIXED_LINE_RE = r"([a-zA-Z]+):\s?(.*)" # to be used with fullmatch
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: str, section: PassAuthSection) -> None:
|
|
28
|
+
super().__init__(name)
|
|
29
|
+
|
|
30
|
+
self._passname = section.passname()
|
|
31
|
+
self._username_prefixes = section.username_prefixes()
|
|
32
|
+
self._password_prefixes = section.password_prefixes()
|
|
33
|
+
|
|
34
|
+
async def credentials(self) -> Tuple[str, str]:
|
|
35
|
+
log.explain_topic("Obtaining credentials from pass")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
log.explain(f"Calling 'pass show {self._passname}'")
|
|
39
|
+
result = subprocess.check_output(["pass", "show", self._passname], text=True)
|
|
40
|
+
except subprocess.CalledProcessError as e:
|
|
41
|
+
raise AuthError(f"Failed to get password info from {self._passname}: {e}")
|
|
42
|
+
|
|
43
|
+
prefixed = {}
|
|
44
|
+
unprefixed = []
|
|
45
|
+
for line in result.strip().splitlines():
|
|
46
|
+
if match := re.fullmatch(self.PREFIXED_LINE_RE, line):
|
|
47
|
+
prefix = match.group(1).lower()
|
|
48
|
+
value = match.group(2)
|
|
49
|
+
log.explain(f"Found prefixed line {line!r} with prefix {prefix!r}, value {value!r}")
|
|
50
|
+
if prefix in prefixed:
|
|
51
|
+
raise AuthError(f"Prefix {prefix} specified multiple times")
|
|
52
|
+
prefixed[prefix] = value
|
|
53
|
+
else:
|
|
54
|
+
log.explain(f"Found unprefixed line {line!r}")
|
|
55
|
+
unprefixed.append(line)
|
|
56
|
+
|
|
57
|
+
username = None
|
|
58
|
+
for prefix in self._username_prefixes:
|
|
59
|
+
log.explain(f"Looking for username at prefix {prefix!r}")
|
|
60
|
+
if prefix in prefixed:
|
|
61
|
+
username = prefixed[prefix]
|
|
62
|
+
log.explain(f"Found username {username!r}")
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
password = None
|
|
66
|
+
for prefix in self._password_prefixes:
|
|
67
|
+
log.explain(f"Looking for password at prefix {prefix!r}")
|
|
68
|
+
if prefix in prefixed:
|
|
69
|
+
password = prefixed[prefix]
|
|
70
|
+
log.explain(f"Found password {password!r}")
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
if password is None and username is None:
|
|
74
|
+
log.explain("No username and password found so far")
|
|
75
|
+
log.explain("Using first unprefixed line as password")
|
|
76
|
+
log.explain("Using second unprefixed line as username")
|
|
77
|
+
elif password is None:
|
|
78
|
+
log.explain("No password found so far")
|
|
79
|
+
log.explain("Using first unprefixed line as password")
|
|
80
|
+
elif username is None:
|
|
81
|
+
log.explain("No username found so far")
|
|
82
|
+
log.explain("Using first unprefixed line as username")
|
|
83
|
+
|
|
84
|
+
if password is None:
|
|
85
|
+
if not unprefixed:
|
|
86
|
+
log.explain("Not enough unprefixed lines left")
|
|
87
|
+
raise AuthError("Password could not be determined")
|
|
88
|
+
password = unprefixed.pop(0)
|
|
89
|
+
log.explain(f"Found password {password!r}")
|
|
90
|
+
|
|
91
|
+
if username is None:
|
|
92
|
+
if not unprefixed:
|
|
93
|
+
log.explain("Not enough unprefixed lines left")
|
|
94
|
+
raise AuthError("Username could not be determined")
|
|
95
|
+
username = unprefixed.pop(0)
|
|
96
|
+
log.explain(f"Found username {username!r}")
|
|
97
|
+
|
|
98
|
+
return username, password
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Optional, Tuple
|
|
2
|
+
|
|
3
|
+
from ..logging import log
|
|
4
|
+
from ..utils import agetpass, ainput
|
|
5
|
+
from .authenticator import Authenticator, AuthError, AuthSection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SimpleAuthSection(AuthSection):
|
|
9
|
+
def username(self) -> Optional[str]:
|
|
10
|
+
return self.s.get("username")
|
|
11
|
+
|
|
12
|
+
def password(self) -> Optional[str]:
|
|
13
|
+
return self.s.get("password")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SimpleAuthenticator(Authenticator):
|
|
17
|
+
def __init__(self, name: str, section: SimpleAuthSection) -> None:
|
|
18
|
+
super().__init__(name)
|
|
19
|
+
|
|
20
|
+
self._username = section.username()
|
|
21
|
+
self._password = section.password()
|
|
22
|
+
|
|
23
|
+
self._username_fixed = self.username is not None
|
|
24
|
+
self._password_fixed = self.password is not None
|
|
25
|
+
|
|
26
|
+
async def credentials(self) -> Tuple[str, str]:
|
|
27
|
+
if self._username is not None and self._password is not None:
|
|
28
|
+
return self._username, self._password
|
|
29
|
+
|
|
30
|
+
async with log.exclusive_output():
|
|
31
|
+
if self._username is None:
|
|
32
|
+
self._username = await ainput("Username: ")
|
|
33
|
+
else:
|
|
34
|
+
print(f"Username: {self._username}")
|
|
35
|
+
|
|
36
|
+
if self._password is None:
|
|
37
|
+
self._password = await agetpass("Password: ")
|
|
38
|
+
|
|
39
|
+
# Intentionally returned inside the context manager so we know
|
|
40
|
+
# they're both not None
|
|
41
|
+
return self._username, self._password
|
|
42
|
+
|
|
43
|
+
def invalidate_credentials(self) -> None:
|
|
44
|
+
if self._username_fixed and self._password_fixed:
|
|
45
|
+
raise AuthError("Configured credentials are invalid")
|
|
46
|
+
|
|
47
|
+
if not self._username_fixed:
|
|
48
|
+
self._username = None
|
|
49
|
+
if not self._password_fixed:
|
|
50
|
+
self._password = None
|
|
51
|
+
|
|
52
|
+
def invalidate_username(self) -> None:
|
|
53
|
+
if self._username_fixed:
|
|
54
|
+
raise AuthError("Configured username is invalid")
|
|
55
|
+
else:
|
|
56
|
+
self._username = None
|
|
57
|
+
|
|
58
|
+
def invalidate_password(self) -> None:
|
|
59
|
+
if self._password_fixed:
|
|
60
|
+
raise AuthError("Configured password is invalid")
|
|
61
|
+
else:
|
|
62
|
+
self._password = None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Tuple
|
|
2
|
+
|
|
3
|
+
from ..logging import log
|
|
4
|
+
from ..utils import ainput
|
|
5
|
+
from .authenticator import Authenticator, AuthError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TfaAuthenticator(Authenticator):
|
|
9
|
+
def __init__(self, name: str) -> None:
|
|
10
|
+
super().__init__(name)
|
|
11
|
+
|
|
12
|
+
async def username(self) -> str:
|
|
13
|
+
raise AuthError("TFA authenticator does not support usernames")
|
|
14
|
+
|
|
15
|
+
async def password(self) -> str:
|
|
16
|
+
async with log.exclusive_output():
|
|
17
|
+
code = await ainput("TFA code: ")
|
|
18
|
+
return code
|
|
19
|
+
|
|
20
|
+
async def credentials(self) -> Tuple[str, str]:
|
|
21
|
+
raise AuthError("TFA authenticator does not support usernames")
|
|
22
|
+
|
|
23
|
+
def invalidate_username(self) -> None:
|
|
24
|
+
raise AuthError("TFA authenticator does not support usernames")
|
|
25
|
+
|
|
26
|
+
def invalidate_password(self) -> None:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def invalidate_credentials(self) -> None:
|
|
30
|
+
pass
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# isort: skip_file
|
|
2
|
+
|
|
3
|
+
# The order of imports matters because each command module registers itself
|
|
4
|
+
# with the parser from ".parser" and the import order affects the order in
|
|
5
|
+
# which they appear in the help. Because of this, isort is disabled for this
|
|
6
|
+
# file. Also, since we're reexporting or just using the side effect of
|
|
7
|
+
# importing itself, we get a few linting warnings, which we're disabling as
|
|
8
|
+
# well.
|
|
9
|
+
|
|
10
|
+
from . import command_local # noqa: F401 imported but unused
|
|
11
|
+
from . import command_ilias_web # noqa: F401 imported but unused
|
|
12
|
+
from . import command_kit_ilias_web # noqa: F401 imported but unused
|
|
13
|
+
from . import command_kit_ipd # noqa: F401 imported but unused
|
|
14
|
+
from .parser import PARSER, ParserLoadError, load_default_section # noqa: F401 imported but unused
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import configparser
|
|
3
|
+
|
|
4
|
+
from ..logging import log
|
|
5
|
+
from .common_ilias_args import configure_common_group_args, load_common
|
|
6
|
+
from .parser import CRAWLER_PARSER, SUBPARSERS, load_crawler
|
|
7
|
+
|
|
8
|
+
COMMAND_NAME = "ilias-web"
|
|
9
|
+
|
|
10
|
+
SUBPARSER = SUBPARSERS.add_parser(
|
|
11
|
+
COMMAND_NAME,
|
|
12
|
+
parents=[CRAWLER_PARSER],
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
GROUP = SUBPARSER.add_argument_group(
|
|
16
|
+
title=f"{COMMAND_NAME} crawler arguments",
|
|
17
|
+
description=f"arguments for the '{COMMAND_NAME}' crawler",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
GROUP.add_argument(
|
|
21
|
+
"--base-url",
|
|
22
|
+
type=str,
|
|
23
|
+
metavar="BASE_URL",
|
|
24
|
+
help="The base url of the ilias instance"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
GROUP.add_argument(
|
|
28
|
+
"--client-id",
|
|
29
|
+
type=str,
|
|
30
|
+
metavar="CLIENT_ID",
|
|
31
|
+
help="The client id of the ilias instance"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
configure_common_group_args(GROUP)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load(
|
|
38
|
+
args: argparse.Namespace,
|
|
39
|
+
parser: configparser.ConfigParser,
|
|
40
|
+
) -> None:
|
|
41
|
+
log.explain(f"Creating config for command '{COMMAND_NAME}'")
|
|
42
|
+
|
|
43
|
+
parser["crawl:ilias"] = {}
|
|
44
|
+
section = parser["crawl:ilias"]
|
|
45
|
+
load_crawler(args, section)
|
|
46
|
+
|
|
47
|
+
section["type"] = COMMAND_NAME
|
|
48
|
+
if args.ilias_url is not None:
|
|
49
|
+
section["base_url"] = args.ilias_url
|
|
50
|
+
if args.client_id is not None:
|
|
51
|
+
section["client_id"] = args.client_id
|
|
52
|
+
|
|
53
|
+
load_common(section, args, parser)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
SUBPARSER.set_defaults(command=load)
|