drp-cli 0.1.3__tar.gz
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.
- drp_cli-0.1.3/CLI.md +57 -0
- drp_cli-0.1.3/LICENSE +21 -0
- drp_cli-0.1.3/PKG-INFO +82 -0
- drp_cli-0.1.3/README.md +134 -0
- drp_cli-0.1.3/cli/__init__.py +3 -0
- drp_cli-0.1.3/cli/api.py +206 -0
- drp_cli-0.1.3/cli/config.py +26 -0
- drp_cli-0.1.3/cli/drp.py +294 -0
- drp_cli-0.1.3/cli/tests.py +334 -0
- drp_cli-0.1.3/drp_cli.egg-info/PKG-INFO +82 -0
- drp_cli-0.1.3/drp_cli.egg-info/SOURCES.txt +15 -0
- drp_cli-0.1.3/drp_cli.egg-info/dependency_links.txt +1 -0
- drp_cli-0.1.3/drp_cli.egg-info/entry_points.txt +2 -0
- drp_cli-0.1.3/drp_cli.egg-info/requires.txt +1 -0
- drp_cli-0.1.3/drp_cli.egg-info/top_level.txt +1 -0
- drp_cli-0.1.3/pyproject.toml +39 -0
- drp_cli-0.1.3/setup.cfg +4 -0
drp_cli-0.1.3/CLI.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# drp
|
|
2
|
+
|
|
3
|
+
Drop text and files from the command line — get a link instantly.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install drp-cli
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
drp setup # configure host & log in
|
|
13
|
+
drp up notes.txt # upload a file → prints URL
|
|
14
|
+
drp up "hello world" # upload text → prints URL
|
|
15
|
+
drp up doc.pdf -k cv # upload with a custom key
|
|
16
|
+
drp get mykey # text → stdout, file → saved to disk
|
|
17
|
+
drp get mykey -o a.txt # save file with custom name
|
|
18
|
+
drp rm mykey # delete a drop
|
|
19
|
+
drp mv mykey newkey # rename a drop
|
|
20
|
+
drp renew mykey # renew a drop's expiry (paid)
|
|
21
|
+
drp ls # list your drops (requires login)
|
|
22
|
+
drp status # show config
|
|
23
|
+
drp --version # show version
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
1. `drp setup` saves your host URL (default: `https://drp.vicnas.me`) and optionally logs you in
|
|
29
|
+
2. `drp up` uploads a file or text string and prints the shareable URL
|
|
30
|
+
3. `drp get` retrieves a drop — text is printed to stdout, files are saved to disk
|
|
31
|
+
4. Works anonymously or logged in — logged-in users get locked drops, longer expiry, and `drp ls`
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
Config is stored at `~/.config/drp/config.json`:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"host": "https://drp.vicnas.me",
|
|
40
|
+
"email": "you@example.com"
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Self-hosted
|
|
45
|
+
|
|
46
|
+
Point the CLI at your own instance:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
drp setup
|
|
50
|
+
# enter your server URL when prompted
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See the [server repo](https://github.com/vicnasdev/drp) for deployment instructions.
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
drp_cli-0.1.3/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Victorio Nascimento
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
drp_cli-0.1.3/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drp-cli
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Drop text and files from the command line — get a link instantly.
|
|
5
|
+
Author: Vic Nas
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://drp.vicnas.me
|
|
8
|
+
Project-URL: Repository, https://github.com/vicnasdev/drp
|
|
9
|
+
Project-URL: Issues, https://github.com/vicnasdev/drp/issues
|
|
10
|
+
Keywords: cli,file-sharing,pastebin,drops
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: requests>=2.28
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# drp
|
|
27
|
+
|
|
28
|
+
Drop text and files from the command line — get a link instantly.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install drp-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
drp setup # configure host & log in
|
|
38
|
+
drp up notes.txt # upload a file → prints URL
|
|
39
|
+
drp up "hello world" # upload text → prints URL
|
|
40
|
+
drp up doc.pdf -k cv # upload with a custom key
|
|
41
|
+
drp get mykey # text → stdout, file → saved to disk
|
|
42
|
+
drp get mykey -o a.txt # save file with custom name
|
|
43
|
+
drp rm mykey # delete a drop
|
|
44
|
+
drp mv mykey newkey # rename a drop
|
|
45
|
+
drp renew mykey # renew a drop's expiry (paid)
|
|
46
|
+
drp ls # list your drops (requires login)
|
|
47
|
+
drp status # show config
|
|
48
|
+
drp --version # show version
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
1. `drp setup` saves your host URL (default: `https://drp.vicnas.me`) and optionally logs you in
|
|
54
|
+
2. `drp up` uploads a file or text string and prints the shareable URL
|
|
55
|
+
3. `drp get` retrieves a drop — text is printed to stdout, files are saved to disk
|
|
56
|
+
4. Works anonymously or logged in — logged-in users get locked drops, longer expiry, and `drp ls`
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Config is stored at `~/.config/drp/config.json`:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"host": "https://drp.vicnas.me",
|
|
65
|
+
"email": "you@example.com"
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Self-hosted
|
|
70
|
+
|
|
71
|
+
Point the CLI at your own instance:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
drp setup
|
|
75
|
+
# enter your server URL when prompted
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See the [server repo](https://github.com/vicnasdev/drp) for deployment instructions.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
drp_cli-0.1.3/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# drp
|
|
2
|
+
|
|
3
|
+
Drop files or paste text, get a link instantly. No account, no friction — just a key.
|
|
4
|
+
|
|
5
|
+
- `/your-key` — view, copy, download, or replace a drop
|
|
6
|
+
- Anonymous text drops expire after **24h** (text) or **90 days** (files)
|
|
7
|
+
- Paid accounts get locked drops, longer expiry, and renewable links
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Text & file drops** — paste anything or drag a file, get a shareable URL
|
|
12
|
+
- **Custom keys** — pick a memorable key or get an auto-generated one
|
|
13
|
+
- **Locking** — paid drops are locked to the owner's account; anon drops have a 24h edit window
|
|
14
|
+
- **Expiry & renewal** — paid accounts set explicit expiry dates and can renew any time
|
|
15
|
+
- **Dashboard** — logged-in users see all drops server-side; anon users get a local browser list with export/import
|
|
16
|
+
- **CLI tool** — `drp up file.txt`, `drp up "some text"`, `drp get mykey`
|
|
17
|
+
- **Self-hostable** — deploys to Railway in a few clicks, runs locally with SQLite
|
|
18
|
+
|
|
19
|
+
## Deploy on Railway
|
|
20
|
+
|
|
21
|
+
[](https://railway.com?referralCode=ZIdvo-)
|
|
22
|
+
|
|
23
|
+
1. Fork this repo
|
|
24
|
+
2. New project on Railway → Deploy from GitHub repo
|
|
25
|
+
3. Add a PostgreSQL plugin
|
|
26
|
+
4. Set environment variables:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
SECRET_KEY=random_large_string
|
|
30
|
+
DOMAIN=RAILWAY_PUBLIC_DOMAIN
|
|
31
|
+
DB_URL=Postgres.DATABASE_URL
|
|
32
|
+
CLOUDINARY_CLOUD_NAME=
|
|
33
|
+
CLOUDINARY_API_KEY=
|
|
34
|
+
CLOUDINARY_API_SECRET=
|
|
35
|
+
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
|
36
|
+
EMAIL_HOST_USER=you@gmail.com
|
|
37
|
+
EMAIL_HOST_PASSWORD=xxxx xxxx xxxx xxxx
|
|
38
|
+
DEFAULT_FROM_EMAIL=you@gmail.com
|
|
39
|
+
ADMIN_EMAIL=you@gmail.com
|
|
40
|
+
LEMONSQUEEZY_API_KEY=
|
|
41
|
+
LEMONSQUEEZY_SIGNING_SECRET=
|
|
42
|
+
LEMONSQUEEZY_STORE_ID=
|
|
43
|
+
LEMONSQUEEZY_STARTER_VARIANT_ID=
|
|
44
|
+
LEMONSQUEEZY_PRO_VARIANT_ID=
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
5. Start command:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
python manage.py createcachetable && python manage.py collectstatic --noinput && python manage.py migrate && gunicorn project.wsgi --bind 0.0.0.0:$PORT
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> Note: run `python manage.py makemigrations` locally the first time before deploying.
|
|
54
|
+
|
|
55
|
+
## Run locally
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -r requirements.txt
|
|
59
|
+
cp .env.example .env # fill in Cloudinary values; leave EMAIL_* blank for console output
|
|
60
|
+
python manage.py migrate
|
|
61
|
+
python manage.py createcachetable
|
|
62
|
+
python manage.py runserver # or: make dev
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Password reset emails print to the terminal in dev — no mail server needed.
|
|
66
|
+
|
|
67
|
+
## Makefile
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
make dev # start Django dev server
|
|
71
|
+
make test # run all tests (core + cli)
|
|
72
|
+
make migrate # run migrations
|
|
73
|
+
make install # pip install -e . (installs drp command)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Gmail App Password (production email)
|
|
77
|
+
|
|
78
|
+
Self-service password reset requires an outbound mail account. Gmail works fine:
|
|
79
|
+
|
|
80
|
+
1. Enable **2-Step Verification** on your Google account
|
|
81
|
+
2. Go to **Security → App passwords**, create one named `drp`
|
|
82
|
+
3. Copy the 16-character password into `EMAIL_HOST_PASSWORD` in your `.env`
|
|
83
|
+
|
|
84
|
+
500 emails/day free. For higher volume use [Brevo](https://brevo.com) (3,000/mo free).
|
|
85
|
+
|
|
86
|
+
## CLI
|
|
87
|
+
|
|
88
|
+
Install from PyPI:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install drp-cli
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Or from source:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install . # or: make install
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Usage:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
drp setup # configure host & log in
|
|
104
|
+
drp up notes.txt # upload a file → prints URL
|
|
105
|
+
drp up "hello world" # upload text → prints URL
|
|
106
|
+
drp up doc.pdf -k cv # upload with custom key
|
|
107
|
+
drp get mykey # text → stdout, file → saved to disk
|
|
108
|
+
drp get mykey -o a.txt # save file with custom name
|
|
109
|
+
drp rm mykey # delete a drop
|
|
110
|
+
drp mv mykey newkey # rename a drop
|
|
111
|
+
drp renew mykey # renew expiry (paid)
|
|
112
|
+
drp ls # list your drops (requires login)
|
|
113
|
+
drp status # show config
|
|
114
|
+
drp --version # show version
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Works anonymously or logged in. Logged-in users get locked drops, longer expiry, and `drp ls`.
|
|
118
|
+
|
|
119
|
+
## Plans
|
|
120
|
+
|
|
121
|
+
| | Free | Starter ($3/mo) | Pro ($8/mo) |
|
|
122
|
+
|---|---|---|---|
|
|
123
|
+
| Max file size | 200 MB | 1 GB | 5 GB |
|
|
124
|
+
| Max text size | 500 KB | 2 MB | 10 MB |
|
|
125
|
+
| Storage | — | 5 GB | 20 GB |
|
|
126
|
+
| Expiry | 24h / 90d | Up to 1 year | Up to 3 years |
|
|
127
|
+
| Locked drops | ✗ | ✓ | ✓ |
|
|
128
|
+
| Renewable | ✗ | ✓ | ✓ |
|
|
129
|
+
|
|
130
|
+
Upgrade from your account page after signing up — billing is handled via Lemon Squeezy.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
drp_cli-0.1.3/cli/api.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP helpers for the drp CLI.
|
|
3
|
+
|
|
4
|
+
All server communication lives here: CSRF, login, upload, download, delete,
|
|
5
|
+
and key-existence checks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import requests
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_csrf(host, session):
|
|
15
|
+
"""Hit the home page to pick up the csrftoken cookie."""
|
|
16
|
+
session.get(f'{host}/', timeout=10)
|
|
17
|
+
return session.cookies.get('csrftoken', '')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def login(host, session, email, password):
|
|
21
|
+
"""Authenticate with the drp server. Returns True on success."""
|
|
22
|
+
csrf = get_csrf(host, session)
|
|
23
|
+
res = session.post(
|
|
24
|
+
f'{host}/auth/login/',
|
|
25
|
+
data={'email': email, 'password': password, 'csrfmiddlewaretoken': csrf},
|
|
26
|
+
timeout=10,
|
|
27
|
+
allow_redirects=False,
|
|
28
|
+
)
|
|
29
|
+
return res.status_code in (302, 301)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def upload_text(host, session, text, key=None):
|
|
33
|
+
"""Upload text content. Returns the key on success, None on failure."""
|
|
34
|
+
csrf = get_csrf(host, session)
|
|
35
|
+
data = {'content': text, 'csrfmiddlewaretoken': csrf}
|
|
36
|
+
if key:
|
|
37
|
+
data['key'] = key
|
|
38
|
+
try:
|
|
39
|
+
res = session.post(f'{host}/save/', data=data, timeout=30)
|
|
40
|
+
if res.ok:
|
|
41
|
+
return res.json().get('key')
|
|
42
|
+
else:
|
|
43
|
+
_err(f'upload failed: {res.text[:200]}')
|
|
44
|
+
except Exception as e:
|
|
45
|
+
_err(f'upload error: {e}')
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def upload_file(host, session, filepath, key=None):
|
|
50
|
+
"""Upload a file. Returns the key on success, None on failure."""
|
|
51
|
+
csrf = get_csrf(host, session)
|
|
52
|
+
data = {'csrfmiddlewaretoken': csrf}
|
|
53
|
+
if key:
|
|
54
|
+
data['key'] = key
|
|
55
|
+
try:
|
|
56
|
+
with open(filepath, 'rb') as f:
|
|
57
|
+
res = session.post(
|
|
58
|
+
f'{host}/save/',
|
|
59
|
+
files={'file': (os.path.basename(filepath), f)},
|
|
60
|
+
data=data,
|
|
61
|
+
timeout=120,
|
|
62
|
+
)
|
|
63
|
+
if res.ok:
|
|
64
|
+
return res.json().get('key')
|
|
65
|
+
else:
|
|
66
|
+
_err(f'upload failed: {res.text[:200]}')
|
|
67
|
+
except Exception as e:
|
|
68
|
+
_err(f'upload error: {e}')
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_drop(host, session, key):
|
|
73
|
+
"""
|
|
74
|
+
Fetch a drop's content via the JSON API.
|
|
75
|
+
Returns (kind, content) where kind is 'text' or 'file'.
|
|
76
|
+
For text drops, content is the string.
|
|
77
|
+
For file drops, content is (bytes, filename).
|
|
78
|
+
Returns (None, None) on failure.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
res = session.get(
|
|
82
|
+
f'{host}/{key}/',
|
|
83
|
+
headers={'Accept': 'application/json'},
|
|
84
|
+
timeout=30,
|
|
85
|
+
)
|
|
86
|
+
if not res.ok:
|
|
87
|
+
if res.status_code == 410:
|
|
88
|
+
_err('drop has expired')
|
|
89
|
+
elif res.status_code == 404:
|
|
90
|
+
_err('drop not found')
|
|
91
|
+
else:
|
|
92
|
+
_err(f'server returned {res.status_code}')
|
|
93
|
+
return None, None
|
|
94
|
+
|
|
95
|
+
data = res.json()
|
|
96
|
+
if data.get('kind') == 'text':
|
|
97
|
+
return 'text', data.get('content', '')
|
|
98
|
+
|
|
99
|
+
# File drop — follow the download redirect
|
|
100
|
+
dl = session.get(
|
|
101
|
+
f'{host}{data["download"]}',
|
|
102
|
+
timeout=120,
|
|
103
|
+
allow_redirects=True,
|
|
104
|
+
)
|
|
105
|
+
if dl.ok:
|
|
106
|
+
filename = data.get('filename', key)
|
|
107
|
+
return 'file', (dl.content, filename)
|
|
108
|
+
|
|
109
|
+
_err('failed to download file')
|
|
110
|
+
return None, None
|
|
111
|
+
except Exception as e:
|
|
112
|
+
_err(f'get error: {e}')
|
|
113
|
+
return None, None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def delete(host, session, key):
|
|
117
|
+
"""Delete a drop. Returns True on success."""
|
|
118
|
+
csrf = get_csrf(host, session)
|
|
119
|
+
try:
|
|
120
|
+
res = session.delete(
|
|
121
|
+
f'{host}/{key}/delete/',
|
|
122
|
+
headers={'X-CSRFToken': csrf},
|
|
123
|
+
timeout=10,
|
|
124
|
+
)
|
|
125
|
+
return res.ok
|
|
126
|
+
except Exception:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def rename(host, session, key, new_key):
|
|
131
|
+
"""Rename a drop's key. Returns the new key on success, None on failure."""
|
|
132
|
+
csrf = get_csrf(host, session)
|
|
133
|
+
try:
|
|
134
|
+
res = session.post(
|
|
135
|
+
f'{host}/{key}/rename/',
|
|
136
|
+
data={'new_key': new_key, 'csrfmiddlewaretoken': csrf},
|
|
137
|
+
timeout=10,
|
|
138
|
+
)
|
|
139
|
+
if res.ok:
|
|
140
|
+
return res.json().get('key')
|
|
141
|
+
_err(res.json().get('error', 'rename failed'))
|
|
142
|
+
except Exception as e:
|
|
143
|
+
_err(f'rename error: {e}')
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def renew(host, session, key):
|
|
148
|
+
"""Renew a drop's expiry. Returns (expires_at, renewals) on success."""
|
|
149
|
+
csrf = get_csrf(host, session)
|
|
150
|
+
try:
|
|
151
|
+
res = session.post(
|
|
152
|
+
f'{host}/{key}/renew/',
|
|
153
|
+
data={'csrfmiddlewaretoken': csrf},
|
|
154
|
+
timeout=10,
|
|
155
|
+
)
|
|
156
|
+
if res.ok:
|
|
157
|
+
data = res.json()
|
|
158
|
+
return data.get('expires_at'), data.get('renewals')
|
|
159
|
+
_err(res.json().get('error', 'renew failed'))
|
|
160
|
+
except Exception as e:
|
|
161
|
+
_err(f'renew error: {e}')
|
|
162
|
+
return None, None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def list_drops(host, session):
|
|
166
|
+
"""List the logged-in user's drops. Returns list of dicts or None."""
|
|
167
|
+
try:
|
|
168
|
+
res = session.get(
|
|
169
|
+
f'{host}/auth/account/',
|
|
170
|
+
headers={'Accept': 'application/json'},
|
|
171
|
+
timeout=15,
|
|
172
|
+
)
|
|
173
|
+
if res.ok:
|
|
174
|
+
return res.json().get('drops', [])
|
|
175
|
+
if res.status_code == 302:
|
|
176
|
+
_err('not logged in')
|
|
177
|
+
else:
|
|
178
|
+
_err(f'server returned {res.status_code}')
|
|
179
|
+
except Exception as e:
|
|
180
|
+
_err(f'list error: {e}')
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def key_exists(host, session, key):
|
|
185
|
+
"""Return True if the key exists on the server."""
|
|
186
|
+
try:
|
|
187
|
+
res = session.get(f'{host}/check-key/', params={'key': key}, timeout=10)
|
|
188
|
+
if res.ok:
|
|
189
|
+
return not res.json().get('available', True)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def slug(name):
|
|
196
|
+
"""Turn a filename into a url-safe slug (max 40 chars)."""
|
|
197
|
+
stem = Path(name).stem
|
|
198
|
+
safe = ''.join(c if c.isalnum() or c in '-_' else '-' for c in stem).strip('-')
|
|
199
|
+
return safe[:40] or secrets.token_urlsafe(6)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _err(msg):
|
|
203
|
+
"""Print an error message."""
|
|
204
|
+
import sys
|
|
205
|
+
print(f' ✗ {msg}', file=sys.stderr)
|
|
206
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config management for the drp CLI.
|
|
3
|
+
|
|
4
|
+
Stores host and email in ~/.config/drp/config.json.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / '.config' / 'drp'
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / 'config.json'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load(path=None):
|
|
15
|
+
"""Load config from disk. Returns empty dict if missing."""
|
|
16
|
+
p = Path(path) if path else CONFIG_FILE
|
|
17
|
+
if p.exists():
|
|
18
|
+
return json.loads(p.read_text())
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def save(cfg, path=None):
|
|
23
|
+
"""Save config to disk, creating parent dirs if needed."""
|
|
24
|
+
p = Path(path) if path else CONFIG_FILE
|
|
25
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
p.write_text(json.dumps(cfg, indent=2) + '\n')
|
drp_cli-0.1.3/cli/drp.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
drp — command-line tool for dropping text and files.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
drp setup # configure host & login
|
|
7
|
+
drp login # (re)authenticate
|
|
8
|
+
drp up <file> # upload a file
|
|
9
|
+
drp up <file> --key myname # upload with a custom key
|
|
10
|
+
drp up "some text" # upload text
|
|
11
|
+
drp get <key> # download a drop
|
|
12
|
+
drp rm <key> # delete a drop
|
|
13
|
+
drp mv <key> <new-key> # rename a drop
|
|
14
|
+
drp renew <key> # renew a drop's expiry
|
|
15
|
+
drp ls # list your drops (requires login)
|
|
16
|
+
drp status # show config
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
import os
|
|
21
|
+
import getpass
|
|
22
|
+
import argparse
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from cli import config, api
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_setup(args):
|
|
29
|
+
"""Interactive first-time setup."""
|
|
30
|
+
cfg = config.load()
|
|
31
|
+
print('drp setup')
|
|
32
|
+
print('─────────')
|
|
33
|
+
default = cfg.get('host', 'https://drp.vicnas.me')
|
|
34
|
+
cfg['host'] = input(f' Host [{default}]: ').strip() or default
|
|
35
|
+
config.save(cfg)
|
|
36
|
+
|
|
37
|
+
answer = input(' Log in now? (y/n) [y]: ').strip().lower()
|
|
38
|
+
if answer != 'n':
|
|
39
|
+
cmd_login(args)
|
|
40
|
+
else:
|
|
41
|
+
config.save(cfg)
|
|
42
|
+
|
|
43
|
+
print(f'\n ✓ Saved to {config.CONFIG_FILE}')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_login(args):
|
|
47
|
+
"""Log in to drp."""
|
|
48
|
+
cfg = config.load()
|
|
49
|
+
host = cfg.get('host', 'https://drp.vicnas.me')
|
|
50
|
+
email = input(' Email: ').strip()
|
|
51
|
+
password = getpass.getpass(' Password: ')
|
|
52
|
+
session = requests.Session()
|
|
53
|
+
if api.login(host, session, email, password):
|
|
54
|
+
cfg['email'] = email
|
|
55
|
+
config.save(cfg)
|
|
56
|
+
print(f' ✓ Logged in as {email}')
|
|
57
|
+
else:
|
|
58
|
+
print(' ✗ Login failed.')
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_up(args):
|
|
63
|
+
"""Upload a file or text."""
|
|
64
|
+
cfg = config.load()
|
|
65
|
+
host = cfg.get('host')
|
|
66
|
+
if not host:
|
|
67
|
+
print('Not configured. Run: drp setup')
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
session = requests.Session()
|
|
71
|
+
_auto_login(cfg, host, session)
|
|
72
|
+
|
|
73
|
+
target = args.target
|
|
74
|
+
key = args.key
|
|
75
|
+
|
|
76
|
+
# If target is a file path that exists, upload as file
|
|
77
|
+
if os.path.isfile(target):
|
|
78
|
+
if not key:
|
|
79
|
+
key = api.slug(os.path.basename(target))
|
|
80
|
+
result_key = api.upload_file(host, session, target, key=key)
|
|
81
|
+
if result_key:
|
|
82
|
+
url = f'{host}/{result_key}/'
|
|
83
|
+
print(f'{url}')
|
|
84
|
+
else:
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
else:
|
|
87
|
+
# Treat as text content
|
|
88
|
+
result_key = api.upload_text(host, session, target, key=key)
|
|
89
|
+
if result_key:
|
|
90
|
+
url = f'{host}/{result_key}/'
|
|
91
|
+
print(f'{url}')
|
|
92
|
+
else:
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_get(args):
|
|
97
|
+
"""Download a drop."""
|
|
98
|
+
cfg = config.load()
|
|
99
|
+
host = cfg.get('host')
|
|
100
|
+
if not host:
|
|
101
|
+
print('Not configured. Run: drp setup')
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
session = requests.Session()
|
|
105
|
+
_auto_login(cfg, host, session)
|
|
106
|
+
|
|
107
|
+
key = args.key
|
|
108
|
+
kind, content = api.get_drop(host, session, key)
|
|
109
|
+
|
|
110
|
+
if kind == 'text':
|
|
111
|
+
print(content)
|
|
112
|
+
elif kind == 'file':
|
|
113
|
+
data, filename = content
|
|
114
|
+
out = args.output or filename
|
|
115
|
+
with open(out, 'wb') as f:
|
|
116
|
+
f.write(data)
|
|
117
|
+
print(f' ✓ {out} ({len(data)} bytes)')
|
|
118
|
+
else:
|
|
119
|
+
print(f' ✗ drop not found: {key}')
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cmd_rm(args):
|
|
124
|
+
"""Delete a drop."""
|
|
125
|
+
cfg = config.load()
|
|
126
|
+
host = cfg.get('host')
|
|
127
|
+
if not host:
|
|
128
|
+
print('Not configured. Run: drp setup')
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
session = requests.Session()
|
|
132
|
+
_auto_login(cfg, host, session)
|
|
133
|
+
|
|
134
|
+
if api.delete(host, session, args.key):
|
|
135
|
+
print(f' ✓ deleted /{args.key}/')
|
|
136
|
+
else:
|
|
137
|
+
print(f' ✗ could not delete /{args.key}/')
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_mv(args):
|
|
142
|
+
"""Rename a drop's key."""
|
|
143
|
+
cfg = config.load()
|
|
144
|
+
host = cfg.get('host')
|
|
145
|
+
if not host:
|
|
146
|
+
print('Not configured. Run: drp setup')
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
session = requests.Session()
|
|
150
|
+
_auto_login(cfg, host, session)
|
|
151
|
+
|
|
152
|
+
new_key = api.rename(host, session, args.key, args.new_key)
|
|
153
|
+
if new_key:
|
|
154
|
+
print(f' ✓ /{args.key}/ → /{new_key}/')
|
|
155
|
+
else:
|
|
156
|
+
print(f' ✗ could not rename /{args.key}/')
|
|
157
|
+
sys.exit(1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_renew(args):
|
|
161
|
+
"""Renew a drop's expiry."""
|
|
162
|
+
cfg = config.load()
|
|
163
|
+
host = cfg.get('host')
|
|
164
|
+
if not host:
|
|
165
|
+
print('Not configured. Run: drp setup')
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
session = requests.Session()
|
|
169
|
+
_auto_login(cfg, host, session)
|
|
170
|
+
|
|
171
|
+
expires_at, renewals = api.renew(host, session, args.key)
|
|
172
|
+
if expires_at:
|
|
173
|
+
print(f' ✓ /{args.key}/ renewed (expires {expires_at}, #{renewals})')
|
|
174
|
+
else:
|
|
175
|
+
print(f' ✗ could not renew /{args.key}/')
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_ls(args):
|
|
180
|
+
"""List your drops."""
|
|
181
|
+
cfg = config.load()
|
|
182
|
+
host = cfg.get('host')
|
|
183
|
+
if not host:
|
|
184
|
+
print('Not configured. Run: drp setup')
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
session = requests.Session()
|
|
188
|
+
_auto_login(cfg, host, session)
|
|
189
|
+
|
|
190
|
+
drops = api.list_drops(host, session)
|
|
191
|
+
if drops is None:
|
|
192
|
+
print(' ✗ could not list drops (are you logged in?)')
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
if not drops:
|
|
196
|
+
print(' (no drops)')
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
for d in drops:
|
|
200
|
+
kind = d['kind']
|
|
201
|
+
key = d['key']
|
|
202
|
+
name = d.get('filename') or ''
|
|
203
|
+
if kind == 'file' and name:
|
|
204
|
+
print(f' {key:30s} {kind:4s} {name}')
|
|
205
|
+
else:
|
|
206
|
+
print(f' {key:30s} {kind:4s}')
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_status(args):
|
|
210
|
+
"""Show current config."""
|
|
211
|
+
cfg = config.load()
|
|
212
|
+
print('drp status')
|
|
213
|
+
print('──────────')
|
|
214
|
+
print(f' Host: {cfg.get("host", "(not set)")}')
|
|
215
|
+
print(f' Account: {cfg.get("email", "anonymous")}')
|
|
216
|
+
print(f' Config: {config.CONFIG_FILE}')
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _auto_login(cfg, host, session):
|
|
220
|
+
"""Silently log in if email is saved. Continues anonymously on failure."""
|
|
221
|
+
email = cfg.get('email')
|
|
222
|
+
if email:
|
|
223
|
+
password = getpass.getpass(f' Password for {email}: ')
|
|
224
|
+
if not api.login(host, session, email, password):
|
|
225
|
+
print(' ⚠ Login failed, continuing as anonymous.')
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main():
|
|
229
|
+
from cli import __version__
|
|
230
|
+
|
|
231
|
+
parser = argparse.ArgumentParser(
|
|
232
|
+
prog='drp',
|
|
233
|
+
description='Drop text and files from the command line.',
|
|
234
|
+
)
|
|
235
|
+
parser.add_argument('--version', '-V', action='version', version=f'%(prog)s {__version__}')
|
|
236
|
+
sub = parser.add_subparsers(dest='command')
|
|
237
|
+
|
|
238
|
+
# setup
|
|
239
|
+
sub.add_parser('setup', help='Configure host & login')
|
|
240
|
+
|
|
241
|
+
# login
|
|
242
|
+
sub.add_parser('login', help='Log in to drp')
|
|
243
|
+
|
|
244
|
+
# up
|
|
245
|
+
p_up = sub.add_parser('up', help='Upload a file or text')
|
|
246
|
+
p_up.add_argument('target', help='File path or text string to upload')
|
|
247
|
+
p_up.add_argument('--key', '-k', default=None, help='Custom key (default: auto from filename)')
|
|
248
|
+
|
|
249
|
+
# get
|
|
250
|
+
p_get = sub.add_parser('get', help='Download a drop')
|
|
251
|
+
p_get.add_argument('key', help='Drop key to download')
|
|
252
|
+
p_get.add_argument('--output', '-o', default=None, help='Output filename (files only)')
|
|
253
|
+
|
|
254
|
+
# rm
|
|
255
|
+
p_rm = sub.add_parser('rm', help='Delete a drop')
|
|
256
|
+
p_rm.add_argument('key', help='Drop key to delete')
|
|
257
|
+
|
|
258
|
+
# mv (rename)
|
|
259
|
+
p_mv = sub.add_parser('mv', help='Rename a drop key')
|
|
260
|
+
p_mv.add_argument('key', help='Current drop key')
|
|
261
|
+
p_mv.add_argument('new_key', help='New key')
|
|
262
|
+
|
|
263
|
+
# renew
|
|
264
|
+
p_renew = sub.add_parser('renew', help='Renew a drop expiry')
|
|
265
|
+
p_renew.add_argument('key', help='Drop key to renew')
|
|
266
|
+
|
|
267
|
+
# ls
|
|
268
|
+
sub.add_parser('ls', help='List your drops (requires login)')
|
|
269
|
+
|
|
270
|
+
# status
|
|
271
|
+
sub.add_parser('status', help='Show config')
|
|
272
|
+
|
|
273
|
+
args = parser.parse_args()
|
|
274
|
+
|
|
275
|
+
commands = {
|
|
276
|
+
'setup': cmd_setup,
|
|
277
|
+
'login': cmd_login,
|
|
278
|
+
'up': cmd_up,
|
|
279
|
+
'get': cmd_get,
|
|
280
|
+
'rm': cmd_rm,
|
|
281
|
+
'mv': cmd_mv,
|
|
282
|
+
'renew': cmd_renew,
|
|
283
|
+
'ls': cmd_ls,
|
|
284
|
+
'status': cmd_status,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if args.command in commands:
|
|
288
|
+
commands[args.command](args)
|
|
289
|
+
else:
|
|
290
|
+
parser.print_help()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == '__main__':
|
|
294
|
+
main()
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the drp CLI helpers (config, api, slug).
|
|
3
|
+
|
|
4
|
+
Uses LiveServerTestCase so api functions talk to a real Django instance.
|
|
5
|
+
All tests are self-cleaning (transaction rollback per test).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
from django.contrib.auth.models import User
|
|
15
|
+
from django.test import LiveServerTestCase, override_settings
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from cli import api, config
|
|
20
|
+
|
|
21
|
+
# Use plain static storage in tests (no collectstatic needed)
|
|
22
|
+
_STATIC_OVERRIDE = override_settings(
|
|
23
|
+
STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
28
|
+
# Config
|
|
29
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
30
|
+
|
|
31
|
+
class ConfigTests(LiveServerTestCase):
|
|
32
|
+
"""config.load / config.save with temp files."""
|
|
33
|
+
|
|
34
|
+
def test_load_missing_returns_empty(self):
|
|
35
|
+
"""Loading a non-existent config returns {}."""
|
|
36
|
+
self.assertEqual(config.load('/tmp/drp_nope_12345.json'), {})
|
|
37
|
+
|
|
38
|
+
def test_save_and_load_roundtrip(self):
|
|
39
|
+
"""save → load returns the same data."""
|
|
40
|
+
with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
|
|
41
|
+
path = f.name
|
|
42
|
+
try:
|
|
43
|
+
cfg = {'host': 'https://example.com', 'email': 'a@b.com'}
|
|
44
|
+
config.save(cfg, path)
|
|
45
|
+
loaded = config.load(path)
|
|
46
|
+
self.assertEqual(loaded, cfg)
|
|
47
|
+
finally:
|
|
48
|
+
os.unlink(path)
|
|
49
|
+
|
|
50
|
+
def test_save_creates_parent_dirs(self):
|
|
51
|
+
"""save creates missing parent directories."""
|
|
52
|
+
with tempfile.TemporaryDirectory() as d:
|
|
53
|
+
path = os.path.join(d, 'sub', 'dir', 'config.json')
|
|
54
|
+
config.save({'host': 'x'}, path)
|
|
55
|
+
self.assertTrue(os.path.exists(path))
|
|
56
|
+
self.assertEqual(config.load(path), {'host': 'x'})
|
|
57
|
+
|
|
58
|
+
def test_save_overwrites(self):
|
|
59
|
+
"""Saving twice overwrites the first value."""
|
|
60
|
+
with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
|
|
61
|
+
path = f.name
|
|
62
|
+
try:
|
|
63
|
+
config.save({'host': 'old'}, path)
|
|
64
|
+
config.save({'host': 'new'}, path)
|
|
65
|
+
self.assertEqual(config.load(path)['host'], 'new')
|
|
66
|
+
finally:
|
|
67
|
+
os.unlink(path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
71
|
+
# Slug
|
|
72
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
73
|
+
|
|
74
|
+
class SlugTests(LiveServerTestCase):
|
|
75
|
+
"""api.slug turns filenames into url-safe keys."""
|
|
76
|
+
|
|
77
|
+
def test_simple(self):
|
|
78
|
+
self.assertEqual(api.slug('notes.txt'), 'notes')
|
|
79
|
+
|
|
80
|
+
def test_spaces_become_dashes(self):
|
|
81
|
+
self.assertEqual(api.slug('my cool file.pdf'), 'my-cool-file')
|
|
82
|
+
|
|
83
|
+
def test_special_chars_stripped(self):
|
|
84
|
+
self.assertEqual(api.slug('hello@world!.py'), 'hello-world')
|
|
85
|
+
|
|
86
|
+
def test_long_name_truncated(self):
|
|
87
|
+
name = 'a' * 100 + '.txt'
|
|
88
|
+
self.assertLessEqual(len(api.slug(name)), 40)
|
|
89
|
+
|
|
90
|
+
def test_empty_name_gets_random(self):
|
|
91
|
+
"""An empty stem (e.g. '.bashrc') still produces a non-empty slug."""
|
|
92
|
+
result = api.slug('.bashrc')
|
|
93
|
+
# .bashrc stem is '.bashrc' → 'bashrc' after stripping
|
|
94
|
+
self.assertTrue(len(result) > 0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
98
|
+
# API – CSRF & Login
|
|
99
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
100
|
+
|
|
101
|
+
@_STATIC_OVERRIDE
|
|
102
|
+
class AuthApiTests(LiveServerTestCase):
|
|
103
|
+
"""CSRF, login, and session handling."""
|
|
104
|
+
|
|
105
|
+
def setUp(self):
|
|
106
|
+
self.user = User.objects.create_user(
|
|
107
|
+
username='cli@test.com', email='cli@test.com', password='pass1234'
|
|
108
|
+
)
|
|
109
|
+
self.session = requests.Session()
|
|
110
|
+
|
|
111
|
+
def test_get_csrf_returns_token(self):
|
|
112
|
+
token = api.get_csrf(self.live_server_url, self.session)
|
|
113
|
+
self.assertTrue(len(token) > 0)
|
|
114
|
+
|
|
115
|
+
def test_login_success(self):
|
|
116
|
+
ok = api.login(self.live_server_url, self.session, 'cli@test.com', 'pass1234')
|
|
117
|
+
self.assertTrue(ok)
|
|
118
|
+
|
|
119
|
+
def test_login_wrong_password(self):
|
|
120
|
+
ok = api.login(self.live_server_url, self.session, 'cli@test.com', 'wrong')
|
|
121
|
+
self.assertFalse(ok)
|
|
122
|
+
|
|
123
|
+
def test_login_nonexistent_user(self):
|
|
124
|
+
ok = api.login(self.live_server_url, self.session, 'no@one.com', 'pass')
|
|
125
|
+
self.assertFalse(ok)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
129
|
+
# API – Upload, Get, Delete (anonymous)
|
|
130
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
131
|
+
|
|
132
|
+
@_STATIC_OVERRIDE
|
|
133
|
+
class AnonDropApiTests(LiveServerTestCase):
|
|
134
|
+
"""Upload / get / delete drops as an anonymous user."""
|
|
135
|
+
|
|
136
|
+
def setUp(self):
|
|
137
|
+
self.session = requests.Session()
|
|
138
|
+
self.host = self.live_server_url
|
|
139
|
+
|
|
140
|
+
# ── Text ──────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def test_upload_text(self):
|
|
143
|
+
key = api.upload_text(self.host, self.session, 'hello world')
|
|
144
|
+
self.assertIsNotNone(key)
|
|
145
|
+
self.assertTrue(len(key) > 0)
|
|
146
|
+
|
|
147
|
+
def test_upload_text_custom_key(self):
|
|
148
|
+
key = api.upload_text(self.host, self.session, 'custom!', key='mykey')
|
|
149
|
+
self.assertEqual(key, 'mykey')
|
|
150
|
+
|
|
151
|
+
def test_get_text_drop(self):
|
|
152
|
+
key = api.upload_text(self.host, self.session, 'i can be retrieved')
|
|
153
|
+
kind, content = api.get_drop(self.host, self.session, key)
|
|
154
|
+
self.assertEqual(kind, 'text')
|
|
155
|
+
self.assertEqual(content, 'i can be retrieved')
|
|
156
|
+
|
|
157
|
+
def test_delete_text_drop(self):
|
|
158
|
+
key = api.upload_text(self.host, self.session, 'delete me')
|
|
159
|
+
ok = api.delete(self.host, self.session, key)
|
|
160
|
+
self.assertTrue(ok)
|
|
161
|
+
# should be gone
|
|
162
|
+
self.assertFalse(api.key_exists(self.host, self.session, key))
|
|
163
|
+
|
|
164
|
+
# ── File ──────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def test_upload_file(self):
|
|
167
|
+
with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
|
|
168
|
+
f.write(b'file content here')
|
|
169
|
+
f.flush()
|
|
170
|
+
path = f.name
|
|
171
|
+
try:
|
|
172
|
+
key = api.upload_file(self.host, self.session, path, key='filetest')
|
|
173
|
+
self.assertEqual(key, 'filetest')
|
|
174
|
+
finally:
|
|
175
|
+
os.unlink(path)
|
|
176
|
+
|
|
177
|
+
def test_get_file_drop(self):
|
|
178
|
+
with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
|
|
179
|
+
f.write(b'binary data')
|
|
180
|
+
f.flush()
|
|
181
|
+
path = f.name
|
|
182
|
+
try:
|
|
183
|
+
key = api.upload_file(self.host, self.session, path, key='getfile')
|
|
184
|
+
kind, content = api.get_drop(self.host, self.session, key)
|
|
185
|
+
self.assertEqual(kind, 'file')
|
|
186
|
+
data, filename = content
|
|
187
|
+
self.assertIn(b'binary data', data)
|
|
188
|
+
finally:
|
|
189
|
+
os.unlink(path)
|
|
190
|
+
|
|
191
|
+
# ── Key checks ────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def test_key_exists_true(self):
|
|
194
|
+
api.upload_text(self.host, self.session, 'exists', key='taken')
|
|
195
|
+
self.assertTrue(api.key_exists(self.host, self.session, 'taken'))
|
|
196
|
+
|
|
197
|
+
def test_key_exists_false(self):
|
|
198
|
+
self.assertFalse(api.key_exists(self.host, self.session, 'nope-not-here'))
|
|
199
|
+
|
|
200
|
+
# ── Not found ─────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def test_get_nonexistent_drop(self):
|
|
203
|
+
kind, content = api.get_drop(self.host, self.session, 'no-such-key')
|
|
204
|
+
self.assertIsNone(kind)
|
|
205
|
+
self.assertIsNone(content)
|
|
206
|
+
|
|
207
|
+
def test_delete_nonexistent_drop(self):
|
|
208
|
+
ok = api.delete(self.host, self.session, 'no-such-key')
|
|
209
|
+
self.assertFalse(ok)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
213
|
+
# API – Authenticated drops
|
|
214
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
215
|
+
|
|
216
|
+
@_STATIC_OVERRIDE
|
|
217
|
+
class AuthDropApiTests(LiveServerTestCase):
|
|
218
|
+
"""Upload / get / delete drops as a logged-in user."""
|
|
219
|
+
|
|
220
|
+
def setUp(self):
|
|
221
|
+
self.user = User.objects.create_user(
|
|
222
|
+
username='auth@test.com', email='auth@test.com', password='pass1234'
|
|
223
|
+
)
|
|
224
|
+
self.session = requests.Session()
|
|
225
|
+
self.host = self.live_server_url
|
|
226
|
+
api.login(self.host, self.session, 'auth@test.com', 'pass1234')
|
|
227
|
+
|
|
228
|
+
def test_upload_text_authed(self):
|
|
229
|
+
key = api.upload_text(self.host, self.session, 'logged in text', key='authtext')
|
|
230
|
+
self.assertEqual(key, 'authtext')
|
|
231
|
+
|
|
232
|
+
def test_upload_file_authed(self):
|
|
233
|
+
with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
|
|
234
|
+
f.write(b'auth file')
|
|
235
|
+
f.flush()
|
|
236
|
+
path = f.name
|
|
237
|
+
try:
|
|
238
|
+
key = api.upload_file(self.host, self.session, path, key='authfile')
|
|
239
|
+
self.assertEqual(key, 'authfile')
|
|
240
|
+
finally:
|
|
241
|
+
os.unlink(path)
|
|
242
|
+
|
|
243
|
+
def test_get_text_authed(self):
|
|
244
|
+
api.upload_text(self.host, self.session, 'authed content', key='authget')
|
|
245
|
+
kind, content = api.get_drop(self.host, self.session, 'authget')
|
|
246
|
+
self.assertEqual(kind, 'text')
|
|
247
|
+
self.assertEqual(content, 'authed content')
|
|
248
|
+
|
|
249
|
+
def test_delete_authed(self):
|
|
250
|
+
api.upload_text(self.host, self.session, 'bye', key='authdel')
|
|
251
|
+
ok = api.delete(self.host, self.session, 'authdel')
|
|
252
|
+
self.assertTrue(ok)
|
|
253
|
+
self.assertFalse(api.key_exists(self.host, self.session, 'authdel'))
|
|
254
|
+
|
|
255
|
+
def test_locked_drop_not_deletable_by_anon(self):
|
|
256
|
+
"""A locked drop can't be deleted from a fresh (anonymous) session."""
|
|
257
|
+
from core.models import Drop
|
|
258
|
+
api.upload_text(self.host, self.session, 'locked', key='locktest')
|
|
259
|
+
Drop.objects.filter(key='locktest').update(locked=True)
|
|
260
|
+
anon = requests.Session()
|
|
261
|
+
ok = api.delete(self.host, anon, 'locktest')
|
|
262
|
+
self.assertFalse(ok)
|
|
263
|
+
|
|
264
|
+
def test_rename_drop(self):
|
|
265
|
+
api.upload_text(self.host, self.session, 'rename me', key='oldname')
|
|
266
|
+
new_key = api.rename(self.host, self.session, 'oldname', 'newname')
|
|
267
|
+
self.assertEqual(new_key, 'newname')
|
|
268
|
+
# old key gone, new key exists
|
|
269
|
+
self.assertFalse(api.key_exists(self.host, self.session, 'oldname'))
|
|
270
|
+
self.assertTrue(api.key_exists(self.host, self.session, 'newname'))
|
|
271
|
+
|
|
272
|
+
def test_rename_to_taken_key_fails(self):
|
|
273
|
+
api.upload_text(self.host, self.session, 'first', key='existing')
|
|
274
|
+
api.upload_text(self.host, self.session, 'second', key='movethis')
|
|
275
|
+
result = api.rename(self.host, self.session, 'movethis', 'existing')
|
|
276
|
+
self.assertIsNone(result)
|
|
277
|
+
|
|
278
|
+
def test_renew_drop(self):
|
|
279
|
+
"""Paid drops can be renewed."""
|
|
280
|
+
from core.models import Drop, Plan
|
|
281
|
+
from django.utils import timezone
|
|
282
|
+
from datetime import timedelta
|
|
283
|
+
self.user.profile.plan = Plan.STARTER
|
|
284
|
+
self.user.profile.save()
|
|
285
|
+
api.upload_text(self.host, self.session, 'renew me', key='renewtest')
|
|
286
|
+
# Manually set expires_at to something in the near future
|
|
287
|
+
drop = Drop.objects.get(key='renewtest')
|
|
288
|
+
drop.expires_at = timezone.now() + timedelta(days=1)
|
|
289
|
+
drop.save()
|
|
290
|
+
expires_at, renewals = api.renew(self.host, self.session, 'renewtest')
|
|
291
|
+
self.assertIsNotNone(expires_at)
|
|
292
|
+
self.assertEqual(renewals, 1)
|
|
293
|
+
|
|
294
|
+
def test_renew_anon_drop_fails(self):
|
|
295
|
+
"""Anon drops (no expires_at) cannot be renewed."""
|
|
296
|
+
anon = requests.Session()
|
|
297
|
+
api.upload_text(self.host, anon, 'no renew', key='anonrenew')
|
|
298
|
+
expires_at, _ = api.renew(self.host, self.session, 'anonrenew')
|
|
299
|
+
self.assertIsNone(expires_at)
|
|
300
|
+
|
|
301
|
+
def test_list_drops(self):
|
|
302
|
+
api.upload_text(self.host, self.session, 'list item 1', key='list1')
|
|
303
|
+
api.upload_text(self.host, self.session, 'list item 2', key='list2')
|
|
304
|
+
drops = api.list_drops(self.host, self.session)
|
|
305
|
+
self.assertIsNotNone(drops)
|
|
306
|
+
keys = [d['key'] for d in drops]
|
|
307
|
+
self.assertIn('list1', keys)
|
|
308
|
+
self.assertIn('list2', keys)
|
|
309
|
+
|
|
310
|
+
def test_list_drops_anon_fails(self):
|
|
311
|
+
"""Anonymous session can't list drops (requires login)."""
|
|
312
|
+
anon = requests.Session()
|
|
313
|
+
drops = api.list_drops(self.host, anon)
|
|
314
|
+
self.assertIsNone(drops)
|
|
315
|
+
|
|
316
|
+
def test_list_drops_empty(self):
|
|
317
|
+
drops = api.list_drops(self.host, self.session)
|
|
318
|
+
self.assertIsNotNone(drops)
|
|
319
|
+
self.assertEqual(len(drops), 0)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
323
|
+
# Version
|
|
324
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
325
|
+
|
|
326
|
+
class VersionTests(LiveServerTestCase):
|
|
327
|
+
"""__version__ is importable and looks like semver."""
|
|
328
|
+
|
|
329
|
+
def test_version_format(self):
|
|
330
|
+
from cli import __version__
|
|
331
|
+
parts = __version__.split('.')
|
|
332
|
+
self.assertEqual(len(parts), 3)
|
|
333
|
+
for p in parts:
|
|
334
|
+
self.assertTrue(p.isdigit())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drp-cli
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Drop text and files from the command line — get a link instantly.
|
|
5
|
+
Author: Vic Nas
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://drp.vicnas.me
|
|
8
|
+
Project-URL: Repository, https://github.com/vicnasdev/drp
|
|
9
|
+
Project-URL: Issues, https://github.com/vicnasdev/drp/issues
|
|
10
|
+
Keywords: cli,file-sharing,pastebin,drops
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: requests>=2.28
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# drp
|
|
27
|
+
|
|
28
|
+
Drop text and files from the command line — get a link instantly.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install drp-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
drp setup # configure host & log in
|
|
38
|
+
drp up notes.txt # upload a file → prints URL
|
|
39
|
+
drp up "hello world" # upload text → prints URL
|
|
40
|
+
drp up doc.pdf -k cv # upload with a custom key
|
|
41
|
+
drp get mykey # text → stdout, file → saved to disk
|
|
42
|
+
drp get mykey -o a.txt # save file with custom name
|
|
43
|
+
drp rm mykey # delete a drop
|
|
44
|
+
drp mv mykey newkey # rename a drop
|
|
45
|
+
drp renew mykey # renew a drop's expiry (paid)
|
|
46
|
+
drp ls # list your drops (requires login)
|
|
47
|
+
drp status # show config
|
|
48
|
+
drp --version # show version
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
1. `drp setup` saves your host URL (default: `https://drp.vicnas.me`) and optionally logs you in
|
|
54
|
+
2. `drp up` uploads a file or text string and prints the shareable URL
|
|
55
|
+
3. `drp get` retrieves a drop — text is printed to stdout, files are saved to disk
|
|
56
|
+
4. Works anonymously or logged in — logged-in users get locked drops, longer expiry, and `drp ls`
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Config is stored at `~/.config/drp/config.json`:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"host": "https://drp.vicnas.me",
|
|
65
|
+
"email": "you@example.com"
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Self-hosted
|
|
70
|
+
|
|
71
|
+
Point the CLI at your own instance:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
drp setup
|
|
75
|
+
# enter your server URL when prompted
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See the [server repo](https://github.com/vicnasdev/drp) for deployment instructions.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CLI.md
|
|
2
|
+
LICENSE
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
cli/__init__.py
|
|
6
|
+
cli/api.py
|
|
7
|
+
cli/config.py
|
|
8
|
+
cli/drp.py
|
|
9
|
+
cli/tests.py
|
|
10
|
+
drp_cli.egg-info/PKG-INFO
|
|
11
|
+
drp_cli.egg-info/SOURCES.txt
|
|
12
|
+
drp_cli.egg-info/dependency_links.txt
|
|
13
|
+
drp_cli.egg-info/entry_points.txt
|
|
14
|
+
drp_cli.egg-info/requires.txt
|
|
15
|
+
drp_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "drp-cli"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Drop text and files from the command line — get a link instantly."
|
|
9
|
+
readme = "CLI.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["requests>=2.28"]
|
|
13
|
+
authors = [{name = "Vic Nas"}]
|
|
14
|
+
keywords = ["cli", "file-sharing", "pastebin", "drops"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://drp.vicnas.me"
|
|
29
|
+
Repository = "https://github.com/vicnasdev/drp"
|
|
30
|
+
Issues = "https://github.com/vicnasdev/drp/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
drp = "cli.drp:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.dynamic]
|
|
36
|
+
version = {attr = "cli.__version__"}
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
include = ["cli*"]
|
drp_cli-0.1.3/setup.cfg
ADDED