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 +5 -0
- leaflink/__main__.py +3 -0
- leaflink/auth/__init__.py +1 -0
- leaflink/auth/browser_login.py +165 -0
- leaflink/auth/cookie_import.py +26 -0
- leaflink/auth/manager.py +100 -0
- leaflink/cli.py +513 -0
- leaflink/client/__init__.py +1 -0
- leaflink/client/models.py +97 -0
- leaflink/client/overleaf_client.py +642 -0
- leaflink/client/playwright_bridge.py +261 -0
- leaflink/config.py +62 -0
- leaflink/exceptions.py +42 -0
- leaflink/project/__init__.py +1 -0
- leaflink/project/metadata.py +55 -0
- leaflink/sync/__init__.py +1 -0
- leaflink/sync/conflict.py +172 -0
- leaflink/sync/diff.py +62 -0
- leaflink/sync/engine.py +534 -0
- leaflink/sync/ignore.py +61 -0
- leaflink/sync/state.py +116 -0
- leaflink/sync/watcher.py +91 -0
- leaflink/utils/__init__.py +1 -0
- leaflink/utils/console.py +59 -0
- leaflink/utils/hashing.py +23 -0
- leaflink/utils/locks.py +57 -0
- leaflink/utils/logging.py +10 -0
- leaflink/utils/paths.py +31 -0
- leaflink/utils/time.py +25 -0
- leaflink-0.1.0.dist-info/METADATA +440 -0
- leaflink-0.1.0.dist-info/RECORD +35 -0
- leaflink-0.1.0.dist-info/WHEEL +5 -0
- leaflink-0.1.0.dist-info/entry_points.txt +2 -0
- leaflink-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
- leaflink-0.1.0.dist-info/top_level.txt +1 -0
leaflink/__init__.py
ADDED
leaflink/__main__.py
ADDED
|
@@ -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
|
leaflink/auth/manager.py
ADDED
|
@@ -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
|