cli-web-hackernews 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.
@@ -0,0 +1,290 @@
1
+ """Auth management for cli-web-hackernews — cookie-based HN authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from .exceptions import AuthError
14
+
15
+ HN_BASE = "https://news.ycombinator.com"
16
+ CONFIG_DIR = Path.home() / ".config" / "cli-web-hackernews"
17
+ AUTH_FILE = CONFIG_DIR / "auth.json"
18
+ ENV_VAR = "CLI_WEB_HACKERNEWS_AUTH_JSON"
19
+
20
+
21
+ def _get_config_dir() -> Path:
22
+ """Ensure config directory exists."""
23
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
24
+ return CONFIG_DIR
25
+
26
+
27
+ def save_auth(user_cookie: str, username: str) -> Path:
28
+ """Save auth cookie to auth.json with restrictive permissions."""
29
+ _get_config_dir()
30
+ data = {"user_cookie": user_cookie, "username": username}
31
+ AUTH_FILE.write_text(json.dumps(data, indent=2))
32
+ try:
33
+ os.chmod(AUTH_FILE, stat.S_IRUSR | stat.S_IWUSR) # 600
34
+ except OSError:
35
+ pass # Windows may not support chmod
36
+ return AUTH_FILE
37
+
38
+
39
+ def load_auth() -> dict[str, str]:
40
+ """Load auth from env var or file. Returns dict with user_cookie and username."""
41
+ # 1. Try env var first (CI/CD)
42
+ env_val = os.environ.get(ENV_VAR)
43
+ if env_val:
44
+ try:
45
+ data = json.loads(env_val)
46
+ if isinstance(data, dict) and "user_cookie" in data:
47
+ return data
48
+ except json.JSONDecodeError:
49
+ pass
50
+
51
+ # 2. Try auth file
52
+ if AUTH_FILE.exists():
53
+ try:
54
+ data = json.loads(AUTH_FILE.read_text())
55
+ if isinstance(data, dict) and "user_cookie" in data:
56
+ return data
57
+ except (json.JSONDecodeError, OSError):
58
+ pass
59
+
60
+ raise AuthError("Not logged in. Run: cli-web-hackernews auth login")
61
+
62
+
63
+ def get_user_cookie() -> str:
64
+ """Get the HN user cookie value."""
65
+ return load_auth()["user_cookie"]
66
+
67
+
68
+ def get_username() -> str:
69
+ """Get the logged-in username."""
70
+ return load_auth()["username"]
71
+
72
+
73
+ def is_logged_in() -> bool:
74
+ """Check if auth credentials exist."""
75
+ try:
76
+ load_auth()
77
+ return True
78
+ except AuthError:
79
+ return False
80
+
81
+
82
+ def logout() -> None:
83
+ """Remove auth credentials."""
84
+ if AUTH_FILE.exists():
85
+ AUTH_FILE.unlink()
86
+
87
+
88
+ def login_with_password(username: str, password: str) -> dict[str, str]:
89
+ """Login to HN with username/password and return auth data.
90
+
91
+ HN login is a POST to /login with acct=username&pw=password.
92
+ On success, it sets a 'user' cookie and redirects.
93
+ """
94
+ with httpx.Client(
95
+ headers={
96
+ "User-Agent": (
97
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
98
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
99
+ "Chrome/122.0.0.0 Safari/537.36"
100
+ ),
101
+ "Content-Type": "application/x-www-form-urlencoded",
102
+ },
103
+ follow_redirects=False,
104
+ timeout=30.0,
105
+ ) as client:
106
+ response = client.post(
107
+ f"{HN_BASE}/login",
108
+ data={"acct": username, "pw": password, "goto": "news"},
109
+ )
110
+
111
+ # HN returns 302 on success, 200 with error on failure
112
+ user_cookie = None
113
+ for cookie_header in response.headers.get_list("set-cookie"):
114
+ if cookie_header.startswith("user="):
115
+ user_cookie = cookie_header.split("user=")[1].split(";")[0]
116
+ break
117
+
118
+ if not user_cookie:
119
+ # Check if response body has error message
120
+ if response.status_code == 200:
121
+ raise AuthError("Login failed: bad username or password", recoverable=False)
122
+ raise AuthError(f"Login failed: HTTP {response.status_code}", recoverable=False)
123
+
124
+ auth_data = {"user_cookie": user_cookie, "username": username}
125
+ save_auth(user_cookie, username)
126
+ return auth_data
127
+
128
+
129
+ def login_browser() -> dict[str, str]:
130
+ """Login to HN via browser (for users who prefer not to enter password in CLI).
131
+
132
+ Uses Python sync_playwright with persistent context.
133
+ """
134
+ import asyncio
135
+ import sys
136
+
137
+ if sys.platform == "win32":
138
+ asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
139
+
140
+ try:
141
+ from playwright.sync_api import sync_playwright
142
+ except ImportError as exc:
143
+ raise AuthError(
144
+ "Browser login requires playwright. Install: pip install playwright && playwright install chromium",
145
+ recoverable=False,
146
+ ) from exc
147
+
148
+ user_data_dir = str(_get_config_dir() / "browser-profile")
149
+
150
+ with sync_playwright() as p:
151
+ context = p.chromium.launch_persistent_context(
152
+ user_data_dir=user_data_dir,
153
+ headless=False,
154
+ args=[
155
+ "--disable-blink-features=AutomationControlled",
156
+ "--no-first-run",
157
+ "--no-default-browser-check",
158
+ ],
159
+ )
160
+ page = context.pages[0] if context.pages else context.new_page()
161
+ page.goto(f"{HN_BASE}/login")
162
+
163
+ print("Please log in to Hacker News in the browser window.")
164
+ print("The window will close automatically after login.")
165
+
166
+ # Wait for the user cookie to appear (max 5 minutes)
167
+ try:
168
+ page.wait_for_url(
169
+ lambda url: "login" not in url,
170
+ timeout=300_000,
171
+ )
172
+ except Exception as exc:
173
+ context.close()
174
+ raise AuthError("Login timed out after 5 minutes", recoverable=False) from exc
175
+
176
+ # Extract cookies
177
+ cookies = context.cookies("https://news.ycombinator.com")
178
+ user_cookie = None
179
+ username = None
180
+ for cookie in cookies:
181
+ if cookie["name"] == "user":
182
+ user_cookie = cookie["value"]
183
+ # Username is the part before &
184
+ username = user_cookie.split("&")[0] if "&" in user_cookie else None
185
+ break
186
+
187
+ context.close()
188
+
189
+ if not user_cookie or not username:
190
+ raise AuthError("Could not extract login cookie from browser", recoverable=False)
191
+
192
+ auth_data = {"user_cookie": user_cookie, "username": username}
193
+ save_auth(user_cookie, username)
194
+ return auth_data
195
+
196
+
197
+ def refresh_auth() -> dict[str, str]:
198
+ """Headlessly re-extract the auth cookie via the persistent browser profile.
199
+
200
+ Launches a headless browser with the profile saved by login_browser(),
201
+ navigates to HN (which re-sends the session cookie), and saves it.
202
+
203
+ Raises:
204
+ AuthError: If the profile is missing or the session is gone —
205
+ the user must run `cli-web-hackernews auth login`.
206
+ """
207
+ import asyncio
208
+ import sys
209
+
210
+ if sys.platform == "win32":
211
+ asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
212
+
213
+ try:
214
+ from playwright.sync_api import sync_playwright
215
+ except ImportError as exc:
216
+ raise AuthError(
217
+ "Headless refresh requires playwright. Run: cli-web-hackernews auth login",
218
+ recoverable=False,
219
+ ) from exc
220
+
221
+ profile_dir = _get_config_dir() / "browser-profile"
222
+ if not profile_dir.exists():
223
+ raise AuthError(
224
+ "Session expired and no browser profile found. Run: cli-web-hackernews auth login",
225
+ recoverable=False,
226
+ )
227
+
228
+ with sync_playwright() as p:
229
+ context = p.chromium.launch_persistent_context(
230
+ user_data_dir=str(profile_dir),
231
+ headless=True,
232
+ args=[
233
+ "--disable-blink-features=AutomationControlled",
234
+ "--no-first-run",
235
+ "--no-default-browser-check",
236
+ ],
237
+ )
238
+ try:
239
+ page = context.pages[0] if context.pages else context.new_page()
240
+ page.goto(HN_BASE, wait_until="domcontentloaded")
241
+ page.wait_for_timeout(2000)
242
+ cookies = context.cookies("https://news.ycombinator.com")
243
+ finally:
244
+ context.close()
245
+
246
+ user_cookie = None
247
+ username = None
248
+ for cookie in cookies:
249
+ if cookie["name"] == "user":
250
+ user_cookie = cookie["value"]
251
+ username = user_cookie.split("&")[0] if "&" in user_cookie else None
252
+ break
253
+
254
+ if not user_cookie or not username:
255
+ raise AuthError("Session expired. Run: cli-web-hackernews auth login", recoverable=False)
256
+
257
+ save_auth(user_cookie, username)
258
+ return {"user_cookie": user_cookie, "username": username}
259
+
260
+
261
+ def validate_auth() -> dict[str, Any]:
262
+ """Validate that the stored auth is still valid by checking the HN profile page."""
263
+ auth = load_auth()
264
+ cookie = auth["user_cookie"]
265
+ username = auth["username"]
266
+
267
+ with httpx.Client(
268
+ headers={
269
+ "User-Agent": (
270
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
271
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
272
+ "Chrome/122.0.0.0 Safari/537.36"
273
+ ),
274
+ },
275
+ cookies={"user": cookie},
276
+ follow_redirects=True,
277
+ timeout=15.0,
278
+ ) as client:
279
+ response = client.get(f"{HN_BASE}/user?id={username}")
280
+
281
+ if response.status_code != 200:
282
+ raise AuthError("Auth validation failed — cookie may be expired", recoverable=False)
283
+
284
+ # Check if we're actually logged in (page shows logout link)
285
+ if 'id="logout"' not in response.text and "logout" not in response.text:
286
+ raise AuthError(
287
+ "Auth cookie expired. Run: cli-web-hackernews auth login", recoverable=False
288
+ )
289
+
290
+ return {"username": username, "valid": True}