e3cli 0.3.1__py3-none-any.whl
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.
- e3cli/__init__.py +3 -0
- e3cli/__main__.py +5 -0
- e3cli/ai/__init__.py +1 -0
- e3cli/api/__init__.py +0 -0
- e3cli/api/assignments.py +38 -0
- e3cli/api/client.py +77 -0
- e3cli/api/courses.py +27 -0
- e3cli/api/files.py +25 -0
- e3cli/api/site.py +10 -0
- e3cli/auth.py +33 -0
- e3cli/cli.py +61 -0
- e3cli/commands/__init__.py +0 -0
- e3cli/commands/_common.py +29 -0
- e3cli/commands/assignments.py +91 -0
- e3cli/commands/courses.py +39 -0
- e3cli/commands/download.py +105 -0
- e3cli/commands/login.py +68 -0
- e3cli/commands/logout.py +19 -0
- e3cli/commands/schedule.py +42 -0
- e3cli/commands/setup.py +142 -0
- e3cli/commands/submit.py +77 -0
- e3cli/commands/sync.py +115 -0
- e3cli/config.py +98 -0
- e3cli/credential.py +116 -0
- e3cli/i18n.py +233 -0
- e3cli/scheduler/__init__.py +0 -0
- e3cli/scheduler/cron.py +54 -0
- e3cli/storage/__init__.py +0 -0
- e3cli/storage/db.py +124 -0
- e3cli/storage/models.py +32 -0
- e3cli/storage/tracking.py +11 -0
- e3cli-0.3.1.dist-info/METADATA +370 -0
- e3cli-0.3.1.dist-info/RECORD +36 -0
- e3cli-0.3.1.dist-info/WHEEL +4 -0
- e3cli-0.3.1.dist-info/entry_points.txt +2 -0
- e3cli-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""e3cli schedule"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from e3cli.config import load_config
|
|
9
|
+
from e3cli.i18n import t
|
|
10
|
+
from e3cli.scheduler import cron
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def enable(
|
|
18
|
+
interval: int = typer.Option(None, "--interval", "-i", help=t("sched.opt_interval")),
|
|
19
|
+
):
|
|
20
|
+
"""Enable automatic sync (install cron job)."""
|
|
21
|
+
cfg = load_config()
|
|
22
|
+
minutes = interval or cfg.schedule.interval_minutes
|
|
23
|
+
cron.install(minutes)
|
|
24
|
+
console.print(f"[green]{t('sched.enabled', m=minutes)}[/green]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def disable():
|
|
29
|
+
"""Disable automatic sync (remove cron job)."""
|
|
30
|
+
cron.uninstall()
|
|
31
|
+
console.print(f"[green]{t('sched.disabled')}[/green]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def status():
|
|
36
|
+
"""Show current schedule status."""
|
|
37
|
+
if cron.is_installed():
|
|
38
|
+
line = cron.get_schedule_line()
|
|
39
|
+
console.print(f"[green]{t('sched.status_on')}[/green]")
|
|
40
|
+
console.print(f" [dim]{line}[/dim]")
|
|
41
|
+
else:
|
|
42
|
+
console.print(f"[yellow]{t('sched.status_off')}[/yellow]")
|
e3cli/commands/setup.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""e3cli setup — interactive setup wizard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from e3cli import __version__
|
|
14
|
+
from e3cli.auth import AuthError, get_token
|
|
15
|
+
from e3cli.config import CONFIG_FILE, ensure_dirs, save_token
|
|
16
|
+
from e3cli.credential import save_credentials
|
|
17
|
+
from e3cli.i18n import set_lang, t
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
app = typer.Typer()
|
|
21
|
+
|
|
22
|
+
BANNER = r"""
|
|
23
|
+
_____ ____ _____ _ _____
|
|
24
|
+
/ ____|___ \ / ____| | |_ _|
|
|
25
|
+
| |__ __) | | | | | |
|
|
26
|
+
| __| |__ <| | | | | |
|
|
27
|
+
| |____ ___) | |____| |____ _| |_
|
|
28
|
+
|______|____/ \_____|______|_____|
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_first_run() -> bool:
|
|
33
|
+
return not CONFIG_FILE.exists()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _choose_language() -> str:
|
|
37
|
+
"""讓使用者選擇語言 / Let user choose language."""
|
|
38
|
+
console.print("[bold cyan]Language / 語言[/bold cyan]")
|
|
39
|
+
console.print(" [cyan]1[/cyan] 繁體中文")
|
|
40
|
+
console.print(" [cyan]2[/cyan] English")
|
|
41
|
+
choice = typer.prompt("Choose / 請選擇", default="1", show_default=True)
|
|
42
|
+
if choice.strip() in ("2", "en", "EN", "english", "English"):
|
|
43
|
+
return "en"
|
|
44
|
+
return "zh"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_setup_wizard() -> None:
|
|
48
|
+
console.print()
|
|
49
|
+
console.print(Panel(
|
|
50
|
+
Text(BANNER, style="cyan", justify="center"),
|
|
51
|
+
title=f"[bold]Welcome to e3cli v{__version__}[/bold]",
|
|
52
|
+
subtitle="NYCU E3 Moodle automation tool",
|
|
53
|
+
border_style="cyan",
|
|
54
|
+
))
|
|
55
|
+
console.print()
|
|
56
|
+
|
|
57
|
+
# Step 0: Language
|
|
58
|
+
lang = _choose_language()
|
|
59
|
+
set_lang(lang)
|
|
60
|
+
console.print()
|
|
61
|
+
|
|
62
|
+
console.print(f"[bold]{t('setup.welcome')}[/bold]")
|
|
63
|
+
console.print()
|
|
64
|
+
|
|
65
|
+
# Step 1: Moodle URL
|
|
66
|
+
console.print(f"[bold cyan]Step 1/4[/bold cyan] — {t('setup.step_url')}")
|
|
67
|
+
console.print(f"[dim]{t('setup.step_url_hint')}[/dim]")
|
|
68
|
+
url = typer.prompt(
|
|
69
|
+
"Moodle URL",
|
|
70
|
+
default="https://e3p.nycu.edu.tw",
|
|
71
|
+
show_default=True,
|
|
72
|
+
).rstrip("/")
|
|
73
|
+
console.print()
|
|
74
|
+
|
|
75
|
+
# Step 2: Download directory
|
|
76
|
+
console.print(f"[bold cyan]Step 2/4[/bold cyan] — {t('setup.step_dir')}")
|
|
77
|
+
console.print(f"[dim]{t('setup.step_dir_hint')}[/dim]")
|
|
78
|
+
default_dir = os.path.expanduser("~/e3-downloads")
|
|
79
|
+
download_dir = typer.prompt(
|
|
80
|
+
t("setup.step_dir"),
|
|
81
|
+
default=default_dir,
|
|
82
|
+
show_default=True,
|
|
83
|
+
)
|
|
84
|
+
console.print()
|
|
85
|
+
|
|
86
|
+
# Step 3: Save config (including language preference)
|
|
87
|
+
console.print(f"[bold cyan]Step 3/4[/bold cyan] — {t('setup.step_save')}")
|
|
88
|
+
ensure_dirs()
|
|
89
|
+
config_content = f"""[moodle]
|
|
90
|
+
url = "{url}"
|
|
91
|
+
service = "moodle_mobile_app"
|
|
92
|
+
|
|
93
|
+
[storage]
|
|
94
|
+
download_dir = "{download_dir}"
|
|
95
|
+
|
|
96
|
+
[schedule]
|
|
97
|
+
interval_minutes = 60
|
|
98
|
+
notify = true
|
|
99
|
+
|
|
100
|
+
[general]
|
|
101
|
+
lang = "{lang}"
|
|
102
|
+
"""
|
|
103
|
+
CONFIG_FILE.write_text(config_content)
|
|
104
|
+
console.print(f"[green] {t('setup.config_saved', path=CONFIG_FILE)}[/green]")
|
|
105
|
+
console.print()
|
|
106
|
+
|
|
107
|
+
# Step 4: Login
|
|
108
|
+
console.print(f"[bold cyan]Step 4/4[/bold cyan] — {t('setup.step_login')}")
|
|
109
|
+
want_login = typer.confirm(t("setup.want_login"), default=True)
|
|
110
|
+
|
|
111
|
+
if want_login:
|
|
112
|
+
username = typer.prompt(f" {t('setup.prompt_id')}")
|
|
113
|
+
password = getpass.getpass(f" {t('login.prompt_pass')}")
|
|
114
|
+
|
|
115
|
+
console.print(f"[dim] {t('login.connecting', url=url)}[/dim]")
|
|
116
|
+
try:
|
|
117
|
+
token = get_token(url, username, password)
|
|
118
|
+
save_token(token)
|
|
119
|
+
|
|
120
|
+
save_creds = typer.confirm(f" {t('setup.want_save_creds')}", default=True)
|
|
121
|
+
if save_creds:
|
|
122
|
+
save_credentials(username, password)
|
|
123
|
+
console.print(f"[green] {t('login.success_saved')}[/green]")
|
|
124
|
+
else:
|
|
125
|
+
console.print(f"[green] {t('login.success')}[/green]")
|
|
126
|
+
except AuthError as e:
|
|
127
|
+
console.print(f"[red] ✗ {e}[/red]")
|
|
128
|
+
console.print(f"[dim] {t('setup.login_fail_hint')}[/dim]")
|
|
129
|
+
console.print()
|
|
130
|
+
|
|
131
|
+
# Done
|
|
132
|
+
console.print(Panel(
|
|
133
|
+
f"[bold green]{t('setup.done_title')}[/bold green]\n\n{t('setup.done_body')}",
|
|
134
|
+
title="[bold]Ready![/bold]",
|
|
135
|
+
border_style="green",
|
|
136
|
+
))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.callback(invoke_without_command=True)
|
|
140
|
+
def setup():
|
|
141
|
+
"""Re-run interactive setup wizard."""
|
|
142
|
+
run_setup_wizard()
|
e3cli/commands/submit.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""e3cli submit"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from e3cli.api.assignments import get_submission_status, save_submission
|
|
13
|
+
from e3cli.commands._common import get_client, get_db
|
|
14
|
+
from e3cli.i18n import t
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
app = typer.Typer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.callback(invoke_without_command=True)
|
|
21
|
+
def submit(
|
|
22
|
+
assignment_id: int = typer.Argument(..., help="Assignment ID"),
|
|
23
|
+
files: list[Path] = typer.Argument(..., help="File(s) to submit"),
|
|
24
|
+
text: str = typer.Option("", "--text", "-t", help=t("submit.opt_text")),
|
|
25
|
+
force: bool = typer.Option(False, "--force", "-f", help=t("submit.opt_force")),
|
|
26
|
+
):
|
|
27
|
+
"""Upload and submit an assignment."""
|
|
28
|
+
for f in files:
|
|
29
|
+
if not f.exists():
|
|
30
|
+
console.print(f"[red]✗ {t('submit.not_found', f=f)}[/red]")
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
client = get_client()
|
|
34
|
+
db = get_db()
|
|
35
|
+
|
|
36
|
+
console.print(f"[dim]{t('submit.checking', id=assignment_id)}[/dim]")
|
|
37
|
+
try:
|
|
38
|
+
status = get_submission_status(client, assignment_id)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
console.print(f"[red]{t('submit.check_fail', e=e)}[/red]")
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
|
|
43
|
+
assign_info = status.get("lastattempt", {}).get("assign", {})
|
|
44
|
+
duedate = assign_info.get("duedate", 0)
|
|
45
|
+
if duedate and duedate < int(time.time()) and not force:
|
|
46
|
+
dt = datetime.fromtimestamp(duedate).strftime("%Y-%m-%d %H:%M")
|
|
47
|
+
console.print(f"[red]✗ {t('submit.past_due', dt=dt)}[/red]")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
console.print(f"[dim]{t('submit.uploading', n=len(files))}[/dim]")
|
|
51
|
+
itemid = 0
|
|
52
|
+
for f in files:
|
|
53
|
+
result = client.upload_file(f, itemid=itemid)
|
|
54
|
+
if result and isinstance(result, list):
|
|
55
|
+
itemid = result[0].get("itemid", itemid)
|
|
56
|
+
console.print(f" ✓ {f.name}")
|
|
57
|
+
|
|
58
|
+
console.print(f"[dim]{t('submit.submitting')}[/dim]")
|
|
59
|
+
save_submission(client, assignment_id, itemid, text)
|
|
60
|
+
|
|
61
|
+
verify = get_submission_status(client, assignment_id)
|
|
62
|
+
sub_status = (
|
|
63
|
+
verify.get("lastattempt", {})
|
|
64
|
+
.get("submission", {})
|
|
65
|
+
.get("status", "unknown")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if sub_status == "submitted":
|
|
69
|
+
console.print(f"[green]{t('submit.ok')}[/green]")
|
|
70
|
+
db.update_assignment_status(assignment_id, "submitted")
|
|
71
|
+
elif sub_status == "draft":
|
|
72
|
+
console.print(f"[yellow]{t('submit.draft')}[/yellow]")
|
|
73
|
+
db.update_assignment_status(assignment_id, "draft")
|
|
74
|
+
else:
|
|
75
|
+
console.print(f"[yellow]⚠ Status: {sub_status}[/yellow]")
|
|
76
|
+
|
|
77
|
+
db.close()
|
e3cli/commands/sync.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""e3cli sync"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from e3cli.api.assignments import get_assignments
|
|
12
|
+
from e3cli.api.courses import get_course_contents, get_enrolled_courses
|
|
13
|
+
from e3cli.api.files import download_file
|
|
14
|
+
from e3cli.api.site import get_site_info
|
|
15
|
+
from e3cli.commands._common import get_client, get_db
|
|
16
|
+
from e3cli.config import load_config
|
|
17
|
+
from e3cli.i18n import t
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
app = typer.Typer()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sanitize(name: str) -> str:
|
|
24
|
+
return re.sub(r'[<>:"/\\|?*]', "_", name).strip().rstrip(".")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback(invoke_without_command=True)
|
|
28
|
+
def sync(
|
|
29
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help=t("sync.opt_quiet")),
|
|
30
|
+
):
|
|
31
|
+
"""Sync all course materials and assignment status."""
|
|
32
|
+
client = get_client()
|
|
33
|
+
db = get_db()
|
|
34
|
+
cfg = load_config()
|
|
35
|
+
download_dir = cfg.storage.download_dir
|
|
36
|
+
now = int(time.time())
|
|
37
|
+
|
|
38
|
+
info = get_site_info(client)
|
|
39
|
+
userid = info["userid"]
|
|
40
|
+
if not quiet:
|
|
41
|
+
console.print(f"[bold]{t('sync.syncing', name=info['fullname'])}[/bold]\n")
|
|
42
|
+
|
|
43
|
+
course_list = get_enrolled_courses(client, userid)
|
|
44
|
+
courseids = []
|
|
45
|
+
course_names = {}
|
|
46
|
+
|
|
47
|
+
for c in course_list:
|
|
48
|
+
cid = c["id"]
|
|
49
|
+
courseids.append(cid)
|
|
50
|
+
course_names[cid] = c.get("shortname", "")
|
|
51
|
+
db.upsert_course(cid, c.get("shortname", ""), c.get("fullname", ""))
|
|
52
|
+
|
|
53
|
+
new_files = 0
|
|
54
|
+
for c in course_list:
|
|
55
|
+
cid = c["id"]
|
|
56
|
+
cname = _sanitize(c.get("shortname", str(cid)))
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
contents = get_course_contents(client, cid)
|
|
60
|
+
except Exception:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
for section in contents:
|
|
64
|
+
section_name = _sanitize(section.get("name", "unnamed"))
|
|
65
|
+
for module in section.get("modules", []):
|
|
66
|
+
mid = module.get("id", 0)
|
|
67
|
+
for file_info in module.get("contents", []):
|
|
68
|
+
fname = file_info.get("filename", "")
|
|
69
|
+
furl = file_info.get("fileurl", "")
|
|
70
|
+
fsize = file_info.get("filesize", 0)
|
|
71
|
+
ftime = file_info.get("timemodified", 0)
|
|
72
|
+
|
|
73
|
+
if not fname or not furl:
|
|
74
|
+
continue
|
|
75
|
+
if db.is_downloaded(cid, mid, fname, ftime):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
dest = download_dir / cname / section_name / fname
|
|
79
|
+
try:
|
|
80
|
+
download_file(client, furl, dest)
|
|
81
|
+
db.record_download(cid, mid, fname, furl, fsize, ftime, str(dest), now)
|
|
82
|
+
new_files += 1
|
|
83
|
+
if not quiet:
|
|
84
|
+
console.print(f" [green]↓[/green] {cname}/{section_name}/{fname}")
|
|
85
|
+
except Exception as e:
|
|
86
|
+
if not quiet:
|
|
87
|
+
console.print(f" [red]✗[/red] {fname}: {e}")
|
|
88
|
+
|
|
89
|
+
time.sleep(0.3)
|
|
90
|
+
|
|
91
|
+
new_assignments = 0
|
|
92
|
+
if courseids:
|
|
93
|
+
try:
|
|
94
|
+
data = get_assignments(client, courseids)
|
|
95
|
+
for course in data.get("courses", []):
|
|
96
|
+
cid = course["id"]
|
|
97
|
+
cname = course_names.get(cid, "")
|
|
98
|
+
for a in course.get("assignments", []):
|
|
99
|
+
is_new = db.upsert_assignment(
|
|
100
|
+
a["id"], cid, cname, a["name"], a.get("duedate", 0), now,
|
|
101
|
+
)
|
|
102
|
+
if is_new:
|
|
103
|
+
new_assignments += 1
|
|
104
|
+
if not quiet:
|
|
105
|
+
console.print(
|
|
106
|
+
f" [yellow]{t('sync.new_assign', course=cname, name=a['name'])}[/yellow]"
|
|
107
|
+
)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
if not quiet:
|
|
110
|
+
console.print(f"[red]{t('sync.assign_fail', e=e)}[/red]")
|
|
111
|
+
|
|
112
|
+
if not quiet:
|
|
113
|
+
console.print(f"\n[green]{t('sync.done', files=new_files, assigns=new_assignments)}[/green]")
|
|
114
|
+
|
|
115
|
+
db.close()
|
e3cli/config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""設定管理 — 讀取/寫入 ~/.e3cli/config.toml"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".e3cli"
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
12
|
+
TOKEN_FILE = CONFIG_DIR / "token"
|
|
13
|
+
DB_PATH = CONFIG_DIR / "data" / "e3cli.db"
|
|
14
|
+
DEFAULT_DOWNLOAD_DIR = Path.home() / "e3-downloads"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MoodleConfig:
|
|
19
|
+
url: str = "https://e3p.nycu.edu.tw"
|
|
20
|
+
service: str = "moodle_mobile_app"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class StorageConfig:
|
|
25
|
+
download_dir: Path = field(default_factory=lambda: DEFAULT_DOWNLOAD_DIR)
|
|
26
|
+
db_path: Path = field(default_factory=lambda: DB_PATH)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ScheduleConfig:
|
|
31
|
+
interval_minutes: int = 60
|
|
32
|
+
notify: bool = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class GeneralConfig:
|
|
37
|
+
lang: str = "" # "" = auto-detect, "zh", "en"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Config:
|
|
42
|
+
moodle: MoodleConfig = field(default_factory=MoodleConfig)
|
|
43
|
+
storage: StorageConfig = field(default_factory=StorageConfig)
|
|
44
|
+
schedule: ScheduleConfig = field(default_factory=ScheduleConfig)
|
|
45
|
+
general: GeneralConfig = field(default_factory=GeneralConfig)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_dirs() -> None:
|
|
49
|
+
"""建立必要的目錄。"""
|
|
50
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
(CONFIG_DIR / "data").mkdir(exist_ok=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config() -> Config:
|
|
55
|
+
"""從 ~/.e3cli/config.toml 載入設定,不存在則使用預設值。"""
|
|
56
|
+
ensure_dirs()
|
|
57
|
+
cfg = Config()
|
|
58
|
+
|
|
59
|
+
if CONFIG_FILE.exists():
|
|
60
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
61
|
+
data = tomllib.load(f)
|
|
62
|
+
|
|
63
|
+
if "moodle" in data:
|
|
64
|
+
m = data["moodle"]
|
|
65
|
+
cfg.moodle.url = m.get("url", cfg.moodle.url)
|
|
66
|
+
cfg.moodle.service = m.get("service", cfg.moodle.service)
|
|
67
|
+
|
|
68
|
+
if "storage" in data:
|
|
69
|
+
s = data["storage"]
|
|
70
|
+
if "download_dir" in s:
|
|
71
|
+
cfg.storage.download_dir = Path(os.path.expanduser(s["download_dir"]))
|
|
72
|
+
if "db_path" in s:
|
|
73
|
+
cfg.storage.db_path = Path(os.path.expanduser(s["db_path"]))
|
|
74
|
+
|
|
75
|
+
if "schedule" in data:
|
|
76
|
+
sc = data["schedule"]
|
|
77
|
+
cfg.schedule.interval_minutes = sc.get("interval_minutes", cfg.schedule.interval_minutes)
|
|
78
|
+
cfg.schedule.notify = sc.get("notify", cfg.schedule.notify)
|
|
79
|
+
|
|
80
|
+
if "general" in data:
|
|
81
|
+
g = data["general"]
|
|
82
|
+
cfg.general.lang = g.get("lang", cfg.general.lang)
|
|
83
|
+
|
|
84
|
+
return cfg
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def save_token(token: str) -> None:
|
|
88
|
+
"""儲存 token 到 ~/.e3cli/token (chmod 600)。"""
|
|
89
|
+
ensure_dirs()
|
|
90
|
+
TOKEN_FILE.write_text(token)
|
|
91
|
+
TOKEN_FILE.chmod(0o600)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_token() -> str | None:
|
|
95
|
+
"""讀取已儲存的 token,不存在則回傳 None。"""
|
|
96
|
+
if TOKEN_FILE.exists():
|
|
97
|
+
return TOKEN_FILE.read_text().strip()
|
|
98
|
+
return None
|
e3cli/credential.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
安全帳密儲存 — 純 Python stdlib 實作 (無需 cryptography/Rust)。
|
|
3
|
+
|
|
4
|
+
加密流程:
|
|
5
|
+
1. 首次使用時產生 32 bytes 隨機 key,存入 ~/.e3cli/key (chmod 600)
|
|
6
|
+
2. 帳密 JSON → PBKDF2 派生加密金鑰 → AES-like XOR stream cipher → base64 編碼
|
|
7
|
+
3. 加上 HMAC-SHA256 驗證完整性,防止竄改
|
|
8
|
+
|
|
9
|
+
安全模型:
|
|
10
|
+
- key 檔案權限 0600,僅擁有者可讀
|
|
11
|
+
- key 與 credentials 分離
|
|
12
|
+
- HMAC 驗證確保資料完整性
|
|
13
|
+
- 密碼從不以明文寫入磁碟
|
|
14
|
+
- logout 時覆寫後再刪除
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
from e3cli.config import CONFIG_DIR, ensure_dirs
|
|
26
|
+
|
|
27
|
+
KEY_FILE = CONFIG_DIR / "key"
|
|
28
|
+
CRED_FILE = CONFIG_DIR / "credentials.enc"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_or_create_key() -> bytes:
|
|
32
|
+
"""取得加密金鑰,不存在則產生新的。"""
|
|
33
|
+
ensure_dirs()
|
|
34
|
+
if KEY_FILE.exists():
|
|
35
|
+
return KEY_FILE.read_bytes()
|
|
36
|
+
key = os.urandom(32)
|
|
37
|
+
KEY_FILE.write_bytes(key)
|
|
38
|
+
KEY_FILE.chmod(0o600)
|
|
39
|
+
return key
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _derive_key(master_key: bytes, salt: bytes) -> bytes:
|
|
43
|
+
"""用 PBKDF2-HMAC-SHA256 派生加密金鑰。"""
|
|
44
|
+
return hashlib.pbkdf2_hmac("sha256", master_key, salt, iterations=100_000)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _xor_bytes(data: bytes, key_stream: bytes) -> bytes:
|
|
48
|
+
"""XOR data with repeating key stream."""
|
|
49
|
+
return bytes(d ^ key_stream[i % len(key_stream)] for i, d in enumerate(data))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _encrypt(plaintext: bytes, master_key: bytes) -> bytes:
|
|
53
|
+
"""加密:salt + HMAC + ciphertext,base64 編碼輸出。"""
|
|
54
|
+
salt = os.urandom(16)
|
|
55
|
+
derived = _derive_key(master_key, salt)
|
|
56
|
+
ciphertext = _xor_bytes(plaintext, derived)
|
|
57
|
+
mac = hmac.new(derived, ciphertext, hashlib.sha256).digest()
|
|
58
|
+
# Format: salt (16) + mac (32) + ciphertext (variable)
|
|
59
|
+
return base64.b64encode(salt + mac + ciphertext)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _decrypt(encoded: bytes, master_key: bytes) -> bytes | None:
|
|
63
|
+
"""解密,驗證 HMAC 後回傳明文,失敗回傳 None。"""
|
|
64
|
+
try:
|
|
65
|
+
raw = base64.b64decode(encoded)
|
|
66
|
+
if len(raw) < 48: # salt(16) + mac(32) minimum
|
|
67
|
+
return None
|
|
68
|
+
salt = raw[:16]
|
|
69
|
+
stored_mac = raw[16:48]
|
|
70
|
+
ciphertext = raw[48:]
|
|
71
|
+
derived = _derive_key(master_key, salt)
|
|
72
|
+
# 驗證 HMAC
|
|
73
|
+
expected_mac = hmac.new(derived, ciphertext, hashlib.sha256).digest()
|
|
74
|
+
if not hmac.compare_digest(stored_mac, expected_mac):
|
|
75
|
+
return None
|
|
76
|
+
return _xor_bytes(ciphertext, derived)
|
|
77
|
+
except Exception:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def save_credentials(username: str, password: str) -> None:
|
|
82
|
+
"""加密儲存帳號密碼。"""
|
|
83
|
+
ensure_dirs()
|
|
84
|
+
key = _get_or_create_key()
|
|
85
|
+
data = json.dumps({"username": username, "password": password}).encode()
|
|
86
|
+
encrypted = _encrypt(data, key)
|
|
87
|
+
CRED_FILE.write_bytes(encrypted)
|
|
88
|
+
CRED_FILE.chmod(0o600)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_credentials() -> tuple[str, str] | None:
|
|
92
|
+
"""讀取已儲存的帳密,回傳 (username, password) 或 None。"""
|
|
93
|
+
if not CRED_FILE.exists() or not KEY_FILE.exists():
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
key = _get_or_create_key()
|
|
97
|
+
decrypted = _decrypt(CRED_FILE.read_bytes(), key)
|
|
98
|
+
if decrypted is None:
|
|
99
|
+
return None
|
|
100
|
+
data = json.loads(decrypted)
|
|
101
|
+
return data["username"], data["password"]
|
|
102
|
+
except (KeyError, json.JSONDecodeError):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def clear_credentials() -> None:
|
|
107
|
+
"""安全清除所有認證資料(帳密 + token + key)。"""
|
|
108
|
+
for path in [CRED_FILE, KEY_FILE, CONFIG_DIR / "token"]:
|
|
109
|
+
if path.exists():
|
|
110
|
+
path.write_bytes(b"\x00" * max(path.stat().st_size, 1))
|
|
111
|
+
path.unlink()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def has_credentials() -> bool:
|
|
115
|
+
"""是否已儲存帳密。"""
|
|
116
|
+
return CRED_FILE.exists() and KEY_FILE.exists()
|