drp-dev 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.
- cli/__init__.py +30 -0
- cli/__main__.py +3 -0
- cli/api/__init__.py +18 -0
- cli/api/actions.py +205 -0
- cli/api/auth.py +40 -0
- cli/api/file.py +340 -0
- cli/api/helpers.py +28 -0
- cli/api/text.py +137 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_context.py +44 -0
- cli/commands/ask.py +110 -0
- cli/commands/cache.py +88 -0
- cli/commands/collection.py +219 -0
- cli/commands/cp.py +62 -0
- cli/commands/edit.py +93 -0
- cli/commands/get.py +344 -0
- cli/commands/load.py +58 -0
- cli/commands/lock.py +85 -0
- cli/commands/ls.py +203 -0
- cli/commands/manage.py +90 -0
- cli/commands/save.py +32 -0
- cli/commands/send.py +76 -0
- cli/commands/serve.py +103 -0
- cli/commands/setup.py +306 -0
- cli/commands/shell.py +549 -0
- cli/commands/status.py +181 -0
- cli/commands/token.py +123 -0
- cli/commands/upload.py +327 -0
- cli/completion.py +297 -0
- cli/config.py +81 -0
- cli/crash_reporter.py +168 -0
- cli/drp.py +447 -0
- cli/format.py +105 -0
- cli/path_check.py +57 -0
- cli/progress.py +107 -0
- cli/prompt.py +102 -0
- cli/session.py +154 -0
- cli/smart_parse.py +207 -0
- cli/spinner.py +98 -0
- cli/timing.py +97 -0
- cli/version_check.py +150 -0
- drp_dev-1.0.0.dist-info/METADATA +96 -0
- drp_dev-1.0.0.dist-info/RECORD +47 -0
- drp_dev-1.0.0.dist-info/WHEEL +5 -0
- drp_dev-1.0.0.dist-info/entry_points.txt +2 -0
- drp_dev-1.0.0.dist-info/licenses/LICENSE +31 -0
- drp_dev-1.0.0.dist-info/top_level.txt +1 -0
cli/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""drp CLI — command-line tool for drp.
|
|
2
|
+
|
|
3
|
+
Drop, share, and manage text snippets and files from the terminal.
|
|
4
|
+
https://drp.fyi
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# VERSION file holds major.minor (e.g. "1.0") — only bumped manually.
|
|
8
|
+
# CI queries PyPI for latest published version, bumps patch +1,
|
|
9
|
+
# and seds this line to a literal before building: __version__ = '1.0.42'
|
|
10
|
+
# Nothing committed back — merges between dev↔main stay clean.
|
|
11
|
+
# Fallback: importlib.metadata for installed packages.
|
|
12
|
+
|
|
13
|
+
def _resolve_version():
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
vf = Path(__file__).resolve().parent.parent / 'VERSION'
|
|
16
|
+
if vf.is_file():
|
|
17
|
+
return vf.read_text().strip()
|
|
18
|
+
try:
|
|
19
|
+
from importlib.metadata import version
|
|
20
|
+
return version('drp-dev')
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
try:
|
|
24
|
+
from importlib.metadata import version
|
|
25
|
+
return version('drp')
|
|
26
|
+
except Exception:
|
|
27
|
+
return '0.0.0'
|
|
28
|
+
|
|
29
|
+
__version__ = '1.0.0'
|
|
30
|
+
DEFAULT_HOST = 'https://drp.fyi'
|
cli/__main__.py
ADDED
cli/api/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API surface for the drp CLI.
|
|
3
|
+
Import from here to keep command modules clean.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .auth import get_csrf, login
|
|
7
|
+
from .text import upload_text, get_clipboard
|
|
8
|
+
from .file import upload_file, get_file, upload_from_url
|
|
9
|
+
from .actions import delete, rename, renew, list_drops, key_exists, save_bookmark
|
|
10
|
+
from .helpers import slug, err, ok
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'get_csrf', 'login',
|
|
14
|
+
'upload_text', 'get_clipboard',
|
|
15
|
+
'upload_file', 'get_file', 'upload_from_url',
|
|
16
|
+
'delete', 'rename', 'renew', 'list_drops', 'key_exists', 'save_bookmark',
|
|
17
|
+
'slug', 'err', 'ok',
|
|
18
|
+
]
|
cli/api/actions.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drop action API calls: delete, rename, renew, list, key_exists, save_bookmark.
|
|
3
|
+
|
|
4
|
+
URL conventions:
|
|
5
|
+
Clipboard: /key/delete|rename|renew|save/
|
|
6
|
+
File: /f/key/delete|rename|renew|save/
|
|
7
|
+
|
|
8
|
+
Return value conventions for callers (manage.py):
|
|
9
|
+
rename() → new_key string on success
|
|
10
|
+
False on a known/reported error (404, 409, 403 …)
|
|
11
|
+
None on an unexpected error (network, bad JSON, etc.)
|
|
12
|
+
delete() → True / False (unchanged)
|
|
13
|
+
renew() → (expires_at, renewals) / (None, None) (unchanged)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .auth import get_csrf
|
|
17
|
+
from .helpers import err
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _url(host, ns, key, action):
|
|
21
|
+
if ns == 'f':
|
|
22
|
+
return f'{host}/f/{key}/{action}/'
|
|
23
|
+
return f'{host}/{key}/{action}/'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def delete(host, session, key, ns='c'):
|
|
27
|
+
csrf = get_csrf(host, session)
|
|
28
|
+
try:
|
|
29
|
+
res = session.delete(
|
|
30
|
+
_url(host, ns, key, 'delete'),
|
|
31
|
+
headers={'X-CSRFToken': csrf},
|
|
32
|
+
timeout=10,
|
|
33
|
+
)
|
|
34
|
+
if res.ok:
|
|
35
|
+
return True
|
|
36
|
+
if res.status_code == 404:
|
|
37
|
+
err(f'Drop not found. If this is a file drop, use: drp rm -f {key}')
|
|
38
|
+
_report_http('rm', 404, f'delete ns={ns} — likely wrong namespace')
|
|
39
|
+
return False
|
|
40
|
+
_handle_error(res, 'Delete failed')
|
|
41
|
+
_report_http('rm', res.status_code, f'delete ns={ns}')
|
|
42
|
+
except Exception as e:
|
|
43
|
+
err(f'Delete error: {e}')
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rename(host, session, key, new_key, ns='c'):
|
|
48
|
+
"""
|
|
49
|
+
Rename a drop key.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
str — the new key on success
|
|
53
|
+
False — a known error that has already been printed and reported
|
|
54
|
+
(404 wrong-namespace, 409 key taken, 403 locked, 400 bad input)
|
|
55
|
+
None — an unexpected error (network failure, unhandled status code)
|
|
56
|
+
caller should file a SilentFailure report
|
|
57
|
+
"""
|
|
58
|
+
csrf = get_csrf(host, session)
|
|
59
|
+
try:
|
|
60
|
+
res = session.post(
|
|
61
|
+
_url(host, ns, key, 'rename'),
|
|
62
|
+
data={'new_key': new_key, 'csrfmiddlewaretoken': csrf},
|
|
63
|
+
timeout=10,
|
|
64
|
+
)
|
|
65
|
+
if res.ok:
|
|
66
|
+
return res.json().get('key')
|
|
67
|
+
|
|
68
|
+
# ── Known errors — print a helpful message and report, then return
|
|
69
|
+
# False so the caller knows not to file a redundant SilentFailure. ──
|
|
70
|
+
if res.status_code == 404:
|
|
71
|
+
ns_flag = '-f ' if ns == 'f' else ''
|
|
72
|
+
other_flag = '' if ns == 'f' else '-f '
|
|
73
|
+
err(
|
|
74
|
+
f'Drop /{ns_flag}{key}/ not found. '
|
|
75
|
+
f'If this is a {"file" if ns == "c" else "clipboard"} drop, '
|
|
76
|
+
f'use: drp mv {other_flag}{key} {new_key}'
|
|
77
|
+
)
|
|
78
|
+
_report_http('mv', 404, f'rename ns={ns} — likely wrong namespace')
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
if res.status_code == 409:
|
|
82
|
+
err(f'Key "{new_key}" is already taken.')
|
|
83
|
+
_report_http('mv', 409, f'rename ns={ns} key conflict')
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
if res.status_code == 403:
|
|
87
|
+
try:
|
|
88
|
+
msg = res.json().get('error', 'Permission denied.')
|
|
89
|
+
except Exception:
|
|
90
|
+
msg = 'Permission denied.'
|
|
91
|
+
err(f'Rename blocked: {msg}')
|
|
92
|
+
_report_http('mv', 403, f'rename ns={ns}')
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
if res.status_code == 400:
|
|
96
|
+
_handle_error(res, 'Rename failed')
|
|
97
|
+
_report_http('mv', 400, f'rename ns={ns}')
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
# Unexpected status — let caller decide whether to report
|
|
101
|
+
_handle_error(res, 'Rename failed')
|
|
102
|
+
_report_http('mv', res.status_code, f'rename ns={ns}')
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
err(f'Rename error: {e}')
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def renew(host, session, key, ns='c'):
|
|
111
|
+
csrf = get_csrf(host, session)
|
|
112
|
+
try:
|
|
113
|
+
res = session.post(
|
|
114
|
+
_url(host, ns, key, 'renew'),
|
|
115
|
+
data={'csrfmiddlewaretoken': csrf},
|
|
116
|
+
timeout=10,
|
|
117
|
+
)
|
|
118
|
+
if res.ok:
|
|
119
|
+
data = res.json()
|
|
120
|
+
return data.get('expires_at'), data.get('renewals')
|
|
121
|
+
_handle_error(res, 'Renew failed')
|
|
122
|
+
_report_http('renew', res.status_code, f'renew ns={ns}')
|
|
123
|
+
except Exception as e:
|
|
124
|
+
err(f'Renew error: {e}')
|
|
125
|
+
return None, None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def save_bookmark(host, session, key, ns='c'):
|
|
129
|
+
"""
|
|
130
|
+
Bookmark a drop. Returns True if saved, False on failure.
|
|
131
|
+
Requires login — server returns 403 if not authenticated.
|
|
132
|
+
"""
|
|
133
|
+
csrf = get_csrf(host, session)
|
|
134
|
+
try:
|
|
135
|
+
res = session.post(
|
|
136
|
+
_url(host, ns, key, 'save'),
|
|
137
|
+
data={'csrfmiddlewaretoken': csrf},
|
|
138
|
+
timeout=10,
|
|
139
|
+
allow_redirects=False,
|
|
140
|
+
)
|
|
141
|
+
if res.status_code in (301, 302, 303):
|
|
142
|
+
err('drp save requires a logged-in account. Run: drp login')
|
|
143
|
+
return False
|
|
144
|
+
if res.ok:
|
|
145
|
+
return True
|
|
146
|
+
if res.status_code == 403:
|
|
147
|
+
err('drp save requires a logged-in account. Run: drp login')
|
|
148
|
+
return False
|
|
149
|
+
if res.status_code == 404:
|
|
150
|
+
err(f'Drop /{key}/ not found.')
|
|
151
|
+
return False
|
|
152
|
+
_handle_error(res, 'Save failed')
|
|
153
|
+
_report_http('save', res.status_code, f'save_bookmark ns={ns}')
|
|
154
|
+
except Exception as e:
|
|
155
|
+
err(f'Save error: {e}')
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def list_drops(host, session):
|
|
160
|
+
try:
|
|
161
|
+
res = session.get(
|
|
162
|
+
f'{host}/auth/account/',
|
|
163
|
+
headers={'Accept': 'application/json'},
|
|
164
|
+
timeout=15,
|
|
165
|
+
)
|
|
166
|
+
if res.ok:
|
|
167
|
+
return res.json().get('drops', [])
|
|
168
|
+
if res.status_code in (302, 403):
|
|
169
|
+
return None
|
|
170
|
+
err(f'Server returned {res.status_code}.')
|
|
171
|
+
_report_http('ls', res.status_code, 'list_drops')
|
|
172
|
+
except Exception as e:
|
|
173
|
+
err(f'List error: {e}')
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def key_exists(host, session, key, ns='c'):
|
|
178
|
+
try:
|
|
179
|
+
res = session.get(
|
|
180
|
+
f'{host}/check-key/',
|
|
181
|
+
params={'key': key, 'ns': ns},
|
|
182
|
+
timeout=10,
|
|
183
|
+
)
|
|
184
|
+
if res.ok:
|
|
185
|
+
return not res.json().get('available', True)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _handle_error(res, prefix):
|
|
192
|
+
try:
|
|
193
|
+
msg = res.json().get('error', res.text[:200])
|
|
194
|
+
except Exception:
|
|
195
|
+
msg = res.text[:200]
|
|
196
|
+
err(f'{prefix}: {msg}')
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _report_http(command: str, status_code: int, context: str) -> None:
|
|
200
|
+
"""Fire-and-forget — never raises."""
|
|
201
|
+
try:
|
|
202
|
+
from cli.crash_reporter import report_http_error
|
|
203
|
+
report_http_error(command, status_code, context)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
cli/api/auth.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication helpers: CSRF token fetching and login.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .helpers import err
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_csrf(host, session):
|
|
9
|
+
"""Return csrftoken, fetching from server only if not already in session."""
|
|
10
|
+
token = _first_csrf(session)
|
|
11
|
+
if token:
|
|
12
|
+
return token
|
|
13
|
+
# Fetch the login page — guaranteed to set the csrftoken cookie
|
|
14
|
+
# (home page may not render {% csrf_token %} and won't set the cookie)
|
|
15
|
+
session.get(f'{host}/auth/login/', timeout=10)
|
|
16
|
+
return _first_csrf(session) or ''
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _first_csrf(session):
|
|
20
|
+
"""Safely get csrftoken even if duplicate cookies exist."""
|
|
21
|
+
for cookie in session.cookies:
|
|
22
|
+
if cookie.name == 'csrftoken':
|
|
23
|
+
return cookie.value
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def login(host, session, identifier, password):
|
|
28
|
+
"""
|
|
29
|
+
Authenticate with the drp server using username or email.
|
|
30
|
+
Returns True on success, False on bad credentials.
|
|
31
|
+
Raises requests.RequestException on network errors.
|
|
32
|
+
"""
|
|
33
|
+
csrf = get_csrf(host, session)
|
|
34
|
+
res = session.post(
|
|
35
|
+
f'{host}/auth/login/',
|
|
36
|
+
data={'email': identifier, 'password': password, 'csrfmiddlewaretoken': csrf},
|
|
37
|
+
timeout=10,
|
|
38
|
+
allow_redirects=False,
|
|
39
|
+
)
|
|
40
|
+
return res.status_code in (301, 302)
|
cli/api/file.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File drop API calls.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import mimetypes
|
|
7
|
+
|
|
8
|
+
import requests as _requests
|
|
9
|
+
|
|
10
|
+
from .auth import get_csrf
|
|
11
|
+
from .helpers import err
|
|
12
|
+
|
|
13
|
+
CHUNK = 256 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _report(command, msg):
|
|
17
|
+
try:
|
|
18
|
+
from cli.crash_reporter import report
|
|
19
|
+
report(command, RuntimeError(msg))
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _touch_session():
|
|
25
|
+
try:
|
|
26
|
+
from cli.session import SESSION_FILE
|
|
27
|
+
SESSION_FILE.touch()
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Upload ────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def upload_file(host, session, filepath, key=None, expiry_days=None, password=None,
|
|
35
|
+
is_test=False, schedule=None, webhook_url=None, notify=None,
|
|
36
|
+
is_public=False, tags=None):
|
|
37
|
+
"""
|
|
38
|
+
Upload a file using the prepare → direct-PUT → confirm flow.
|
|
39
|
+
Returns the drop key string on success, None on failure.
|
|
40
|
+
"""
|
|
41
|
+
from cli.progress import ProgressBar
|
|
42
|
+
|
|
43
|
+
size = os.path.getsize(filepath)
|
|
44
|
+
filename = os.path.basename(filepath)
|
|
45
|
+
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
46
|
+
|
|
47
|
+
# ── Step 1: prepare ───────────────────────────────────────────────────────
|
|
48
|
+
payload = {
|
|
49
|
+
"filename": filename,
|
|
50
|
+
"size": size,
|
|
51
|
+
"content_type": content_type,
|
|
52
|
+
"ns": "f",
|
|
53
|
+
}
|
|
54
|
+
if key:
|
|
55
|
+
payload["key"] = key
|
|
56
|
+
if expiry_days:
|
|
57
|
+
payload["expiry_days"] = expiry_days
|
|
58
|
+
if is_test:
|
|
59
|
+
payload["is_test"] = True
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
csrf = get_csrf(host, session)
|
|
63
|
+
res = session.post(
|
|
64
|
+
f"{host}/upload/prepare/",
|
|
65
|
+
json=payload,
|
|
66
|
+
headers={"X-CSRFToken": csrf},
|
|
67
|
+
timeout=30,
|
|
68
|
+
)
|
|
69
|
+
if not res.ok:
|
|
70
|
+
msg = f"Prepare failed (HTTP {res.status_code})"
|
|
71
|
+
_handle_error(res, "Prepare failed")
|
|
72
|
+
_report("up", msg)
|
|
73
|
+
return None
|
|
74
|
+
prep = res.json()
|
|
75
|
+
_touch_session()
|
|
76
|
+
except Exception as e:
|
|
77
|
+
err(f"Prepare error: {e}")
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
presigned_url = prep["presigned_url"]
|
|
81
|
+
drop_key = prep["key"]
|
|
82
|
+
|
|
83
|
+
# ── Step 2: stream file directly to B2 ───────────────────────────────────
|
|
84
|
+
bar = ProgressBar(size, label="uploading")
|
|
85
|
+
|
|
86
|
+
class _ProgressFile:
|
|
87
|
+
def __init__(self, path):
|
|
88
|
+
self._f = open(path, "rb")
|
|
89
|
+
def read(self, n=-1):
|
|
90
|
+
chunk = self._f.read(n)
|
|
91
|
+
if chunk:
|
|
92
|
+
bar.update(len(chunk))
|
|
93
|
+
return chunk
|
|
94
|
+
def __len__(self):
|
|
95
|
+
return size
|
|
96
|
+
def close(self):
|
|
97
|
+
self._f.close()
|
|
98
|
+
|
|
99
|
+
pf = _ProgressFile(filepath)
|
|
100
|
+
try:
|
|
101
|
+
put_res = _requests.put(
|
|
102
|
+
presigned_url,
|
|
103
|
+
data=pf,
|
|
104
|
+
headers={
|
|
105
|
+
"Content-Type": content_type,
|
|
106
|
+
"Content-Length": str(size),
|
|
107
|
+
},
|
|
108
|
+
timeout=None,
|
|
109
|
+
)
|
|
110
|
+
if not put_res.ok:
|
|
111
|
+
msg = f"B2 upload failed (HTTP {put_res.status_code})"
|
|
112
|
+
err(f"{msg}: {put_res.text[:200]}")
|
|
113
|
+
_report("up", msg)
|
|
114
|
+
return None
|
|
115
|
+
except Exception as e:
|
|
116
|
+
err(f"Upload error: {e}")
|
|
117
|
+
raise
|
|
118
|
+
finally:
|
|
119
|
+
pf.close()
|
|
120
|
+
|
|
121
|
+
bar.done()
|
|
122
|
+
|
|
123
|
+
# ── Step 3: confirm ───────────────────────────────────────────────────────
|
|
124
|
+
confirm_payload = {
|
|
125
|
+
"key": drop_key,
|
|
126
|
+
"ns": "f",
|
|
127
|
+
"filename": filename,
|
|
128
|
+
"content_type": content_type,
|
|
129
|
+
}
|
|
130
|
+
if expiry_days:
|
|
131
|
+
confirm_payload["expiry_days"] = expiry_days
|
|
132
|
+
if password:
|
|
133
|
+
confirm_payload["password"] = password
|
|
134
|
+
if is_test:
|
|
135
|
+
confirm_payload["is_test"] = True
|
|
136
|
+
if schedule:
|
|
137
|
+
confirm_payload["schedule"] = schedule
|
|
138
|
+
if webhook_url:
|
|
139
|
+
confirm_payload["webhook_url"] = webhook_url
|
|
140
|
+
if notify:
|
|
141
|
+
confirm_payload["notify"] = notify
|
|
142
|
+
if is_public:
|
|
143
|
+
confirm_payload["is_public"] = True
|
|
144
|
+
if tags:
|
|
145
|
+
confirm_payload["tags"] = tags
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
csrf = get_csrf(host, session)
|
|
149
|
+
res = session.post(
|
|
150
|
+
f"{host}/upload/confirm/",
|
|
151
|
+
json=confirm_payload,
|
|
152
|
+
headers={"X-CSRFToken": csrf},
|
|
153
|
+
timeout=30,
|
|
154
|
+
)
|
|
155
|
+
if res.ok:
|
|
156
|
+
_touch_session()
|
|
157
|
+
return res.json().get("key")
|
|
158
|
+
msg = f"Confirm failed (HTTP {res.status_code})"
|
|
159
|
+
_handle_error(res, "Confirm failed")
|
|
160
|
+
_report("up", msg)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
err(f"Confirm error: {e}")
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── Download ──────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def get_file(host, session, key, password=''):
|
|
171
|
+
"""
|
|
172
|
+
Fetch a file drop.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
('file', (bytes_content, filename)) — success
|
|
176
|
+
('password_required', None) — password needed / wrong password
|
|
177
|
+
(None, None) — not found, expired, or error
|
|
178
|
+
"""
|
|
179
|
+
from cli.progress import ProgressBar
|
|
180
|
+
|
|
181
|
+
headers = {"Accept": "application/json"}
|
|
182
|
+
if password:
|
|
183
|
+
headers["X-Drop-Password"] = password
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
res = session.get(
|
|
187
|
+
f"{host}/f/{key}/",
|
|
188
|
+
headers=headers,
|
|
189
|
+
timeout=30,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if res.status_code == 401:
|
|
193
|
+
return 'password_required', None
|
|
194
|
+
|
|
195
|
+
if not res.ok:
|
|
196
|
+
_handle_http_error(res, key)
|
|
197
|
+
return None, None
|
|
198
|
+
|
|
199
|
+
_touch_session()
|
|
200
|
+
|
|
201
|
+
data = res.json()
|
|
202
|
+
if data.get("kind") != "file":
|
|
203
|
+
err(f"/f/{key}/ is not a file drop.")
|
|
204
|
+
return None, None
|
|
205
|
+
|
|
206
|
+
filename = data.get("filename", key)
|
|
207
|
+
filesize = data.get("filesize", 0)
|
|
208
|
+
|
|
209
|
+
b2_url = data.get("presigned_url")
|
|
210
|
+
|
|
211
|
+
if not b2_url:
|
|
212
|
+
download_path = data.get("download")
|
|
213
|
+
if not download_path:
|
|
214
|
+
err(f"No download URL in response for /f/{key}/.")
|
|
215
|
+
_report("get", "missing both presigned_url and download fields")
|
|
216
|
+
return None, None
|
|
217
|
+
|
|
218
|
+
dl_res = session.get(
|
|
219
|
+
f"{host}{download_path}",
|
|
220
|
+
timeout=10,
|
|
221
|
+
allow_redirects=False,
|
|
222
|
+
)
|
|
223
|
+
if dl_res.status_code == 401:
|
|
224
|
+
return 'password_required', None
|
|
225
|
+
if dl_res.status_code in (301, 302, 303, 307, 308):
|
|
226
|
+
b2_url = dl_res.headers["Location"]
|
|
227
|
+
elif dl_res.ok:
|
|
228
|
+
return "file", (dl_res.content, filename)
|
|
229
|
+
else:
|
|
230
|
+
msg = f"Download redirect failed (HTTP {dl_res.status_code})"
|
|
231
|
+
err(f"{msg}.")
|
|
232
|
+
_report("get", msg)
|
|
233
|
+
return None, None
|
|
234
|
+
|
|
235
|
+
bar = ProgressBar(max(filesize, 1), label="downloading")
|
|
236
|
+
chunks = []
|
|
237
|
+
downloaded = 0
|
|
238
|
+
retries = 3
|
|
239
|
+
|
|
240
|
+
for attempt in range(retries + 1):
|
|
241
|
+
req_headers = {}
|
|
242
|
+
if downloaded:
|
|
243
|
+
req_headers["Range"] = f"bytes={downloaded}-"
|
|
244
|
+
try:
|
|
245
|
+
with _requests.get(b2_url, stream=True, timeout=30,
|
|
246
|
+
headers=req_headers) as stream:
|
|
247
|
+
if stream.status_code not in (200, 206):
|
|
248
|
+
msg = f"B2 download failed (HTTP {stream.status_code})"
|
|
249
|
+
err(f"{msg}.")
|
|
250
|
+
_report("get", msg)
|
|
251
|
+
return None, None
|
|
252
|
+
for chunk in stream.iter_content(chunk_size=CHUNK):
|
|
253
|
+
if chunk:
|
|
254
|
+
chunks.append(chunk)
|
|
255
|
+
downloaded += len(chunk)
|
|
256
|
+
bar.update(len(chunk))
|
|
257
|
+
break # success — exit retry loop
|
|
258
|
+
except (_requests.exceptions.ChunkedEncodingError,
|
|
259
|
+
_requests.exceptions.ConnectionError) as exc:
|
|
260
|
+
if attempt < retries:
|
|
261
|
+
import time as _time
|
|
262
|
+
_time.sleep(1)
|
|
263
|
+
continue
|
|
264
|
+
err(f"Download failed after {retries + 1} attempts: {exc}")
|
|
265
|
+
return None, None
|
|
266
|
+
|
|
267
|
+
bar.done()
|
|
268
|
+
return "file", (b"".join(chunks), filename)
|
|
269
|
+
|
|
270
|
+
except Exception as e:
|
|
271
|
+
err(f"Get error: {e}")
|
|
272
|
+
raise
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _handle_error(res, prefix):
|
|
276
|
+
try:
|
|
277
|
+
msg = res.json().get("error", res.text[:200])
|
|
278
|
+
except Exception:
|
|
279
|
+
msg = res.text[:200]
|
|
280
|
+
err(f"{prefix}: {msg}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _handle_http_error(res, key):
|
|
284
|
+
if res.status_code == 404:
|
|
285
|
+
err(f"File /f/{key}/ not found.")
|
|
286
|
+
elif res.status_code == 410:
|
|
287
|
+
err(f"File /f/{key}/ has expired.")
|
|
288
|
+
else:
|
|
289
|
+
msg = f"Server returned {res.status_code}"
|
|
290
|
+
err(f"{msg}.")
|
|
291
|
+
_report("get", msg)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── Remote URL upload ─────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def upload_from_url(host, session, url, key=None, expiry_days=None,
|
|
297
|
+
password=None, is_test=False, schedule=None,
|
|
298
|
+
webhook_url=None, notify=None, is_public=False, tags=None):
|
|
299
|
+
"""
|
|
300
|
+
Ask the server to fetch a URL and store it as a file drop.
|
|
301
|
+
Returns (key, filename, filesize) on success, None on failure.
|
|
302
|
+
"""
|
|
303
|
+
payload = {"url": url}
|
|
304
|
+
if key:
|
|
305
|
+
payload["key"] = key
|
|
306
|
+
if expiry_days:
|
|
307
|
+
payload["expiry_days"] = expiry_days
|
|
308
|
+
if password:
|
|
309
|
+
payload["password"] = password
|
|
310
|
+
if is_test:
|
|
311
|
+
payload["is_test"] = True
|
|
312
|
+
if schedule:
|
|
313
|
+
payload["schedule"] = schedule
|
|
314
|
+
if webhook_url:
|
|
315
|
+
payload["webhook_url"] = webhook_url
|
|
316
|
+
if notify:
|
|
317
|
+
payload["notify"] = notify
|
|
318
|
+
if is_public:
|
|
319
|
+
payload["is_public"] = True
|
|
320
|
+
if tags:
|
|
321
|
+
payload["tags"] = tags
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
csrf = get_csrf(host, session)
|
|
325
|
+
res = session.post(
|
|
326
|
+
f"{host}/upload/from-url/",
|
|
327
|
+
json=payload,
|
|
328
|
+
headers={"X-CSRFToken": csrf},
|
|
329
|
+
timeout=120, # server-side fetch can take a while
|
|
330
|
+
)
|
|
331
|
+
if not res.ok:
|
|
332
|
+
_handle_error(res, "Remote upload failed")
|
|
333
|
+
_report("up", f"upload_from_url HTTP {res.status_code}")
|
|
334
|
+
return None
|
|
335
|
+
_touch_session()
|
|
336
|
+
data = res.json()
|
|
337
|
+
return data.get("key"), data.get("filename", ""), data.get("filesize", 0)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
err(f"Remote upload error: {e}")
|
|
340
|
+
raise
|
cli/api/helpers.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Small utilities shared across CLI API modules.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def slug(name):
|
|
10
|
+
"""Turn a filename into a url-safe slug (max 40 chars)."""
|
|
11
|
+
import secrets
|
|
12
|
+
import re
|
|
13
|
+
stem = Path(name).stem
|
|
14
|
+
safe = ''.join(c if c.isalnum() or c in '-_' else '-' for c in stem).strip('-')
|
|
15
|
+
safe = re.sub(r'-{2,}', '-', safe) # collapse consecutive hyphens
|
|
16
|
+
return safe[:40] or secrets.token_urlsafe(6)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def err(msg):
|
|
20
|
+
"""Print a formatted error to stderr."""
|
|
21
|
+
from cli.format import red
|
|
22
|
+
print(f' {red("✗", stream=sys.stderr)} {msg}', file=sys.stderr)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ok(msg):
|
|
26
|
+
"""Print a formatted success message."""
|
|
27
|
+
from cli.format import green
|
|
28
|
+
print(f' {green("✓")} {msg}')
|