waidrop-cli 1.0.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.
- waidrop_cli/__init__.py +3 -0
- waidrop_cli/__main__.py +4 -0
- waidrop_cli/archive.py +103 -0
- waidrop_cli/auth.py +321 -0
- waidrop_cli/auth_flow.py +182 -0
- waidrop_cli/chats.py +169 -0
- waidrop_cli/cli.py +733 -0
- waidrop_cli/config.py +359 -0
- waidrop_cli/context.py +352 -0
- waidrop_cli/crypto.py +126 -0
- waidrop_cli/dropbox.py +217 -0
- waidrop_cli/env_file.py +132 -0
- waidrop_cli/errors.py +21 -0
- waidrop_cli/fetch.py +214 -0
- waidrop_cli/mcp_cli.py +293 -0
- waidrop_cli/mcp_config.py +191 -0
- waidrop_cli/mcp_server.py +367 -0
- waidrop_cli/media.py +179 -0
- waidrop_cli/output.py +160 -0
- waidrop_cli-1.0.0.dist-info/METADATA +477 -0
- waidrop_cli-1.0.0.dist-info/RECORD +25 -0
- waidrop_cli-1.0.0.dist-info/WHEEL +4 -0
- waidrop_cli-1.0.0.dist-info/entry_points.txt +3 -0
- waidrop_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- waidrop_cli-1.0.0.dist-info/licenses/NOTICE +6 -0
waidrop_cli/__init__.py
ADDED
waidrop_cli/__main__.py
ADDED
waidrop_cli/archive.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Archive format detection and encrypted object access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .crypto import (
|
|
9
|
+
chat_hash,
|
|
10
|
+
decrypt_media,
|
|
11
|
+
decrypt_text,
|
|
12
|
+
derive_keys,
|
|
13
|
+
derive_media_key,
|
|
14
|
+
media_hash,
|
|
15
|
+
)
|
|
16
|
+
from .errors import RemoteError, UsageError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .config import DropboxConfig
|
|
20
|
+
|
|
21
|
+
ENCRYPTED = "encrypted"
|
|
22
|
+
SUPPORTED_FORMAT = 4
|
|
23
|
+
FORMAT_OBJECT = "_format.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArchiveCodec:
|
|
27
|
+
"""Read encrypted archive objects."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, archive_key: str = "", version: int = SUPPORTED_FORMAT):
|
|
30
|
+
self.version = version
|
|
31
|
+
if not archive_key:
|
|
32
|
+
raise UsageError(
|
|
33
|
+
"archive is encrypted; encryption key required "
|
|
34
|
+
"(--key, WAIDROP_KEY, or `waidrop config`)"
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
self._hash_key, self._enc_key = derive_keys(archive_key)
|
|
38
|
+
self._media_key = derive_media_key(archive_key)
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise UsageError(str(exc)) from exc
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def part_suffix(self) -> str:
|
|
44
|
+
return ".enc"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def chat_suffix(self) -> str:
|
|
48
|
+
return ".enc"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def media_suffix(self) -> str:
|
|
52
|
+
return ".enc"
|
|
53
|
+
|
|
54
|
+
def decode_text(self, payload: bytes | bytearray) -> str:
|
|
55
|
+
return decrypt_text(self._enc_key, bytes(payload))
|
|
56
|
+
|
|
57
|
+
def decode_media(self, relative_path: str, payload: bytes | bytearray) -> bytes:
|
|
58
|
+
return decrypt_media(self._media_key, relative_path, bytes(payload))
|
|
59
|
+
|
|
60
|
+
def chat_hash(self, chat_id: str) -> str:
|
|
61
|
+
return chat_hash(self._hash_key, chat_id)
|
|
62
|
+
|
|
63
|
+
def media_hash(self, msg_id: str) -> str:
|
|
64
|
+
return media_hash(self._hash_key, msg_id)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_format_marker(payload: bytes) -> tuple[int, str]:
|
|
68
|
+
try:
|
|
69
|
+
document = json.loads(payload.decode("utf-8"))
|
|
70
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
71
|
+
raise RemoteError(f"archive format marker is invalid: {exc}") from exc
|
|
72
|
+
if not isinstance(document, dict):
|
|
73
|
+
raise RemoteError("archive format marker is invalid: expected a JSON object")
|
|
74
|
+
layout = document.get("layout")
|
|
75
|
+
if layout != "waidrop":
|
|
76
|
+
raise RemoteError(f"unsupported archive layout: {layout!r}")
|
|
77
|
+
|
|
78
|
+
version = document.get("format")
|
|
79
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
|
80
|
+
raise RemoteError("archive format marker is invalid: missing format version")
|
|
81
|
+
if version != SUPPORTED_FORMAT:
|
|
82
|
+
raise RemoteError(f"unsupported archive format {version}; update waidrop-cli")
|
|
83
|
+
|
|
84
|
+
mode = document.get("mode")
|
|
85
|
+
if mode == "plaintext":
|
|
86
|
+
raise RemoteError(
|
|
87
|
+
"plaintext archives are no longer supported; re-export with encryption enabled"
|
|
88
|
+
)
|
|
89
|
+
if mode != ENCRYPTED:
|
|
90
|
+
raise RemoteError(f"unsupported archive mode: {mode!r}")
|
|
91
|
+
return version, mode
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_format(client, cfg: DropboxConfig) -> tuple[int, str]:
|
|
95
|
+
payload = client.download(f"{cfg.account_root}/{FORMAT_OBJECT}")
|
|
96
|
+
if payload is None:
|
|
97
|
+
raise RemoteError("archive format marker is missing")
|
|
98
|
+
return parse_format_marker(payload)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_archive(client, cfg: DropboxConfig) -> ArchiveCodec:
|
|
102
|
+
version, _mode = read_format(client, cfg)
|
|
103
|
+
return ArchiveCodec(archive_key=cfg.archive_key, version=version)
|
waidrop_cli/auth.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Dropbox OAuth (PKCE, offline access) and local credential storage.
|
|
2
|
+
|
|
3
|
+
The CLI is a public client: it ships only the app key (client id), never a
|
|
4
|
+
client secret. Each user authorizes once; we store a long-lived refresh token
|
|
5
|
+
and mint short-lived access tokens on demand. The archive lives in the Dropbox
|
|
6
|
+
App Folder, which is namespaced per app, so the CLI must use the same app key
|
|
7
|
+
as the extension to see the same folder.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import stat
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .errors import RemoteError, UsageError
|
|
27
|
+
|
|
28
|
+
AUTHORIZE_URL = "https://www.dropbox.com/oauth2/authorize"
|
|
29
|
+
DEFAULT_TOKEN_URL = "https://api.dropboxapi.com/oauth2/token"
|
|
30
|
+
DEFAULT_REVOKE_URL = "https://api.dropboxapi.com/2/auth/token/revoke"
|
|
31
|
+
DEFAULT_API_BASE = "https://api.dropboxapi.com/2"
|
|
32
|
+
DEFAULT_CONTENT_BASE = "https://content.dropboxapi.com/2"
|
|
33
|
+
|
|
34
|
+
# Read-only scopes for the CLI. The extension additionally requests
|
|
35
|
+
# files.content.write so it can upload the archive.
|
|
36
|
+
CLI_SCOPES = ("account_info.read", "files.metadata.read", "files.content.read")
|
|
37
|
+
REQUIRED_SCOPES = ("files.metadata.read", "files.content.read")
|
|
38
|
+
|
|
39
|
+
# Set at release; must match the extension's DEFAULT_DROPBOX_APP_KEY. The app
|
|
40
|
+
# key is a public client id and is safe to ship. Never ship a client secret.
|
|
41
|
+
DEFAULT_APP_KEY = "ydtsl7okrf4xuee"
|
|
42
|
+
|
|
43
|
+
EXPIRY_SKEW_SECONDS = 60
|
|
44
|
+
CREDENTIALS_VERSION = 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _b64url(data: bytes) -> str:
|
|
48
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def generate_pkce() -> tuple[str, str]:
|
|
52
|
+
"""Return (code_verifier, code_challenge) using S256."""
|
|
53
|
+
verifier = secrets.token_urlsafe(48)
|
|
54
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
55
|
+
return verifier, _b64url(digest)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_authorize_url(
|
|
59
|
+
app_key: str,
|
|
60
|
+
*,
|
|
61
|
+
code_challenge: str,
|
|
62
|
+
redirect_uri: str | None = None,
|
|
63
|
+
state: str | None = None,
|
|
64
|
+
scopes: tuple[str, ...] = CLI_SCOPES,
|
|
65
|
+
) -> str:
|
|
66
|
+
params = {
|
|
67
|
+
"client_id": app_key,
|
|
68
|
+
"response_type": "code",
|
|
69
|
+
"token_access_type": "offline",
|
|
70
|
+
"code_challenge": code_challenge,
|
|
71
|
+
"code_challenge_method": "S256",
|
|
72
|
+
"scope": " ".join(scopes),
|
|
73
|
+
}
|
|
74
|
+
if redirect_uri:
|
|
75
|
+
params["redirect_uri"] = redirect_uri
|
|
76
|
+
if state:
|
|
77
|
+
params["state"] = state
|
|
78
|
+
return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_error_body(exc: urllib.error.HTTPError) -> str:
|
|
82
|
+
cached = getattr(exc, "_cached_body", None)
|
|
83
|
+
if cached is not None:
|
|
84
|
+
return cached
|
|
85
|
+
try:
|
|
86
|
+
detail = exc.read().decode("utf-8", errors="replace")[:300]
|
|
87
|
+
except Exception:
|
|
88
|
+
detail = ""
|
|
89
|
+
exc._cached_body = detail
|
|
90
|
+
return detail
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _token_error(detail: str, context: str) -> RemoteError:
|
|
94
|
+
try:
|
|
95
|
+
document = json.loads(detail)
|
|
96
|
+
except (json.JSONDecodeError, TypeError):
|
|
97
|
+
return RemoteError(f"Dropbox token request failed: {detail or 'unknown error'}")
|
|
98
|
+
code = document.get("error")
|
|
99
|
+
description = document.get("error_description") or ""
|
|
100
|
+
if code == "invalid_grant":
|
|
101
|
+
if context == "refresh token":
|
|
102
|
+
return RemoteError(
|
|
103
|
+
"Dropbox refresh token was revoked or expired; run `waidrop auth` again"
|
|
104
|
+
)
|
|
105
|
+
return RemoteError(
|
|
106
|
+
"the authorization code expired or was already used; run `waidrop auth` again"
|
|
107
|
+
)
|
|
108
|
+
if code in ("invalid_scope", "missing_scope"):
|
|
109
|
+
return RemoteError(
|
|
110
|
+
f"Dropbox scope error ({code}): {description or 'enable the required scopes in the App Console'}"
|
|
111
|
+
)
|
|
112
|
+
if code == "unauthorized_client":
|
|
113
|
+
return RemoteError(
|
|
114
|
+
"Dropbox rejected the app key; check WAIDROP_DROPBOX_APP_KEY / --dropbox-app-key"
|
|
115
|
+
)
|
|
116
|
+
message = f"Dropbox token request failed: {code or 'unknown'}"
|
|
117
|
+
if description:
|
|
118
|
+
message += f" ({description})"
|
|
119
|
+
return RemoteError(message)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _token_request(token_url: str, payload: dict, timeout: float, context: str) -> dict:
|
|
123
|
+
request = urllib.request.Request(
|
|
124
|
+
token_url,
|
|
125
|
+
data=urllib.parse.urlencode(payload).encode("utf-8"),
|
|
126
|
+
method="POST",
|
|
127
|
+
headers={"content-type": "application/x-www-form-urlencoded"},
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
131
|
+
return json.loads(response.read().decode("utf-8"))
|
|
132
|
+
except urllib.error.HTTPError as exc:
|
|
133
|
+
raise _token_error(_read_error_body(exc), context) from exc
|
|
134
|
+
except urllib.error.URLError as exc:
|
|
135
|
+
raise RemoteError(f"cannot reach Dropbox: {exc.reason}") from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def exchange_code(
|
|
139
|
+
*,
|
|
140
|
+
app_key: str,
|
|
141
|
+
code: str,
|
|
142
|
+
code_verifier: str,
|
|
143
|
+
token_url: str = DEFAULT_TOKEN_URL,
|
|
144
|
+
redirect_uri: str | None = None,
|
|
145
|
+
client_secret: str | None = None,
|
|
146
|
+
timeout: float = 60.0,
|
|
147
|
+
) -> dict:
|
|
148
|
+
payload = {
|
|
149
|
+
"grant_type": "authorization_code",
|
|
150
|
+
"code": code,
|
|
151
|
+
"client_id": app_key,
|
|
152
|
+
"code_verifier": code_verifier,
|
|
153
|
+
}
|
|
154
|
+
if redirect_uri:
|
|
155
|
+
payload["redirect_uri"] = redirect_uri
|
|
156
|
+
if client_secret:
|
|
157
|
+
payload["client_secret"] = client_secret
|
|
158
|
+
return _token_request(token_url, payload, timeout, context="authorization code")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def refresh_access_token(
|
|
162
|
+
*,
|
|
163
|
+
app_key: str,
|
|
164
|
+
refresh_token: str,
|
|
165
|
+
token_url: str = DEFAULT_TOKEN_URL,
|
|
166
|
+
client_secret: str | None = None,
|
|
167
|
+
timeout: float = 60.0,
|
|
168
|
+
) -> dict:
|
|
169
|
+
payload = {
|
|
170
|
+
"grant_type": "refresh_token",
|
|
171
|
+
"refresh_token": refresh_token,
|
|
172
|
+
"client_id": app_key,
|
|
173
|
+
}
|
|
174
|
+
if client_secret:
|
|
175
|
+
payload["client_secret"] = client_secret
|
|
176
|
+
return _token_request(token_url, payload, timeout, context="refresh token")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _split_scope(value) -> list[str]:
|
|
180
|
+
if not value:
|
|
181
|
+
return []
|
|
182
|
+
if isinstance(value, (list, tuple)):
|
|
183
|
+
return [str(item) for item in value]
|
|
184
|
+
return str(value).split()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_token_document(document: dict) -> dict:
|
|
188
|
+
access_token = document.get("access_token")
|
|
189
|
+
if not access_token:
|
|
190
|
+
raise RemoteError("Dropbox token response contained no access token")
|
|
191
|
+
expires_in = document.get("expires_in")
|
|
192
|
+
return {
|
|
193
|
+
"access_token": access_token,
|
|
194
|
+
"refresh_token": document.get("refresh_token"),
|
|
195
|
+
"expires_at": time.time() + float(expires_in) if expires_in else None,
|
|
196
|
+
"scope": _split_scope(document.get("scope")),
|
|
197
|
+
"account_id": document.get("account_id"),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def missing_scopes(granted, required: tuple[str, ...] = REQUIRED_SCOPES) -> list[str]:
|
|
202
|
+
granted_set = set(granted or [])
|
|
203
|
+
return [scope for scope in required if scope not in granted_set]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class Credentials:
|
|
208
|
+
app_key: str
|
|
209
|
+
refresh_token: str
|
|
210
|
+
access_token: str | None = None
|
|
211
|
+
expires_at: float | None = None
|
|
212
|
+
scope: list[str] = field(default_factory=list)
|
|
213
|
+
account_id: str | None = None
|
|
214
|
+
account_name: str | None = None
|
|
215
|
+
email: str | None = None
|
|
216
|
+
obtained_at: str | None = None
|
|
217
|
+
|
|
218
|
+
def to_dict(self) -> dict:
|
|
219
|
+
return {
|
|
220
|
+
"version": CREDENTIALS_VERSION,
|
|
221
|
+
"app_key": self.app_key,
|
|
222
|
+
"refresh_token": self.refresh_token,
|
|
223
|
+
"access_token": self.access_token,
|
|
224
|
+
"expires_at": self.expires_at,
|
|
225
|
+
"scope": list(self.scope),
|
|
226
|
+
"account_id": self.account_id,
|
|
227
|
+
"account_name": self.account_name,
|
|
228
|
+
"email": self.email,
|
|
229
|
+
"obtained_at": self.obtained_at,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def from_dict(cls, data: dict) -> Credentials:
|
|
234
|
+
return cls(
|
|
235
|
+
app_key=str(data["app_key"]),
|
|
236
|
+
refresh_token=str(data.get("refresh_token") or ""),
|
|
237
|
+
access_token=data.get("access_token"),
|
|
238
|
+
expires_at=float(data["expires_at"]) if data.get("expires_at") is not None else None,
|
|
239
|
+
scope=_split_scope(data.get("scope")),
|
|
240
|
+
account_id=data.get("account_id"),
|
|
241
|
+
account_name=data.get("account_name"),
|
|
242
|
+
email=data.get("email"),
|
|
243
|
+
obtained_at=data.get("obtained_at"),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def default_credentials_path() -> Path:
|
|
248
|
+
if os.name == "nt":
|
|
249
|
+
base = Path(os.environ.get("APPDATA") or Path.home())
|
|
250
|
+
return base / "waidrop" / "credentials.json"
|
|
251
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
252
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
253
|
+
return base / "waidrop" / "credentials.json"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def resolve_credentials_path(args) -> Path:
|
|
257
|
+
explicit = getattr(args, "credentials_file", None) or os.environ.get("WAIDROP_CREDENTIALS")
|
|
258
|
+
if explicit:
|
|
259
|
+
return Path(str(explicit)).expanduser()
|
|
260
|
+
return default_credentials_path()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def load_credentials(path) -> Credentials | None:
|
|
264
|
+
if os.name != "nt":
|
|
265
|
+
try:
|
|
266
|
+
mode = stat.S_IMODE(os.stat(path).st_mode)
|
|
267
|
+
except FileNotFoundError:
|
|
268
|
+
return None
|
|
269
|
+
except OSError:
|
|
270
|
+
return None
|
|
271
|
+
if mode & 0o077:
|
|
272
|
+
raise UsageError(
|
|
273
|
+
f"credentials file {path} must not be readable or writable by "
|
|
274
|
+
f"group/others; run `chmod 600 {path}`"
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
with open(path, encoding="utf-8") as handle:
|
|
278
|
+
data = json.load(handle)
|
|
279
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
280
|
+
return None
|
|
281
|
+
if not isinstance(data, dict) or data.get("version") != CREDENTIALS_VERSION:
|
|
282
|
+
return None
|
|
283
|
+
try:
|
|
284
|
+
return Credentials.from_dict(data)
|
|
285
|
+
except (KeyError, TypeError, ValueError):
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def save_credentials(path, creds: Credentials) -> None:
|
|
290
|
+
path = Path(path)
|
|
291
|
+
parent = path.parent
|
|
292
|
+
existed = parent.exists()
|
|
293
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
if not existed or parent == default_credentials_path().parent:
|
|
295
|
+
try:
|
|
296
|
+
os.chmod(parent, 0o700)
|
|
297
|
+
except OSError:
|
|
298
|
+
pass
|
|
299
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".credentials-", suffix=".tmp")
|
|
300
|
+
try:
|
|
301
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
302
|
+
json.dump(creds.to_dict(), handle, indent=2)
|
|
303
|
+
handle.write("\n")
|
|
304
|
+
os.chmod(tmp, 0o600)
|
|
305
|
+
os.replace(tmp, path)
|
|
306
|
+
except BaseException:
|
|
307
|
+
try:
|
|
308
|
+
os.unlink(tmp)
|
|
309
|
+
except OSError:
|
|
310
|
+
pass
|
|
311
|
+
raise
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def delete_credentials(path) -> bool:
|
|
315
|
+
try:
|
|
316
|
+
Path(path).unlink()
|
|
317
|
+
return True
|
|
318
|
+
except FileNotFoundError:
|
|
319
|
+
return False
|
|
320
|
+
except OSError:
|
|
321
|
+
return False
|
waidrop_cli/auth_flow.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Interactive Dropbox authorization flows: paste-code and loopback.
|
|
2
|
+
|
|
3
|
+
The paste flow is the default: it needs no redirect URI registration and works
|
|
4
|
+
over SSH and on headless hosts (the user opens the URL anywhere and pastes the
|
|
5
|
+
code back). The loopback flow is optional and gives a smoother desktop
|
|
6
|
+
experience; its redirect URI must be registered in the App Console.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hmac
|
|
12
|
+
import secrets
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import webbrowser
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
17
|
+
from urllib.parse import parse_qs, urlparse
|
|
18
|
+
|
|
19
|
+
from .auth import (
|
|
20
|
+
CLI_SCOPES,
|
|
21
|
+
build_authorize_url,
|
|
22
|
+
exchange_code,
|
|
23
|
+
generate_pkce,
|
|
24
|
+
parse_token_document,
|
|
25
|
+
)
|
|
26
|
+
from .errors import RemoteError, UsageError
|
|
27
|
+
|
|
28
|
+
LOOPBACK_PORT = 53682
|
|
29
|
+
LOOPBACK_PATH = "/callback"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _open_browser(url: str, open_browser: bool) -> None:
|
|
33
|
+
if not open_browser:
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
webbrowser.open(url)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_paste_flow(
|
|
42
|
+
*,
|
|
43
|
+
app_key: str,
|
|
44
|
+
token_url: str,
|
|
45
|
+
client_secret: str | None = None,
|
|
46
|
+
open_browser: bool = True,
|
|
47
|
+
code: str | None = None,
|
|
48
|
+
input_fn=input,
|
|
49
|
+
print_fn=print,
|
|
50
|
+
timeout: float = 60.0,
|
|
51
|
+
) -> dict:
|
|
52
|
+
verifier, challenge = generate_pkce()
|
|
53
|
+
url = build_authorize_url(app_key, code_challenge=challenge, scopes=CLI_SCOPES)
|
|
54
|
+
|
|
55
|
+
print_fn("Connect WAiDrop to Dropbox:")
|
|
56
|
+
print_fn(" 1. Open this URL in a browser:")
|
|
57
|
+
print_fn(f" {url}")
|
|
58
|
+
print_fn(' 2. Click "Allow", then copy the authorization code Dropbox shows.')
|
|
59
|
+
|
|
60
|
+
if code is None and not sys.stdin.isatty():
|
|
61
|
+
raise UsageError(
|
|
62
|
+
"cannot prompt for the authorization code in a non-interactive session; "
|
|
63
|
+
"pass --code <CODE> or use --loopback"
|
|
64
|
+
)
|
|
65
|
+
_open_browser(url, open_browser)
|
|
66
|
+
|
|
67
|
+
if code is None:
|
|
68
|
+
code = input_fn(" Paste the authorization code: ").strip()
|
|
69
|
+
if not code:
|
|
70
|
+
raise UsageError("no authorization code provided")
|
|
71
|
+
|
|
72
|
+
document = exchange_code(
|
|
73
|
+
app_key=app_key,
|
|
74
|
+
code=code,
|
|
75
|
+
code_verifier=verifier,
|
|
76
|
+
token_url=token_url,
|
|
77
|
+
client_secret=client_secret,
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
)
|
|
80
|
+
return parse_token_document(document)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _result_page(ok: bool) -> str:
|
|
84
|
+
if ok:
|
|
85
|
+
body = "WAiDrop connected to Dropbox. You can close this tab and return to the terminal."
|
|
86
|
+
else:
|
|
87
|
+
body = "WAiDrop could not complete the connection. Check the terminal for details."
|
|
88
|
+
return f"<!doctype html><html><head><meta charset=utf-8></head><body><h1>WAiDrop</h1><p>{body}</p></body></html>"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_loopback_flow(
|
|
92
|
+
*,
|
|
93
|
+
app_key: str,
|
|
94
|
+
token_url: str,
|
|
95
|
+
port: int = LOOPBACK_PORT,
|
|
96
|
+
client_secret: str | None = None,
|
|
97
|
+
open_browser: bool = True,
|
|
98
|
+
timeout: float = 300.0,
|
|
99
|
+
print_fn=print,
|
|
100
|
+
) -> dict:
|
|
101
|
+
verifier, challenge = generate_pkce()
|
|
102
|
+
state = secrets.token_urlsafe(24)
|
|
103
|
+
redirect_uri = f"http://127.0.0.1:{port}{LOOPBACK_PATH}"
|
|
104
|
+
url = build_authorize_url(
|
|
105
|
+
app_key, code_challenge=challenge, redirect_uri=redirect_uri, state=state, scopes=CLI_SCOPES
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result: dict = {"code": None, "error": None, "state_ok": False}
|
|
109
|
+
done = threading.Event()
|
|
110
|
+
|
|
111
|
+
class Handler(BaseHTTPRequestHandler):
|
|
112
|
+
def log_message(self, *args): # silence request logging
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
def do_GET(self):
|
|
116
|
+
parsed = urlparse(self.path)
|
|
117
|
+
if parsed.path != LOOPBACK_PATH:
|
|
118
|
+
self.send_response(404)
|
|
119
|
+
self.end_headers()
|
|
120
|
+
return
|
|
121
|
+
query = parse_qs(parsed.query)
|
|
122
|
+
got_state = (query.get("state") or [""])[0]
|
|
123
|
+
state_ok = hmac.compare_digest(got_state, state)
|
|
124
|
+
result["state_ok"] = state_ok
|
|
125
|
+
if not state_ok:
|
|
126
|
+
self.send_response(400)
|
|
127
|
+
self.send_header("content-type", "text/html; charset=utf-8")
|
|
128
|
+
self.end_headers()
|
|
129
|
+
self.wfile.write(_result_page(False).encode("utf-8"))
|
|
130
|
+
return
|
|
131
|
+
if "code" in query:
|
|
132
|
+
result["code"] = query["code"][0]
|
|
133
|
+
elif "error" in query:
|
|
134
|
+
description = (query.get("error_description") or [""])[0]
|
|
135
|
+
result["error"] = f"{query['error'][0]} {description}".strip()
|
|
136
|
+
else:
|
|
137
|
+
result["error"] = "missing authorization code"
|
|
138
|
+
self.send_response(200)
|
|
139
|
+
self.send_header("content-type", "text/html; charset=utf-8")
|
|
140
|
+
self.end_headers()
|
|
141
|
+
self.wfile.write(_result_page(result["code"] is not None).encode("utf-8"))
|
|
142
|
+
done.set()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
raise UsageError(
|
|
148
|
+
f"cannot bind the loopback callback on 127.0.0.1:{port} ({exc}); "
|
|
149
|
+
"pick another --port or use the paste flow"
|
|
150
|
+
) from exc
|
|
151
|
+
|
|
152
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
153
|
+
server_thread.start()
|
|
154
|
+
try:
|
|
155
|
+
print_fn(
|
|
156
|
+
f"Waiting for Dropbox authorization at {redirect_uri} (timeout {int(timeout)}s)..."
|
|
157
|
+
)
|
|
158
|
+
_open_browser(url, open_browser)
|
|
159
|
+
if not done.wait(timeout):
|
|
160
|
+
raise UsageError(
|
|
161
|
+
"timed out waiting for Dropbox authorization; run again or use the paste flow"
|
|
162
|
+
)
|
|
163
|
+
finally:
|
|
164
|
+
server.shutdown()
|
|
165
|
+
server.server_close()
|
|
166
|
+
server_thread.join(timeout=2)
|
|
167
|
+
|
|
168
|
+
if not result["state_ok"]:
|
|
169
|
+
raise RemoteError("authorization state mismatch (possible CSRF); run `waidrop auth` again")
|
|
170
|
+
if result["error"]:
|
|
171
|
+
raise UsageError(f"Dropbox authorization failed: {result['error']}")
|
|
172
|
+
|
|
173
|
+
document = exchange_code(
|
|
174
|
+
app_key=app_key,
|
|
175
|
+
code=result["code"],
|
|
176
|
+
code_verifier=verifier,
|
|
177
|
+
token_url=token_url,
|
|
178
|
+
redirect_uri=redirect_uri,
|
|
179
|
+
client_secret=client_secret,
|
|
180
|
+
timeout=60.0,
|
|
181
|
+
)
|
|
182
|
+
return parse_token_document(document)
|