clipto 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of clipto might be problematic. Click here for more details.
- clipto/__init__.py +4 -0
- clipto/__main__.py +4 -0
- clipto/cli.py +174 -0
- clipto/server.py +186 -0
- clipto/utils.py +99 -0
- clipto/web/app.js +386 -0
- clipto/web/index.html +124 -0
- clipto/web/style.css +616 -0
- clipto-0.1.0.dist-info/METADATA +151 -0
- clipto-0.1.0.dist-info/RECORD +13 -0
- clipto-0.1.0.dist-info/WHEEL +4 -0
- clipto-0.1.0.dist-info/entry_points.txt +2 -0
- clipto-0.1.0.dist-info/licenses/LICENSE +21 -0
clipto/__init__.py
ADDED
clipto/__main__.py
ADDED
clipto/cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from clipto import __version__
|
|
11
|
+
from clipto.server import CliptoHTTPServer, CliptoRequestHandler
|
|
12
|
+
from clipto.utils import (
|
|
13
|
+
find_available_port,
|
|
14
|
+
get_local_ip,
|
|
15
|
+
get_tailscale_ip,
|
|
16
|
+
terminal_hyperlink,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="clipto",
|
|
23
|
+
description="Instant clipboard, screenshot, and file bridge from browser to terminal.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-v", "--version",
|
|
27
|
+
action="version",
|
|
28
|
+
version=f"%(prog)s {__version__}",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-p", "--port",
|
|
32
|
+
type=int,
|
|
33
|
+
default=8765,
|
|
34
|
+
help="Port to listen on (default: 8765, auto-increments if occupied; use 0 for random).",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--no-hunt",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Disable automatic port hunting if specified port is in use.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"-d", "--dir",
|
|
43
|
+
type=Path,
|
|
44
|
+
default=Path("."),
|
|
45
|
+
help="Target directory where files will be saved (default: current directory).",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"-1", "--once",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="One-shot mode: exit immediately after receiving an upload and print saved file path(s).",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"-t", "--title",
|
|
54
|
+
type=str,
|
|
55
|
+
default=None,
|
|
56
|
+
help="Custom session title or prompt shown in the web UI header.",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"-o", "--open",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Automatically open the web UI in the default browser on launch.",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--host",
|
|
65
|
+
type=str,
|
|
66
|
+
default="0.0.0.0",
|
|
67
|
+
help="Host address to bind to (default: 0.0.0.0).",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--timeout",
|
|
71
|
+
type=int,
|
|
72
|
+
default=None,
|
|
73
|
+
help="Timeout in seconds (useful with --once to prevent hanging indefinitely).",
|
|
74
|
+
)
|
|
75
|
+
return parser
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def print_banner(
|
|
79
|
+
port: int,
|
|
80
|
+
target_dir: Path,
|
|
81
|
+
title: Optional[str] = None,
|
|
82
|
+
once: bool = False,
|
|
83
|
+
):
|
|
84
|
+
local_url = f"http://localhost:{port}"
|
|
85
|
+
lan_ip = get_local_ip()
|
|
86
|
+
tailscale_ip = get_tailscale_ip()
|
|
87
|
+
|
|
88
|
+
lines = [
|
|
89
|
+
f"š Clipto v{__version__}" + (" [ONE-SHOT MODE]" if once else ""),
|
|
90
|
+
f"Saving to: {target_dir.resolve()}",
|
|
91
|
+
]
|
|
92
|
+
if title:
|
|
93
|
+
lines.append(f"Session: {title}")
|
|
94
|
+
|
|
95
|
+
lines.append(f"Local: {terminal_hyperlink(local_url)}")
|
|
96
|
+
if lan_ip and lan_ip != "127.0.0.1":
|
|
97
|
+
lines.append(f"Network: {terminal_hyperlink(f'http://{lan_ip}:{port}')}")
|
|
98
|
+
if tailscale_ip:
|
|
99
|
+
lines.append(f"Tailscale: {terminal_hyperlink(f'http://{tailscale_ip}:{port}')}")
|
|
100
|
+
|
|
101
|
+
max_len = max(len(line) for line in lines)
|
|
102
|
+
border = "ā" * (max_len + 4)
|
|
103
|
+
|
|
104
|
+
print(f"\nā{border}ā", file=sys.stderr)
|
|
105
|
+
for line in lines:
|
|
106
|
+
padding = " " * (max_len - len(line))
|
|
107
|
+
print(f"ā {line}{padding} ā", file=sys.stderr)
|
|
108
|
+
print(f"ā{border}ā\n", file=sys.stderr)
|
|
109
|
+
print("Press Cmd+V or drag files into the browser tab. Press Ctrl+C to stop.\n", file=sys.stderr)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main():
|
|
113
|
+
parser = build_parser()
|
|
114
|
+
args = parser.parse_args()
|
|
115
|
+
|
|
116
|
+
target_dir = args.dir.resolve()
|
|
117
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
# Determine port
|
|
120
|
+
if args.no_hunt:
|
|
121
|
+
port = args.port
|
|
122
|
+
else:
|
|
123
|
+
try:
|
|
124
|
+
port = find_available_port(args.port)
|
|
125
|
+
except RuntimeError as e:
|
|
126
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
server = CliptoHTTPServer(
|
|
131
|
+
(args.host, port),
|
|
132
|
+
CliptoRequestHandler,
|
|
133
|
+
upload_dir=target_dir,
|
|
134
|
+
title=args.title,
|
|
135
|
+
once=args.once,
|
|
136
|
+
)
|
|
137
|
+
except OSError as e:
|
|
138
|
+
print(f"Error binding to port {port}: {e}", file=sys.stderr)
|
|
139
|
+
sys.exit(1)
|
|
140
|
+
|
|
141
|
+
print_banner(port, target_dir, title=args.title, once=args.once)
|
|
142
|
+
|
|
143
|
+
if args.open:
|
|
144
|
+
webbrowser.open(f"http://localhost:{port}")
|
|
145
|
+
|
|
146
|
+
# Start server thread
|
|
147
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
148
|
+
server_thread.start()
|
|
149
|
+
|
|
150
|
+
start_time = time.time()
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
while not server.shutdown_event.is_set():
|
|
154
|
+
if args.timeout and (time.time() - start_time) > args.timeout:
|
|
155
|
+
print("\nOperation timed out.", file=sys.stderr)
|
|
156
|
+
server.shutdown()
|
|
157
|
+
sys.exit(124)
|
|
158
|
+
time.sleep(0.1)
|
|
159
|
+
|
|
160
|
+
# Shutdown triggered (in once mode)
|
|
161
|
+
server.shutdown()
|
|
162
|
+
|
|
163
|
+
# In once mode, output the uploaded file path(s) to stdout
|
|
164
|
+
for file_path in server.uploaded_files:
|
|
165
|
+
print(str(file_path.resolve()))
|
|
166
|
+
|
|
167
|
+
except KeyboardInterrupt:
|
|
168
|
+
print("\nStopping Clipto server...", file=sys.stderr)
|
|
169
|
+
server.shutdown()
|
|
170
|
+
sys.exit(0)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
clipto/server.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import mimetypes
|
|
3
|
+
import socket
|
|
4
|
+
import threading
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from email.parser import BytesParser
|
|
7
|
+
from email.policy import default
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from clipto.utils import get_unique_path, sanitize_filename
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_web_dir() -> Path:
|
|
16
|
+
"""Locate the web assets directory."""
|
|
17
|
+
try:
|
|
18
|
+
import importlib.resources as pkg_resources
|
|
19
|
+
return Path(str(pkg_resources.files("clipto").joinpath("web")))
|
|
20
|
+
except Exception:
|
|
21
|
+
return Path(__file__).parent / "web"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CliptoHTTPServer(ThreadingHTTPServer):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
server_address,
|
|
28
|
+
RequestHandlerClass,
|
|
29
|
+
upload_dir: Path,
|
|
30
|
+
title: Optional[str] = None,
|
|
31
|
+
once: bool = False,
|
|
32
|
+
):
|
|
33
|
+
super().__init__(server_address, RequestHandlerClass)
|
|
34
|
+
self.upload_dir = Path(upload_dir).resolve()
|
|
35
|
+
self.upload_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self.title = title
|
|
37
|
+
self.once = once
|
|
38
|
+
self.uploaded_files: List[Path] = []
|
|
39
|
+
self.shutdown_event = threading.Event()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CliptoRequestHandler(BaseHTTPRequestHandler):
|
|
43
|
+
server: CliptoHTTPServer
|
|
44
|
+
|
|
45
|
+
def log_message(self, format, *args):
|
|
46
|
+
# Suppress verbose standard logging to keep terminal clean
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def send_json(self, status: int, data: dict):
|
|
50
|
+
body = json.dumps(data).encode("utf-8")
|
|
51
|
+
self.send_response(status)
|
|
52
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
53
|
+
self.send_header("Content-Length", str(len(body)))
|
|
54
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
55
|
+
self.end_headers()
|
|
56
|
+
self.wfile.write(body)
|
|
57
|
+
|
|
58
|
+
def serve_file(self, file_path: Path):
|
|
59
|
+
if not file_path.is_file():
|
|
60
|
+
self.send_error(404, "File Not Found")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
64
|
+
if file_path.suffix == ".css":
|
|
65
|
+
content_type = "text/css"
|
|
66
|
+
elif file_path.suffix == ".js":
|
|
67
|
+
content_type = "application/javascript"
|
|
68
|
+
|
|
69
|
+
content = file_path.read_bytes()
|
|
70
|
+
self.send_response(200)
|
|
71
|
+
self.send_header("Content-Type", content_type or "application/octet-stream")
|
|
72
|
+
self.send_header("Content-Length", str(len(content)))
|
|
73
|
+
self.end_headers()
|
|
74
|
+
self.wfile.write(content)
|
|
75
|
+
|
|
76
|
+
def do_GET(self):
|
|
77
|
+
web_dir = get_web_dir()
|
|
78
|
+
parsed_url = urllib.parse.urlparse(self.path)
|
|
79
|
+
path = parsed_url.path
|
|
80
|
+
|
|
81
|
+
if path in ("/", "/index.html"):
|
|
82
|
+
self.serve_file(web_dir / "index.html")
|
|
83
|
+
elif path == "/static/style.css":
|
|
84
|
+
self.serve_file(web_dir / "style.css")
|
|
85
|
+
elif path == "/static/app.js":
|
|
86
|
+
self.serve_file(web_dir / "app.js")
|
|
87
|
+
elif path == "/api/info":
|
|
88
|
+
self.send_json(200, {
|
|
89
|
+
"hostname": socket.gethostname(),
|
|
90
|
+
"dir": str(self.server.upload_dir),
|
|
91
|
+
"title": self.server.title,
|
|
92
|
+
"once": self.server.once,
|
|
93
|
+
})
|
|
94
|
+
elif path == "/api/file":
|
|
95
|
+
query = urllib.parse.parse_qs(parsed_url.query)
|
|
96
|
+
filename = query.get("name", [None])[0]
|
|
97
|
+
if not filename:
|
|
98
|
+
self.send_error(400, "Missing file name")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
safe_name = sanitize_filename(filename)
|
|
102
|
+
target = (self.server.upload_dir / safe_name).resolve()
|
|
103
|
+
|
|
104
|
+
# Prevent directory traversal attacks
|
|
105
|
+
try:
|
|
106
|
+
target.relative_to(self.server.upload_dir)
|
|
107
|
+
except ValueError:
|
|
108
|
+
self.send_error(403, "Forbidden")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if not target.is_file():
|
|
112
|
+
self.send_error(404, "File Not Found")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
self.serve_file(target)
|
|
116
|
+
elif path == "/api/health":
|
|
117
|
+
self.send_json(200, {"status": "ok"})
|
|
118
|
+
else:
|
|
119
|
+
self.send_error(404, "Not Found")
|
|
120
|
+
|
|
121
|
+
def do_POST(self):
|
|
122
|
+
if self.path != "/api/upload":
|
|
123
|
+
self.send_error(404, "Not Found")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
content_type = self.headers.get("Content-Type", "")
|
|
127
|
+
if not content_type.startswith("multipart/form-data"):
|
|
128
|
+
self.send_error(400, "Content-Type must be multipart/form-data")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
133
|
+
if content_length <= 0:
|
|
134
|
+
self.send_error(400, "Invalid Content-Length")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
body = self.rfile.read(content_length)
|
|
138
|
+
raw_data = f"Content-Type: {content_type}\r\n\r\n".encode("utf-8") + body
|
|
139
|
+
msg = BytesParser(policy=default).parsebytes(raw_data)
|
|
140
|
+
|
|
141
|
+
saved_results = []
|
|
142
|
+
for part in msg.iter_parts():
|
|
143
|
+
filename = part.get_filename()
|
|
144
|
+
if not filename:
|
|
145
|
+
# Check for note or text parameter
|
|
146
|
+
name = part.get_param("name", header="content-disposition")
|
|
147
|
+
if name == "note":
|
|
148
|
+
text = part.get_payload(decode=True).decode("utf-8", errors="replace").strip()
|
|
149
|
+
if text:
|
|
150
|
+
target_path = get_unique_path(self.server.upload_dir, "note.txt")
|
|
151
|
+
target_path.write_text(text, encoding="utf-8")
|
|
152
|
+
saved_results.append({
|
|
153
|
+
"name": target_path.name,
|
|
154
|
+
"path": str(target_path.resolve()),
|
|
155
|
+
"size": target_path.stat().st_size,
|
|
156
|
+
"is_image": False,
|
|
157
|
+
})
|
|
158
|
+
self.server.uploaded_files.append(target_path)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
clean_name = sanitize_filename(filename, default_name="upload.bin")
|
|
162
|
+
target_path = get_unique_path(self.server.upload_dir, clean_name)
|
|
163
|
+
payload = part.get_payload(decode=True)
|
|
164
|
+
|
|
165
|
+
if payload is not None:
|
|
166
|
+
target_path.write_bytes(payload)
|
|
167
|
+
is_img = target_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
|
|
168
|
+
saved_results.append({
|
|
169
|
+
"name": target_path.name,
|
|
170
|
+
"path": str(target_path.resolve()),
|
|
171
|
+
"size": target_path.stat().st_size,
|
|
172
|
+
"is_image": is_img,
|
|
173
|
+
})
|
|
174
|
+
self.server.uploaded_files.append(target_path)
|
|
175
|
+
|
|
176
|
+
self.send_json(200, {
|
|
177
|
+
"status": "ok",
|
|
178
|
+
"saved_files": saved_results,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
if self.server.once and self.server.uploaded_files:
|
|
182
|
+
# Trigger shutdown cleanly in a short delay so the response finishes sending
|
|
183
|
+
threading.Timer(0.3, self.server.shutdown_event.set).start()
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
self.send_json(500, {"error": str(e)})
|
clipto/utils.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import socket
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_available_port(preferred_port: int = 8765, max_attempts: int = 50) -> int:
|
|
11
|
+
"""
|
|
12
|
+
Find an available TCP port.
|
|
13
|
+
If preferred_port is 0, let the OS allocate an ephemeral port.
|
|
14
|
+
Otherwise, check preferred_port up to preferred_port + max_attempts.
|
|
15
|
+
"""
|
|
16
|
+
if preferred_port == 0:
|
|
17
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
18
|
+
s.bind(("", 0))
|
|
19
|
+
return s.getsockname()[1]
|
|
20
|
+
|
|
21
|
+
for port in range(preferred_port, preferred_port + max_attempts):
|
|
22
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
23
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
24
|
+
try:
|
|
25
|
+
s.bind(("", port))
|
|
26
|
+
return port
|
|
27
|
+
except OSError:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
raise RuntimeError(f"No available port found in range {preferred_port} - {preferred_port + max_attempts}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_local_ip() -> Optional[str]:
|
|
34
|
+
"""Determine the LAN IP address of this machine."""
|
|
35
|
+
try:
|
|
36
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
37
|
+
s.settimeout(0.5)
|
|
38
|
+
# Doesn't actually send packets, but prompts OS to select route
|
|
39
|
+
s.connect(("8.8.8.8", 80))
|
|
40
|
+
return s.getsockname()[0]
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_tailscale_ip() -> Optional[str]:
|
|
46
|
+
"""Detect Tailscale IPv4 if available."""
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["tailscale", "ip", "-4"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=1,
|
|
53
|
+
check=False,
|
|
54
|
+
)
|
|
55
|
+
if result.returncode == 0:
|
|
56
|
+
ip = result.stdout.strip().split("\n")[0]
|
|
57
|
+
if ip.startswith("100."):
|
|
58
|
+
return ip
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sanitize_filename(filename: str, default_name: str = "upload") -> str:
|
|
65
|
+
"""Sanitize uploaded filenames to prevent path traversal and unsafe characters."""
|
|
66
|
+
filename = os.path.basename(filename).strip()
|
|
67
|
+
# Remove null bytes and control chars
|
|
68
|
+
filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
|
|
69
|
+
# Replace unsafe characters
|
|
70
|
+
filename = re.sub(r'[\\/:*?"<>|]', "_", filename)
|
|
71
|
+
return filename or default_name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_unique_path(directory: Path, filename: str) -> Path:
|
|
75
|
+
"""
|
|
76
|
+
Ensure the path does not overwrite an existing file.
|
|
77
|
+
e.g., photo.png -> photo (1).png -> photo (2).png
|
|
78
|
+
"""
|
|
79
|
+
target = directory / filename
|
|
80
|
+
if not target.exists():
|
|
81
|
+
return target
|
|
82
|
+
|
|
83
|
+
stem = target.stem
|
|
84
|
+
suffix = target.suffix
|
|
85
|
+
counter = 1
|
|
86
|
+
|
|
87
|
+
while True:
|
|
88
|
+
candidate = directory / f"{stem} ({counter}){suffix}"
|
|
89
|
+
if not candidate.exists():
|
|
90
|
+
return candidate
|
|
91
|
+
counter += 1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def terminal_hyperlink(url: str, text: Optional[str] = None) -> str:
|
|
95
|
+
"""Return an OSC 8 terminal hyperlink if terminal is interactive."""
|
|
96
|
+
display_text = text or url
|
|
97
|
+
if sys.stdout.isatty():
|
|
98
|
+
return f"\033]8;;{url}\033\\{display_text}\033]8;;\033\\"
|
|
99
|
+
return display_text
|