leaflink 0.1.0__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.
leaflink/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """leaflink package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
leaflink/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from leaflink.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Authentication helpers."""
@@ -0,0 +1,165 @@
1
+ """Browser-assisted login using Playwright when available."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Iterable
8
+ from urllib.parse import urlparse
9
+
10
+ from leaflink.client.models import SessionCookie
11
+ from leaflink.exceptions import AuthenticationError
12
+ from leaflink.utils.console import print_console
13
+
14
+ SUPPORTED_HOSTS = {"www.overleaf.com", "cn.overleaf.com"}
15
+ SUPPORTED_COOKIE_DOMAINS = {"overleaf.com", *SUPPORTED_HOSTS}
16
+ IMPORTANT_COOKIE_NAMES = {
17
+ "sharelatex.sid",
18
+ "overleaf_session2",
19
+ "connect.sid",
20
+ "_csrf",
21
+ "XSRF-TOKEN",
22
+ }
23
+ COOKIE_NAME_BLACKLIST_PREFIXES = (
24
+ "_ga",
25
+ "_gid",
26
+ "_gat",
27
+ "ajs_",
28
+ "amplitude_",
29
+ "mp_",
30
+ )
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class BrowserLoginResult:
35
+ base_url: str
36
+ cookies: list[SessionCookie]
37
+
38
+
39
+ def select_relevant_cookies(cookies: Iterable[dict[str, object]]) -> list[SessionCookie]:
40
+ selected: list[SessionCookie] = []
41
+ fallback: list[SessionCookie] = []
42
+ seen: set[tuple[str, str, str]] = set()
43
+ for item in cookies:
44
+ name = str(item.get("name", ""))
45
+ domain = str(item.get("domain", "")).lstrip(".").lower()
46
+ if not is_supported_cookie_domain(domain):
47
+ continue
48
+ cookie = SessionCookie(
49
+ name=name,
50
+ value=str(item.get("value", "")),
51
+ domain=str(item.get("domain", "")),
52
+ path=str(item.get("path", "/")),
53
+ secure=bool(item.get("secure", True)),
54
+ http_only=bool(item.get("httpOnly", True)),
55
+ )
56
+ key = (cookie.domain, cookie.path, cookie.name)
57
+ if key in seen:
58
+ continue
59
+ seen.add(key)
60
+ if name in IMPORTANT_COOKIE_NAMES or name.endswith(".sid"):
61
+ selected.append(cookie)
62
+ continue
63
+ if not any(name.startswith(prefix) for prefix in COOKIE_NAME_BLACKLIST_PREFIXES):
64
+ fallback.append(cookie)
65
+ return selected or fallback
66
+
67
+
68
+ def select_supported_cookies(cookies: Iterable[dict[str, object]]) -> list[SessionCookie]:
69
+ supported: list[SessionCookie] = []
70
+ for item in cookies:
71
+ domain = str(item.get("domain", "")).lstrip(".").lower()
72
+ if not is_supported_cookie_domain(domain):
73
+ continue
74
+ supported.append(
75
+ SessionCookie(
76
+ name=str(item.get("name", "")),
77
+ value=str(item.get("value", "")),
78
+ domain=str(item.get("domain", "")),
79
+ path=str(item.get("path", "/")),
80
+ secure=bool(item.get("secure", True)),
81
+ http_only=bool(item.get("httpOnly", True)),
82
+ )
83
+ )
84
+ return supported
85
+
86
+
87
+ def has_supported_cookies(cookies: Iterable[dict[str, object]]) -> bool:
88
+ for item in cookies:
89
+ domain = str(item.get("domain", "")).lstrip(".").lower()
90
+ if is_supported_cookie_domain(domain):
91
+ return True
92
+ return False
93
+
94
+
95
+ def is_supported_cookie_domain(domain: str) -> bool:
96
+ normalized = domain.lstrip(".").lower()
97
+ if normalized in SUPPORTED_COOKIE_DOMAINS:
98
+ return True
99
+ return any(host.endswith(f".{normalized}") for host in SUPPORTED_HOSTS)
100
+
101
+
102
+ def is_project_dashboard(urls: Iterable[str]) -> bool:
103
+ for url in urls:
104
+ parsed = urlparse(url)
105
+ if parsed.netloc in SUPPORTED_HOSTS and parsed.path.startswith("/project"):
106
+ return True
107
+ return False
108
+
109
+
110
+ def detect_base_url(urls: Iterable[str], fallback: str) -> str:
111
+ detected = fallback.rstrip("/")
112
+ for url in urls:
113
+ host = urlparse(url).netloc
114
+ if host in SUPPORTED_HOSTS:
115
+ detected = f"https://{host}"
116
+ return detected
117
+
118
+
119
+ def login_with_browser(base_url: str, timeout_seconds: int = 300) -> BrowserLoginResult:
120
+ try:
121
+ from playwright.sync_api import Error as PlaywrightError
122
+ from playwright.sync_api import sync_playwright
123
+ except ImportError as exc: # pragma: no cover
124
+ raise AuthenticationError(
125
+ "Playwright is not installed. Install `leaflink[browser]` or use `--cookie-file`."
126
+ ) from exc
127
+
128
+ login_url = f"{base_url.rstrip('/')}/login"
129
+ with sync_playwright() as playwright: # pragma: no cover
130
+ browser = playwright.chromium.launch(headless=False)
131
+ context = browser.new_context()
132
+ page = context.new_page()
133
+ page.goto(login_url, wait_until="domcontentloaded")
134
+ print_console(
135
+ "auth",
136
+ "Complete login in the browser window. leaflink will detect the session automatically and close the browser.",
137
+ )
138
+ deadline = time.monotonic() + timeout_seconds
139
+ last_urls = [page.url]
140
+ while time.monotonic() < deadline:
141
+ try:
142
+ context.pages
143
+ urls = [current.url for current in context.pages]
144
+ last_urls = urls or last_urls
145
+ browser_cookies = context.cookies()
146
+ cookies = select_relevant_cookies(browser_cookies)
147
+ except PlaywrightError as exc:
148
+ raise AuthenticationError(
149
+ "The browser window was closed before leaflink captured a reusable session. "
150
+ "Please run `leaflink login` again and leave the browser open until leaflink confirms success."
151
+ ) from exc
152
+
153
+ if is_project_dashboard(last_urls) and has_supported_cookies(browser_cookies):
154
+ if not cookies:
155
+ cookies = select_supported_cookies(browser_cookies)
156
+ resolved_base_url = detect_base_url(last_urls, fallback=base_url)
157
+ browser.close()
158
+ return BrowserLoginResult(base_url=resolved_base_url, cookies=cookies)
159
+ page.wait_for_timeout(1000)
160
+
161
+ browser.close()
162
+ raise AuthenticationError(
163
+ "Timed out waiting for a successful Overleaf session. "
164
+ "Please make sure the project dashboard opens in the browser before closing it."
165
+ )
@@ -0,0 +1,26 @@
1
+ """Load cookies from a JSON file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from leaflink.client.models import SessionCookie
9
+
10
+
11
+ def import_cookies_from_file(path: Path) -> list[SessionCookie]:
12
+ raw = json.loads(path.read_text(encoding="utf-8"))
13
+ cookies: list[SessionCookie] = []
14
+ items = raw["cookies"] if isinstance(raw, dict) and "cookies" in raw else raw
15
+ for item in items:
16
+ cookies.append(
17
+ SessionCookie(
18
+ name=item["name"],
19
+ value=item["value"],
20
+ domain=item["domain"],
21
+ path=item.get("path", "/"),
22
+ secure=bool(item.get("secure", True)),
23
+ http_only=bool(item.get("httpOnly", item.get("http_only", True))),
24
+ )
25
+ )
26
+ return cookies
@@ -0,0 +1,100 @@
1
+ """Persist and load authentication state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ from leaflink.auth.browser_login import BrowserLoginResult, login_with_browser
10
+ from leaflink.auth.cookie_import import import_cookies_from_file
11
+ from leaflink.client.models import AuthSession, SessionCookie
12
+ from leaflink.utils.paths import app_config_dir
13
+ from leaflink.utils.time import utc_now_iso
14
+
15
+
16
+ class AuthManager:
17
+ """Store auth sessions in the user config directory."""
18
+
19
+ def __init__(self, root: Path | None = None) -> None:
20
+ self.root = root or app_config_dir()
21
+ self.root.mkdir(parents=True, exist_ok=True)
22
+ self.auth_path = self.root / "auth.json"
23
+
24
+ def load_all(self) -> dict[str, AuthSession]:
25
+ if not self.auth_path.exists():
26
+ return {}
27
+ raw = json.loads(self.auth_path.read_text(encoding="utf-8"))
28
+ sessions: dict[str, AuthSession] = {}
29
+ for base_url, item in raw.items():
30
+ sessions[base_url] = AuthSession(
31
+ base_url=base_url,
32
+ cookies=[SessionCookie(**cookie) for cookie in item["cookies"]],
33
+ created_at=item["created_at"],
34
+ updated_at=item["updated_at"],
35
+ )
36
+ return sessions
37
+
38
+ def load(self, base_url: str) -> AuthSession | None:
39
+ return self.load_all().get(base_url.rstrip("/"))
40
+
41
+ def save(self, session: AuthSession) -> None:
42
+ sessions = self.load_all()
43
+ sessions[session.base_url.rstrip("/")] = session
44
+ payload = {
45
+ base_url: {
46
+ "cookies": [asdict(cookie) for cookie in saved.cookies],
47
+ "created_at": saved.created_at,
48
+ "updated_at": saved.updated_at,
49
+ }
50
+ for base_url, saved in sessions.items()
51
+ }
52
+ self.auth_path.write_text(
53
+ json.dumps(payload, indent=2, sort_keys=True),
54
+ encoding="utf-8",
55
+ )
56
+
57
+ def login(self, base_url: str, cookie_file: Path | None = None) -> AuthSession:
58
+ base_url = base_url.rstrip("/")
59
+ if cookie_file is not None:
60
+ cookies = import_cookies_from_file(cookie_file)
61
+ resolved_base_url = base_url
62
+ else:
63
+ browser_result = login_with_browser(base_url)
64
+ cookies = browser_result.cookies
65
+ resolved_base_url = browser_result.base_url
66
+ existing = self.load(base_url)
67
+ now = utc_now_iso()
68
+ session = AuthSession(
69
+ base_url=resolved_base_url,
70
+ cookies=cookies,
71
+ created_at=existing.created_at if existing else now,
72
+ updated_at=now,
73
+ )
74
+ self.save(session)
75
+ return session
76
+
77
+ def logout(self, base_url: str | None = None) -> int:
78
+ if not self.auth_path.exists():
79
+ return 0
80
+ if base_url is None:
81
+ self.auth_path.unlink()
82
+ return 1
83
+ sessions = self.load_all()
84
+ removed = 1 if sessions.pop(base_url.rstrip("/"), None) else 0
85
+ if sessions:
86
+ payload = {
87
+ url: {
88
+ "cookies": [asdict(cookie) for cookie in session.cookies],
89
+ "created_at": session.created_at,
90
+ "updated_at": session.updated_at,
91
+ }
92
+ for url, session in sessions.items()
93
+ }
94
+ self.auth_path.write_text(
95
+ json.dumps(payload, indent=2, sort_keys=True),
96
+ encoding="utf-8",
97
+ )
98
+ elif self.auth_path.exists():
99
+ self.auth_path.unlink()
100
+ return removed