qrdrop 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.
Files changed (51) hide show
  1. qrdrop/__init__.py +3 -0
  2. qrdrop/__main__.py +6 -0
  3. qrdrop/cli.py +164 -0
  4. qrdrop/core/__init__.py +1 -0
  5. qrdrop/core/filesystem.py +331 -0
  6. qrdrop/core/filetypes.py +367 -0
  7. qrdrop/core/log_redaction.py +50 -0
  8. qrdrop/core/network.py +72 -0
  9. qrdrop/core/password.py +49 -0
  10. qrdrop/core/qr.py +65 -0
  11. qrdrop/core/session.py +110 -0
  12. qrdrop/core/terminal.py +158 -0
  13. qrdrop/core/wordlist.py +1047 -0
  14. qrdrop/static/app.js +1098 -0
  15. qrdrop/static/style.css +1504 -0
  16. qrdrop/static/vendor/highlight/LICENSE +29 -0
  17. qrdrop/static/vendor/highlight/highlight.min.js +1213 -0
  18. qrdrop/static/vendor/highlight/languages/cmake.min.js +7 -0
  19. qrdrop/static/vendor/highlight/languages/dockerfile.min.js +8 -0
  20. qrdrop/static/vendor/highlight/languages/fsharp.min.js +47 -0
  21. qrdrop/static/vendor/highlight/languages/groovy.min.js +21 -0
  22. qrdrop/static/vendor/highlight/languages/latex.min.js +33 -0
  23. qrdrop/static/vendor/highlight/languages/powershell.min.js +39 -0
  24. qrdrop/static/vendor/highlight/languages/properties.min.js +10 -0
  25. qrdrop/static/vendor/highlight/languages/scala.min.js +28 -0
  26. qrdrop/static/vendor/highlight/languages/vim.min.js +12 -0
  27. qrdrop/static/vendor/highlight/styles/github-dark.min.css +10 -0
  28. qrdrop/static/vendor/highlight/styles/github.min.css +10 -0
  29. qrdrop/templates/base.html +52 -0
  30. qrdrop/templates/browse.html +224 -0
  31. qrdrop/templates/login.html +45 -0
  32. qrdrop/templates/view_image.html +39 -0
  33. qrdrop/templates/view_text.html +60 -0
  34. qrdrop/web/__init__.py +1 -0
  35. qrdrop/web/app.py +50 -0
  36. qrdrop/web/handlers/__init__.py +1 -0
  37. qrdrop/web/handlers/_common.py +104 -0
  38. qrdrop/web/handlers/archive.py +293 -0
  39. qrdrop/web/handlers/auth.py +129 -0
  40. qrdrop/web/handlers/browse.py +160 -0
  41. qrdrop/web/handlers/files.py +266 -0
  42. qrdrop/web/handlers/health.py +16 -0
  43. qrdrop/web/handlers/mutate.py +195 -0
  44. qrdrop/web/handlers/upload.py +398 -0
  45. qrdrop/web/middleware.py +180 -0
  46. qrdrop/web/routes.py +53 -0
  47. qrdrop-0.1.0.dist-info/METADATA +213 -0
  48. qrdrop-0.1.0.dist-info/RECORD +51 -0
  49. qrdrop-0.1.0.dist-info/WHEEL +4 -0
  50. qrdrop-0.1.0.dist-info/entry_points.txt +2 -0
  51. qrdrop-0.1.0.dist-info/licenses/LICENSE +21 -0
qrdrop/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """QRDrop - Instant file sharing from your terminal."""
2
+
3
+ __version__ = "0.1.0"
qrdrop/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for uvx/python -m qrdrop."""
2
+
3
+ from qrdrop.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
qrdrop/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ """Command-line interface for qrdrop."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from qrdrop import __version__
7
+
8
+
9
+ def parse_args() -> argparse.Namespace:
10
+ """Parse command line arguments."""
11
+ parser = argparse.ArgumentParser(
12
+ prog="qrdrop",
13
+ description="Instant file sharing from your terminal",
14
+ )
15
+ parser.add_argument(
16
+ "-p",
17
+ "--port",
18
+ type=int,
19
+ default=8000,
20
+ help="Port to serve on (default: 8000)",
21
+ )
22
+ parser.add_argument(
23
+ "-b",
24
+ "--bind",
25
+ type=str,
26
+ default="0.0.0.0",
27
+ help="Address to bind to (default: 0.0.0.0)",
28
+ )
29
+ parser.add_argument(
30
+ "--password",
31
+ type=str,
32
+ default=None,
33
+ help="Use specific password instead of generating one",
34
+ )
35
+ parser.add_argument(
36
+ "--hide-dotfiles",
37
+ action="store_true",
38
+ help="Exclude files starting with '.' from listings",
39
+ )
40
+ parser.add_argument(
41
+ "--upload",
42
+ action="store_true",
43
+ help="Allow file uploads",
44
+ )
45
+ parser.add_argument(
46
+ "--modify",
47
+ action="store_true",
48
+ help="Allow uploads, deletions, and directory create/rename (implies --upload)",
49
+ )
50
+ parser.add_argument(
51
+ "--timeout",
52
+ type=int,
53
+ default=None,
54
+ help="Expire sessions after this many seconds (default: sessions last until the server stops)",
55
+ )
56
+ parser.add_argument(
57
+ "-q",
58
+ "--quiet",
59
+ action="store_true",
60
+ help="Suppress startup banner",
61
+ )
62
+ parser.add_argument(
63
+ "--version",
64
+ action="version",
65
+ version=f"qrdrop {__version__}",
66
+ )
67
+ args = parser.parse_args()
68
+ if args.timeout is not None and args.timeout <= 0:
69
+ parser.error("--timeout must be a positive number of seconds")
70
+ return args
71
+
72
+
73
+ def main() -> None:
74
+ """Main entry point for the CLI."""
75
+ import os
76
+
77
+ args = parse_args()
78
+
79
+ # Import here to avoid circular imports and speed up --help/--version
80
+ from qrdrop.core.network import find_available_port, get_local_ip
81
+ from qrdrop.core.password import generate_password
82
+ from qrdrop.core.qr import generate_qr_terminal
83
+ from qrdrop.core.terminal import print_startup_banner
84
+ from qrdrop.web.app import AppConfig, create_app
85
+
86
+ # Get root directory
87
+ root_dir = Path.cwd()
88
+
89
+ # In Docker, use the exact port specified (don't auto-increment)
90
+ # Outside Docker, find an available port
91
+ port = args.port if os.environ.get("DOCKER_CONTAINER") else find_available_port(args.port)
92
+
93
+ # Generate or use provided password
94
+ password = args.password if args.password else generate_password()
95
+
96
+ # Determine permissions (--modify implies --upload)
97
+ allow_upload = args.upload or args.modify
98
+ allow_delete = args.modify
99
+ allow_modify = args.modify
100
+
101
+ # Create configuration
102
+ config = AppConfig(
103
+ root_dir=root_dir,
104
+ password=password,
105
+ port=port,
106
+ bind=args.bind,
107
+ show_hidden=not args.hide_dotfiles,
108
+ session_timeout=args.timeout,
109
+ allow_upload=allow_upload,
110
+ allow_delete=allow_delete,
111
+ allow_modify=allow_modify,
112
+ )
113
+
114
+ # Create app
115
+ app = create_app(config)
116
+
117
+ # Print startup banner
118
+ if not args.quiet:
119
+ from urllib.parse import quote
120
+
121
+ local_ip = get_local_ip()
122
+ local_url = f"http://localhost:{port}"
123
+ network_url = f"http://{local_ip}:{port}"
124
+ # Percent-encode so a --password containing URL-reserved characters
125
+ # (&, #, +, spaces, ...) survives the QR auto-login round trip.
126
+ auth_url = f"http://{local_ip}:{port}/?auth={quote(password, safe='')}"
127
+
128
+ qr_code = generate_qr_terminal(auth_url)
129
+
130
+ print_startup_banner(
131
+ version=__version__,
132
+ local_url=local_url,
133
+ network_url=network_url,
134
+ password=password,
135
+ qr_code=qr_code,
136
+ root_dir=str(root_dir),
137
+ )
138
+
139
+ # Start server
140
+ import copy
141
+
142
+ import uvicorn
143
+ from uvicorn.config import LOGGING_CONFIG
144
+
145
+ # Wire a redaction filter into uvicorn's logging config so the
146
+ # `?auth=<password>` query parameter never leaks into access logs.
147
+ log_config = copy.deepcopy(LOGGING_CONFIG)
148
+ log_config.setdefault("filters", {})["redact_auth"] = {
149
+ "()": "qrdrop.core.log_redaction.RedactAuthFilter",
150
+ }
151
+ for handler in log_config.get("handlers", {}).values():
152
+ handler.setdefault("filters", []).append("redact_auth")
153
+
154
+ uvicorn.run(
155
+ app,
156
+ host=args.bind,
157
+ port=port,
158
+ log_level="warning" if args.quiet else "info",
159
+ log_config=log_config,
160
+ )
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
@@ -0,0 +1 @@
1
+ """Core business logic modules."""
@@ -0,0 +1,331 @@
1
+ """Secure filesystem operations with path traversal protection.
2
+
3
+ All file operations must go through these functions to ensure
4
+ proper security boundaries are maintained.
5
+ """
6
+
7
+ import os
8
+ import stat
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from threading import Lock
13
+ from typing import NamedTuple
14
+
15
+
16
+ class PathTraversalError(Exception):
17
+ """Raised when a path attempts to escape the root directory."""
18
+
19
+ pass
20
+
21
+
22
+ class SymlinkEscapeError(Exception):
23
+ """Raised when a symlink resolves to a location outside root."""
24
+
25
+ pass
26
+
27
+
28
+ # Size units tuple - module-level to avoid recreation on each call
29
+ _SIZE_UNITS: tuple[tuple[str, int], ...] = (
30
+ ("GB", 1024 * 1024 * 1024),
31
+ ("MB", 1024 * 1024),
32
+ ("KB", 1024),
33
+ ("B", 1),
34
+ )
35
+
36
+ # Cache for resolved root paths to avoid repeated resolution.
37
+ # Stores (Path, str) so str(root) doesn't have to be recomputed on every
38
+ # validate_path call.
39
+ _root_cache: dict[str, tuple[Path, str]] = {}
40
+ _root_cache_lock = Lock()
41
+
42
+
43
+ class _CacheEntry(NamedTuple):
44
+ """Cache entry for directory listings."""
45
+
46
+ mtime: float
47
+ entries: tuple # Immutable tuple of FileEntry
48
+
49
+
50
+ # LRU cache for directory listings
51
+ _dir_cache: dict[tuple[str, bool], _CacheEntry] = {}
52
+ _dir_cache_lock = Lock()
53
+ _DIR_CACHE_MAX_SIZE = 256
54
+
55
+
56
+ def _get_resolved_root_cached(root: Path) -> tuple[Path, str]:
57
+ """Get cached (resolved_path, resolved_str) tuple for `root`."""
58
+ key = str(root)
59
+ cached = _root_cache.get(key)
60
+ if cached is not None:
61
+ return cached
62
+ with _root_cache_lock:
63
+ cached = _root_cache.get(key)
64
+ if cached is not None:
65
+ return cached
66
+ resolved = root.resolve()
67
+ cached = (resolved, str(resolved))
68
+ _root_cache[key] = cached
69
+ return cached
70
+
71
+
72
+ def _is_under_root_str(root_str: str, target_str: str) -> bool:
73
+ """String-only containment check; avoids re-resolving already-resolved paths.
74
+
75
+ Uses string-prefix comparison rather than `Path.relative_to` so the result
76
+ is consistent across platforms (Windows uses both "/" and "\\" as separators
77
+ in resolved paths).
78
+ """
79
+ return (
80
+ target_str == root_str
81
+ or target_str.startswith(root_str + "/")
82
+ or target_str.startswith(root_str + "\\")
83
+ )
84
+
85
+
86
+ @dataclass(slots=True)
87
+ class FileEntry:
88
+ """Represents a file or directory entry.
89
+
90
+ Uses __slots__ for memory efficiency.
91
+ """
92
+
93
+ name: str
94
+ size_bytes: int
95
+ size_human: str
96
+ mtime: datetime
97
+ is_dir: bool
98
+ is_hidden: bool
99
+
100
+
101
+ def validate_path(root: Path, requested: str) -> Path:
102
+ """Validate and resolve a requested path relative to root.
103
+
104
+ Ensures the requested path:
105
+ 1. Resolves to a location under the root directory
106
+ 2. Does not escape via ".." traversal
107
+ 3. Does not escape via symlink targets
108
+
109
+ Args:
110
+ root: The root directory that bounds all access.
111
+ requested: The user-requested path (may be relative).
112
+
113
+ Returns:
114
+ Path: The resolved absolute path.
115
+
116
+ Raises:
117
+ PathTraversalError: If the path would escape the root directory.
118
+ SymlinkEscapeError: If a symlink target is outside root.
119
+ """
120
+ root_resolved, root_str = _get_resolved_root_cached(root)
121
+
122
+ if not requested or requested == "/" or requested == ".":
123
+ return root_resolved
124
+
125
+ # On NTFS "file.txt:stream" addresses an alternate data stream of
126
+ # file.txt; its resolved path still sits under root, so the containment
127
+ # check below would let hidden streams be read. No real entry under a
128
+ # relative path contains ":".
129
+ if os.name == "nt" and ":" in requested:
130
+ raise PathTraversalError(f"Path contains invalid character: {requested}")
131
+
132
+ target = root / requested.lstrip("/")
133
+ resolved = target.resolve()
134
+ resolved_str = str(resolved)
135
+
136
+ if not _is_under_root_str(root_str, resolved_str):
137
+ raise PathTraversalError(f"Path escapes root directory: {requested}")
138
+
139
+ # is_symlink() returns False for nonexistent paths, so the prior exists()
140
+ # check is redundant. Only resolve target.parent if we actually have a
141
+ # symlink to inspect, since resolve() is the dominant cost here.
142
+ if target.is_symlink() and not _is_under_root_str(root_str, str(target.parent.resolve())):
143
+ raise SymlinkEscapeError(f"Symlink location escapes root: {requested}")
144
+
145
+ return resolved
146
+
147
+
148
+ def _list_directory_uncached(path: Path, show_hidden: bool) -> list[FileEntry]:
149
+ """Internal uncached directory listing.
150
+
151
+ Args:
152
+ path: The directory path to list.
153
+ show_hidden: Whether to include files starting with '.'.
154
+
155
+ Returns:
156
+ list[FileEntry]: List of file entries in the directory.
157
+ """
158
+ entries: list[FileEntry] = []
159
+ fromtimestamp = datetime.fromtimestamp
160
+ is_dir_check = stat.S_ISDIR
161
+
162
+ with os.scandir(path) as it:
163
+ for entry_de in it:
164
+ name = entry_de.name
165
+ is_hidden = name.startswith(".")
166
+
167
+ # Skip hidden files if not showing them
168
+ if is_hidden and not show_hidden:
169
+ continue
170
+
171
+ try:
172
+ # DirEntry.stat() follows symlinks by default — matches prior behavior.
173
+ # On Windows, stat info is populated from the directory enumeration
174
+ # for non-symlinks, avoiding a per-file syscall.
175
+ stat_info = entry_de.stat()
176
+ is_dir = is_dir_check(stat_info.st_mode)
177
+ size = 0 if is_dir else stat_info.st_size
178
+
179
+ entries.append(
180
+ FileEntry(
181
+ name=name,
182
+ size_bytes=size,
183
+ size_human=humanize_size(size),
184
+ mtime=fromtimestamp(stat_info.st_mtime, tz=UTC),
185
+ is_dir=is_dir,
186
+ is_hidden=is_hidden,
187
+ )
188
+ )
189
+ except OSError:
190
+ # Skip files we can't stat (permission errors, etc.)
191
+ continue
192
+
193
+ return entries
194
+
195
+
196
+ def list_directory(path: Path, show_hidden: bool = True) -> list[FileEntry]:
197
+ """List contents of a directory with caching.
198
+
199
+ Uses an LRU cache with mtime-based invalidation for performance.
200
+ Cache entries are invalidated when directory mtime changes.
201
+
202
+ Args:
203
+ path: The directory path to list.
204
+ show_hidden: Whether to include files starting with '.'.
205
+
206
+ Returns:
207
+ list[FileEntry]: List of file entries in the directory.
208
+
209
+ Raises:
210
+ NotADirectoryError: If path is not a directory.
211
+ FileNotFoundError: If path does not exist.
212
+ """
213
+ if not path.exists():
214
+ raise FileNotFoundError(f"Directory not found: {path}")
215
+
216
+ if not path.is_dir():
217
+ raise NotADirectoryError(f"Not a directory: {path}")
218
+
219
+ # Get directory mtime for cache validation
220
+ try:
221
+ dir_mtime = path.stat().st_mtime
222
+ except OSError:
223
+ # Can't stat - skip cache
224
+ return _list_directory_uncached(path, show_hidden)
225
+
226
+ cache_key = (str(path), show_hidden)
227
+
228
+ cached = _dir_cache.get(cache_key)
229
+ if cached is not None and cached.mtime == dir_mtime:
230
+ return list(cached.entries)
231
+
232
+ entries = _list_directory_uncached(path, show_hidden)
233
+
234
+ # Update cache with lock
235
+ with _dir_cache_lock:
236
+ # Evict oldest entries if cache is full
237
+ if len(_dir_cache) >= _DIR_CACHE_MAX_SIZE:
238
+ # Remove 25% of entries (simple LRU approximation)
239
+ keys_to_remove = list(_dir_cache.keys())[: _DIR_CACHE_MAX_SIZE // 4]
240
+ for key in keys_to_remove:
241
+ del _dir_cache[key]
242
+
243
+ # Store as tuple for immutability
244
+ _dir_cache[cache_key] = _CacheEntry(mtime=dir_mtime, entries=tuple(entries))
245
+
246
+ return entries
247
+
248
+
249
+ def humanize_size(size_bytes: int) -> str:
250
+ """Convert byte size to human-readable format.
251
+
252
+ Args:
253
+ size_bytes: Size in bytes.
254
+
255
+ Returns:
256
+ str: Human-readable size string (e.g., "1.5 MB").
257
+ """
258
+ if size_bytes == 0:
259
+ return "—"
260
+
261
+ for unit_name, unit_size in _SIZE_UNITS:
262
+ if size_bytes >= unit_size:
263
+ value = size_bytes / unit_size
264
+ if value >= 100:
265
+ return f"{value:.0f} {unit_name}"
266
+ elif value >= 10:
267
+ return f"{value:.1f} {unit_name}"
268
+ else:
269
+ return f"{value:.2f} {unit_name}"
270
+
271
+ return f"{size_bytes} B"
272
+
273
+
274
+ def _key_name(e: FileEntry) -> str:
275
+ """Sort key for name (case-insensitive)."""
276
+ return e.name.lower()
277
+
278
+
279
+ def _key_size(e: FileEntry) -> int:
280
+ """Sort key for size."""
281
+ return e.size_bytes
282
+
283
+
284
+ def _key_mtime(e: FileEntry) -> datetime:
285
+ """Sort key for modification time."""
286
+ return e.mtime
287
+
288
+
289
+ # Pre-defined sort key functions to avoid lambda recreation
290
+ _SORT_KEYS = {
291
+ "name": _key_name,
292
+ "size": _key_size,
293
+ "modified": _key_mtime,
294
+ }
295
+
296
+
297
+ def sort_entries(
298
+ entries: list[FileEntry],
299
+ sort_by: str = "name",
300
+ sort_order: str = "asc",
301
+ ) -> list[FileEntry]:
302
+ """Sort file entries with directories first, then by specified field.
303
+
304
+ Uses single-pass separation and pre-defined key functions for performance.
305
+
306
+ Args:
307
+ entries: List of file entries to sort.
308
+ sort_by: Field to sort by - "name", "size", or "modified".
309
+ sort_order: Sort direction - "asc" or "desc".
310
+
311
+ Returns:
312
+ list[FileEntry]: Sorted list (directories always first, then files sorted).
313
+ """
314
+ # Single pass to separate directories and files
315
+ dirs: list[FileEntry] = []
316
+ files: list[FileEntry] = []
317
+ for e in entries:
318
+ (dirs if e.is_dir else files).append(e)
319
+
320
+ reverse = sort_order == "desc"
321
+ key_func = _SORT_KEYS.get(sort_by, _key_name)
322
+
323
+ if sort_by == "size":
324
+ # Directories have no meaningful size - always sort alphabetically
325
+ dirs.sort(key=_key_name)
326
+ files.sort(key=key_func, reverse=reverse)
327
+ else:
328
+ dirs.sort(key=key_func, reverse=reverse)
329
+ files.sort(key=key_func, reverse=reverse)
330
+
331
+ return dirs + files