updownserver 1.0.2__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.
@@ -0,0 +1,847 @@
1
+ import http.server, http, pathlib, sys, argparse, ssl, os, builtins, tempfile, threading
2
+ import base64, binascii, functools, contextlib
3
+
4
+ # Does not seem to do be used, but leaving this import out causes updownserver
5
+ # to not receive IPv4 requests when started with default options under Windows
6
+ import socket
7
+
8
+ # The cgi module was deprecated in Python 3.13, so I saved a copy in this
9
+ # project
10
+ if sys.version_info.major == 3 and sys.version_info.minor < 13:
11
+ import cgi
12
+ else:
13
+ import updownserver.cgi
14
+
15
+ COLOR_SCHEME = {
16
+ 'light': 'light',
17
+ 'auto': 'light dark',
18
+ 'dark': 'dark',
19
+ }
20
+
21
+
22
+
23
+ def get_directory_head_injection(theme: str) -> bytes:
24
+ return bytes('''<!-- Injected by updownserver -->
25
+ <meta name="viewport" content="width=device-width" />
26
+ <meta name="color-scheme" content="''' + COLOR_SCHEME.get(theme) + '''">
27
+ <style>
28
+ :root {
29
+ --bg-color: #ffffff;
30
+ --text-color: #000000;
31
+ --accent-color: #007bff;
32
+ --border-color: #e9ecef;
33
+ --zone-bg: #f8f9fa;
34
+ --zone-hover: #e2e6ea;
35
+ }
36
+ @media (prefers-color-scheme: dark) {
37
+ :root {
38
+ --bg-color: #121212;
39
+ --text-color: #e0e0e0;
40
+ --accent-color: #4da3ff;
41
+ --border-color: #333;
42
+ --zone-bg: #1e1e1e;
43
+ --zone-hover: #2d2d2d;
44
+ }
45
+ }
46
+
47
+ .upload-widget {
48
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
49
+ border: 2px dashed var(--border-color);
50
+ border-radius: 12px;
51
+ background-color: var(--zone-bg);
52
+ text-align: center;
53
+ padding: 30px 20px;
54
+ margin: 20px 0;
55
+ transition: all 0.2s ease;
56
+ position: relative;
57
+ cursor: pointer;
58
+ }
59
+
60
+ .upload-widget:hover, .upload-widget.dragover {
61
+ border-color: var(--accent-color);
62
+ background-color: var(--zone-hover);
63
+ }
64
+
65
+ .upload-widget h3 {
66
+ margin: 0 0 10px 0;
67
+ color: var(--text-color);
68
+ font-size: 1.2rem;
69
+ }
70
+
71
+ .upload-widget p {
72
+ margin: 0;
73
+ color: var(--text-color);
74
+ opacity: 0.7;
75
+ }
76
+
77
+ .upload-progress {
78
+ margin-top: 15px;
79
+ font-weight: bold;
80
+ color: var(--accent-color);
81
+ min-height: 1.5em;
82
+ }
83
+
84
+ input[type="file"] {
85
+ display: none;
86
+ }
87
+ </style>
88
+ <!-- End injection by updownserver -->
89
+ ''', 'utf-8')
90
+
91
+ DIRECTORY_BODY_INJECTION = b'''<!-- Injected by updownserver -->
92
+ <div class="upload-widget" id="drop-zone" onclick="document.getElementById('file-input').click()">
93
+ <h3>Drag and drop files here to upload</h3>
94
+ <p>or click to select components</p>
95
+ <div id="upload-status" class="upload-progress"></div>
96
+ <form id="upload-form" action="/upload" method="POST" enctype="multipart/form-data">
97
+ <input type="file" id="file-input" name="files" multiple onchange="handleFiles(this.files)">
98
+ </form>
99
+ </div>
100
+
101
+ <!-- New Folder Interface -->
102
+ <!-- New Folder Interface (Hidden if no auth) -->
103
+ <div id="mkdir-container" style="text-align: center; margin-bottom: 20px; display: none;">
104
+ <form id="mkdir-form" onsubmit="event.preventDefault(); createFolder();" style="display: inline-block;">
105
+ <input type="text" id="foldername" name="foldername" placeholder="New folder name" style="padding: 5px; border-radius: 4px; border: 1px solid #ccc;">
106
+ <button type="submit" style="padding: 5px 10px; cursor: pointer; background-color: var(--accent-color); color: white; border: none; border-radius: 4px;">Create Folder</button>
107
+ </form>
108
+ </div>
109
+
110
+ <script>
111
+ // Initialize UI based on Auth
112
+ document.addEventListener('DOMContentLoaded', () => {
113
+ if (typeof ENABLE_DELETE !== 'undefined' && ENABLE_DELETE) {
114
+ const mkdirContainer = document.getElementById('mkdir-container');
115
+ if (mkdirContainer) mkdirContainer.style.display = 'block';
116
+ }
117
+ });
118
+
119
+ const dropZone = document.getElementById('drop-zone');
120
+ const statusDiv = document.getElementById('upload-status');
121
+
122
+ // Prevent default drag behaviors
123
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
124
+ dropZone.addEventListener(eventName, preventDefaults, false);
125
+ document.body.addEventListener(eventName, preventDefaults, false);
126
+ });
127
+
128
+ function preventDefaults(e) {
129
+ e.preventDefault();
130
+ e.stopPropagation();
131
+ }
132
+
133
+ // Highlight drop zone
134
+ ['dragenter', 'dragover'].forEach(eventName => {
135
+ dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
136
+ });
137
+
138
+ ['dragleave', 'drop'].forEach(eventName => {
139
+ dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
140
+ });
141
+
142
+ // Handle dropped files
143
+ dropZone.addEventListener('drop', (e) => {
144
+ const dt = e.dataTransfer;
145
+ const files = dt.files;
146
+ handleFiles(files);
147
+ });
148
+
149
+ function handleFiles(files) {
150
+ if (files.length === 0) return;
151
+ uploadFiles(files);
152
+ }
153
+
154
+ function uploadFiles(files) {
155
+ const url = '/upload';
156
+ const formData = new FormData();
157
+ const fileNames = [];
158
+
159
+ for (let i = 0; i < files.length; i++) {
160
+ formData.append('files', files[i]);
161
+ fileNames.push(files[i].name);
162
+ }
163
+
164
+ statusDiv.textContent = `Uploading ${files.length} file(s)...`;
165
+
166
+ const xhr = new XMLHttpRequest();
167
+ xhr.open('POST', url, true);
168
+
169
+ xhr.upload.onprogress = function(e) {
170
+ if (e.lengthComputable) {
171
+ const percentComplete = (e.loaded / e.total) * 100;
172
+ statusDiv.textContent = `Uploading: ${Math.round(percentComplete)}%`;
173
+ }
174
+ };
175
+
176
+ xhr.onload = function() {
177
+ if (xhr.status === 204) {
178
+ statusDiv.textContent = 'Upload successful! Reloading...';
179
+ setTimeout(() => location.reload(), 1000);
180
+ } else if (xhr.status === 401) {
181
+ statusDiv.textContent = 'Authentication required.';
182
+ location.href = '/upload';
183
+ } else {
184
+ statusDiv.textContent = `Error: ${xhr.status} ${xhr.statusText}`;
185
+ }
186
+ };
187
+
188
+ xhr.onerror = function() {
189
+ statusDiv.textContent = 'Upload failed due to connection error.';
190
+ };
191
+
192
+ xhr.send(formData);
193
+ }
194
+
195
+ // --- New Feature: Create Folder ---
196
+ function createFolder() {
197
+ const input = document.getElementById('foldername');
198
+ const foldername = input.value.trim();
199
+ if (!foldername) return;
200
+
201
+ const formData = new FormData();
202
+ formData.append('foldername', foldername);
203
+
204
+ fetch('/mkdir', {
205
+ method: 'POST',
206
+ body: formData
207
+ })
208
+ .then(response => {
209
+ if (response.status === 201) {
210
+ location.reload();
211
+ } else if (response.status === 401) {
212
+ alert('Authentication required');
213
+ location.reload();
214
+ } else {
215
+ return response.text().then(text => alert('Error: ' + text));
216
+ }
217
+ })
218
+ .catch(err => alert('Error: ' + err));
219
+ }
220
+
221
+ // --- New Feature: Delete Buttons ---
222
+ // Inject delete buttons into the file list
223
+ document.addEventListener('DOMContentLoaded', () => {
224
+ if (typeof ENABLE_DELETE !== 'undefined' && !ENABLE_DELETE) return;
225
+
226
+ const list = document.querySelector('ul');
227
+ if (!list) return;
228
+
229
+ const items = list.querySelectorAll('li');
230
+ items.forEach(li => {
231
+ const link = li.querySelector('a');
232
+ if (!link) return;
233
+
234
+ const name = link.getAttribute('href');
235
+ // Skip parent directory link
236
+ if (name === '../' || name === '..') return;
237
+
238
+ const delBtn = document.createElement('span');
239
+ delBtn.innerHTML = ' &#128465;'; // Trash can icon
240
+ delBtn.style.cursor = 'pointer';
241
+ delBtn.style.color = 'red';
242
+ delBtn.style.marginLeft = '10px';
243
+ delBtn.title = 'Delete';
244
+
245
+ delBtn.onclick = (e) => {
246
+ e.preventDefault();
247
+ if (confirm(`Delete "${decodeURIComponent(name)}"?`)) {
248
+ deleteFile(name);
249
+ }
250
+ };
251
+
252
+ li.appendChild(delBtn);
253
+ });
254
+ });
255
+
256
+ function deleteFile(filename) {
257
+ fetch(filename, {
258
+ method: 'DELETE'
259
+ })
260
+ .then(response => {
261
+ if (response.status === 204) {
262
+ location.reload();
263
+ } else if (response.status === 401) {
264
+ alert('Authentication required');
265
+ location.reload();
266
+ } else if (response.status === 403) {
267
+ alert('Access denied');
268
+ } else {
269
+ alert('Delete failed (directory not empty?)');
270
+ }
271
+ })
272
+ .catch(err => alert('Error: ' + err));
273
+ }
274
+ </script>
275
+ <hr>
276
+ <!-- End injection by updownserver -->
277
+ '''
278
+
279
+
280
+
281
+ class PersistentFieldStorage(cgi.FieldStorage):
282
+ # Override cgi.FieldStorage.make_file() method. Valid for Python 3.1 ~ 3.10.
283
+ # Modified version of the original .make_file() method (base copied from
284
+ # Python 3.10)
285
+ def make_file(self) -> object:
286
+ if self._binary_file:
287
+ return tempfile.NamedTemporaryFile(mode = 'wb+',
288
+ dir = args.directory, delete = False)
289
+ else:
290
+ return tempfile.NamedTemporaryFile("w+", dir = args.directory,
291
+ delete = False, encoding = self.encoding, newline = '\n')
292
+
293
+ # True argument/return type is str | pathlib.Path, but Python 3.9 doesn't
294
+ # support |
295
+ def auto_rename(path: pathlib.Path) -> pathlib.Path:
296
+ if not os.path.exists(path):
297
+ return path
298
+ (base, ext) = os.path.splitext(path)
299
+ for i in range(1, sys.maxsize):
300
+ renamed_path = f'{base} ({i}){ext}'
301
+ if not os.path.exists(renamed_path):
302
+ return renamed_path
303
+ raise FileExistsError(f'File {path} already exists.')
304
+
305
+ def receive_upload(handler: http.server.BaseHTTPRequestHandler,
306
+ ) -> tuple[http.HTTPStatus, str]:
307
+ result = (http.HTTPStatus.INTERNAL_SERVER_ERROR, 'Server error')
308
+ name_conflict = False
309
+
310
+ form = PersistentFieldStorage(fp=handler.rfile, headers=handler.headers,
311
+ environ={'REQUEST_METHOD': 'POST'})
312
+ if 'files' not in form:
313
+ return (http.HTTPStatus.BAD_REQUEST, 'Field "files" not found')
314
+
315
+ fields = form['files']
316
+ if not isinstance(fields, list):
317
+ fields = [fields]
318
+
319
+ if not all(field.file and field.filename for field in fields):
320
+ return (http.HTTPStatus.BAD_REQUEST, 'No files selected')
321
+
322
+ for field in fields:
323
+ if field.file and field.filename:
324
+ filename = pathlib.Path(field.filename).name
325
+ else:
326
+ filename = None
327
+
328
+ if filename:
329
+ destination = pathlib.Path(args.directory) / filename
330
+ if os.path.exists(destination):
331
+ if args.allow_replace and os.path.isfile(destination):
332
+ os.remove(destination)
333
+ else:
334
+ destination = auto_rename(destination)
335
+ name_conflict = True
336
+ if hasattr(field.file, 'name'):
337
+ source = field.file.name
338
+ field.file.close()
339
+ os.rename(source, destination)
340
+ # class '_io.BytesIO', small file (< 1000B, in cgi.py), in-memory
341
+ # buffer
342
+ else:
343
+ with open(destination, 'wb') as f:
344
+ f.write(field.file.read())
345
+ handler.log_message('[Uploaded] "%s" --> %s', filename, destination)
346
+ result = (http.HTTPStatus.NO_CONTENT, 'Some filename(s) changed '
347
+ 'due to name conflict' if name_conflict else 'Files accepted')
348
+
349
+ return result
350
+
351
+ def receive_mkdir(handler: http.server.BaseHTTPRequestHandler
352
+ ) -> tuple[http.HTTPStatus, str]:
353
+ if args.basic_auth_upload and not check_http_authentication(handler):
354
+ # This should have been handled by do_POST but double check
355
+ return (http.HTTPStatus.UNAUTHORIZED, 'Authentication required')
356
+
357
+ form = PersistentFieldStorage(fp=handler.rfile, headers=handler.headers,
358
+ environ={'REQUEST_METHOD': 'POST'})
359
+
360
+ if 'foldername' not in form:
361
+ return (http.HTTPStatus.BAD_REQUEST, 'Field "foldername" not found')
362
+
363
+ foldername = form['foldername'].value
364
+ if not foldername:
365
+ return (http.HTTPStatus.BAD_REQUEST, 'Folder name is empty')
366
+
367
+ # Sanitize folder name - prevent directory traversal or absolute paths
368
+ foldername = os.path.basename(foldername)
369
+
370
+ target_path = pathlib.Path(args.directory) / foldername
371
+
372
+ if os.path.exists(target_path):
373
+ return (http.HTTPStatus.CONFLICT, 'Directory or file already exists')
374
+
375
+ try:
376
+ os.mkdir(target_path)
377
+ handler.log_message('[Mkdir] Created directory "%s"', target_path)
378
+ return (http.HTTPStatus.CREATED, 'Directory created')
379
+ except OSError as e:
380
+ return (http.HTTPStatus.INTERNAL_SERVER_ERROR, f'Failed to create directory: {e}')
381
+
382
+
383
+ # True return type is tuple[bool, str | None], but Python 3.9 doesn't support |
384
+ def check_http_authentication_header(
385
+ handler: http.server.BaseHTTPRequestHandler, auth: str,
386
+ ) -> tuple[bool, str]:
387
+ auth_header = handler.headers.get('Authorization')
388
+ if auth_header is None:
389
+ return (False, 'No credentials given')
390
+
391
+ auth_header_words = auth_header.split(' ')
392
+ if len(auth_header_words) != 2:
393
+ return (False, 'Credentials incorrectly formatted')
394
+
395
+ if auth_header_words[0].lower() != 'basic':
396
+ return (False, 'Credentials incorrectly formatted')
397
+
398
+ try:
399
+ http_username_password = base64.b64decode(auth_header_words[1]).decode()
400
+ except binascii.Error:
401
+ return (False, 'Credentials incorrectly formatted')
402
+
403
+ http_username, http_password = http_username_password.split(':', 2)
404
+ args_username, args_password = auth.split(':', 2)
405
+ if http_username != args_username: return (False, 'Bad username')
406
+ if http_password != args_password: return (False, 'Bad password')
407
+
408
+ return (True, None)
409
+
410
+ def check_http_authentication(handler: http.server.BaseHTTPRequestHandler
411
+ ) -> bool:
412
+ """
413
+ This function should be called in at the beginning of HTTP method handler.
414
+ It validates Authorization header and sends back 401 response on failure.
415
+ It returns False if this happens.
416
+ """
417
+ if not args.basic_auth_upload:
418
+ # If no auth settings apply, check always passes
419
+ if not args.basic_auth:
420
+ return True
421
+
422
+ # If only --basic-auth is supplied, it's used for all requests
423
+ valid, message = check_http_authentication_header(handler, args.basic_auth)
424
+ else:
425
+ # If --basic-auth-upload is supplied, it's always required for /upload
426
+ # and other write operations (mkdir, delete)
427
+ is_write_op = (handler.path == '/upload' or
428
+ handler.path == '/mkdir' or
429
+ (hasattr(handler, 'command') and handler.command == 'DELETE'))
430
+
431
+ if is_write_op:
432
+ valid, message = check_http_authentication_header(handler,
433
+ args.basic_auth_upload)
434
+ else:
435
+ # For paths outside /upload, no auth is required when --basic-auth
436
+ # is not supplied
437
+ if not args.basic_auth:
438
+ return True
439
+
440
+ # For paths outside /upload (read ops), if both auths are supplied,
441
+ # both are accepted.
442
+ valid, message = check_http_authentication_header(handler,
443
+ args.basic_auth)
444
+ if not valid:
445
+ valid, message = check_http_authentication_header(handler,
446
+ args.basic_auth_upload)
447
+
448
+ if not valid:
449
+ handler.send_response(http.HTTPStatus.UNAUTHORIZED)
450
+ handler.send_header('WWW-Authenticate', 'Basic realm="Upload"')
451
+ handler.end_headers()
452
+ return valid
453
+
454
+ # Let's not inherit http.server.SimpleHTTPRequestHandler - that would cause
455
+ # diamond-pattern inheritance
456
+ class ListDirectoryInterception:
457
+ # Only runs when serving directory listings
458
+ def flush_headers_interceptor(self):
459
+ # Calculate auth state for delete JS injection size matching copyfile_interceptor
460
+ has_auth = args.basic_auth or args.basic_auth_upload or args.client_certificate
461
+ enable_delete_js = b'<script>const ENABLE_DELETE = ' + \
462
+ (b'true' if has_auth else b'false') + b';</script>'
463
+
464
+ for i, header in enumerate(self._headers_buffer):
465
+ if header[:15] == b'Content-Length:':
466
+ length = int(header[15:]) + len(DIRECTORY_BODY_INJECTION) + \
467
+ len(get_directory_head_injection(args.theme)) + \
468
+ len(enable_delete_js) + \
469
+ len(get_shutdown_timer_injection())
470
+
471
+ # Use same encoding that self.send_header() uses
472
+ self._headers_buffer[i] = f'Content-Length: {length}\r\n' \
473
+ .encode('latin-1', 'strict')
474
+
475
+ # Can't use super() - avoiding diamond-pattern inheritance'
476
+ http.server.SimpleHTTPRequestHandler.flush_headers(self)
477
+
478
+ # Only runs when serving directory listings
479
+ def copyfile_interceptor(self, source, outputfile):
480
+ content = source.read()
481
+ content = content.replace(b'</head>',
482
+ get_directory_head_injection(args.theme) + b'</head>')
483
+
484
+ # Determine if delete/mkdir should be enabled based on auth
485
+ has_auth = args.basic_auth or args.basic_auth_upload or args.client_certificate
486
+ enable_delete_js = b'<script>const ENABLE_DELETE = ' + \
487
+ (b'true' if has_auth else b'false') + b';</script>'
488
+
489
+ content = content.replace(b'<ul>', enable_delete_js + DIRECTORY_BODY_INJECTION + b'<ul>')
490
+
491
+ # Inject shutdown timer if enabled (or warning if disabled)
492
+ content = content.replace(b'</body>', get_shutdown_timer_injection() + b'</body>')
493
+
494
+ outputfile.write(content)
495
+
496
+ # True argument type is str | pathlib.Path, but Python 3.9 doesn't support |
497
+ def list_directory(self, path: pathlib.Path) -> object:
498
+ setattr(self, 'flush_headers', self.flush_headers_interceptor)
499
+ setattr(self, 'copyfile', self.copyfile_interceptor)
500
+
501
+ # Can't use super() - avoiding diamond-pattern inheritance'
502
+ return http.server.SimpleHTTPRequestHandler.list_directory(self, path)
503
+
504
+ class SimpleHTTPRequestHandler(ListDirectoryInterception,
505
+ http.server.SimpleHTTPRequestHandler):
506
+ def do_GET(self):
507
+ if not check_http_authentication(self): return
508
+
509
+ if self.path == '/upload':
510
+ send_upload_page(self)
511
+ else:
512
+ super().do_GET()
513
+
514
+ def do_POST(self):
515
+ if not check_http_authentication(self): return
516
+
517
+ if self.path == '/upload':
518
+ result = receive_upload(self)
519
+ elif self.path == '/mkdir':
520
+ result = receive_mkdir(self)
521
+ else:
522
+ self.send_error(http.HTTPStatus.NOT_FOUND,
523
+ 'Can only POST/PUT to /upload or /mkdir')
524
+ return
525
+
526
+ if result[0] < http.HTTPStatus.BAD_REQUEST:
527
+ self.send_response(result[0], result[1])
528
+ self.end_headers()
529
+ else:
530
+ self.send_error(result[0], result[1])
531
+
532
+ def do_PUT(self):
533
+ self.do_POST()
534
+
535
+ def do_DELETE(self):
536
+ if not check_http_authentication(self): return
537
+
538
+ # Security: Prevent directory traversal
539
+ target_path = self.translate_path(self.path)
540
+
541
+ # Ensure target is within the served directory
542
+ server_root = pathlib.Path(args.directory).resolve()
543
+ try:
544
+ target_path_obj = pathlib.Path(target_path).resolve()
545
+ if server_root not in target_path_obj.parents and server_root != target_path_obj:
546
+ self.send_error(http.HTTPStatus.FORBIDDEN, "Access denied")
547
+ return
548
+ except Exception:
549
+ self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid path")
550
+ return
551
+
552
+ if os.path.isfile(target_path):
553
+ try:
554
+ os.remove(target_path)
555
+ self.send_response(http.HTTPStatus.NO_CONTENT)
556
+ self.end_headers()
557
+ except OSError:
558
+ self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to delete file")
559
+ elif os.path.isdir(target_path):
560
+ try:
561
+ os.rmdir(target_path)
562
+ self.send_response(http.HTTPStatus.NO_CONTENT)
563
+ self.end_headers()
564
+ except OSError:
565
+ self.send_error(http.HTTPStatus.CONFLICT, "Directory not empty or in use")
566
+ else:
567
+ self.send_error(http.HTTPStatus.NOT_FOUND, "File not found")
568
+
569
+ class CGIHTTPRequestHandler(ListDirectoryInterception,
570
+ http.server.CGIHTTPRequestHandler):
571
+ def do_GET(self):
572
+ if not check_http_authentication(self): return
573
+
574
+ super().do_GET()
575
+
576
+ def do_POST(self):
577
+ if not check_http_authentication(self): return
578
+
579
+ if self.path == '/upload':
580
+ result = receive_upload(self)
581
+ elif self.path == '/mkdir':
582
+ result = receive_mkdir(self)
583
+ else:
584
+ super().do_POST()
585
+ return
586
+
587
+ if result[0] < http.HTTPStatus.BAD_REQUEST:
588
+ self.send_response(result[0], result[1])
589
+ self.end_headers()
590
+ else:
591
+ self.send_error(result[0], result[1])
592
+
593
+ def do_DELETE(self):
594
+ # reuse the implementation from SimpleHTTPRequestHandler (safe since we check args.directory)
595
+ SimpleHTTPRequestHandler.do_DELETE(self)
596
+
597
+ def do_PUT(self):
598
+ self.do_POST()
599
+
600
+ def intercept_first_print():
601
+ if args.server_certificate:
602
+ # Use the right protocol in the first print call in case of HTTPS
603
+ old_print = builtins.print
604
+ def new_print(*args, **kwargs):
605
+ old_print(args[0].replace('HTTP', 'HTTPS').replace('http', 'https'),
606
+ **kwargs)
607
+ builtins.print = old_print
608
+ builtins.print = new_print
609
+
610
+ def ssl_wrap(socket: socket.socket) -> ssl.SSLSocket:
611
+ context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
612
+ server_root = pathlib.Path(args.directory).resolve()
613
+
614
+ # Server certificate handling
615
+ server_certificate = pathlib.Path(args.server_certificate).resolve()
616
+
617
+ if not server_certificate.is_file():
618
+ print(f'Server certificate "{server_certificate}" not found, exiting')
619
+ sys.exit(4)
620
+
621
+ if server_root in server_certificate.parents:
622
+ print(f'Server certificate "{server_certificate}" is inside web server '
623
+ f'root "{server_root}", exiting')
624
+ sys.exit(3)
625
+
626
+ try:
627
+ context.load_cert_chain(certfile=server_certificate)
628
+ except ssl.SSLError as e:
629
+ print(f'Unable to load certificate "{server_certificate}", exiting\n\n'
630
+ f'NOTE: Certificate must be a single file in .pem format. If you '
631
+ 'have multiple certificate files, such as Let\'s Encrypt provides, '
632
+ 'you can cat them together to get one file.')
633
+ sys.exit(4)
634
+
635
+ if args.client_certificate:
636
+ # Client certificate handling
637
+ client_certificate = pathlib.Path(args.client_certificate).resolve()
638
+
639
+ if not client_certificate.is_file():
640
+ print(f'Client certificate "{client_certificate}" not found, '
641
+ 'exiting')
642
+ sys.exit(4)
643
+
644
+ if server_root in client_certificate.parents:
645
+ print(f'Client certificate "{client_certificate}" is inside web '
646
+ f'server root "{server_root}", exiting')
647
+ sys.exit(3)
648
+
649
+ context.load_verify_locations(cafile=client_certificate)
650
+ context.verify_mode = ssl.CERT_REQUIRED
651
+
652
+ try:
653
+ return context.wrap_socket(socket, server_side=True)
654
+ except ssl.SSLError as e:
655
+ print('SSL error: "{}", exiting'.format(e))
656
+ sys.exit(5)
657
+
658
+ def serve_forever():
659
+ # Verify arguments in case the method was called directly
660
+ assert hasattr(args, 'port') and type(args.port) is int
661
+ assert hasattr(args, 'cgi') and type(args.cgi) is bool
662
+ assert hasattr(args, 'allow_replace') and type(args.allow_replace) is bool
663
+ assert hasattr(args, 'bind')
664
+ assert hasattr(args, 'theme')
665
+ assert hasattr(args, 'server_certificate')
666
+ assert hasattr(args, 'client_certificate')
667
+ assert hasattr(args, 'basic_auth')
668
+ assert hasattr(args, 'basic_auth_upload')
669
+ assert hasattr(args, 'directory') and type(args.directory) is str
670
+
671
+ if args.cgi:
672
+ handler_class = CGIHTTPRequestHandler
673
+ else:
674
+ handler_class = functools.partial(SimpleHTTPRequestHandler,
675
+ directory=args.directory)
676
+
677
+ print('File upload available at /upload')
678
+
679
+ class DualStackServer(http.server.ThreadingHTTPServer):
680
+ def server_bind(self):
681
+ # suppress exception when protocol is IPv4
682
+ with contextlib.suppress(Exception):
683
+ self.socket.setsockopt(
684
+ socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
685
+ bind = super().server_bind()
686
+ if args.server_certificate:
687
+ self.socket = ssl_wrap(self.socket)
688
+ return bind
689
+ server_class = DualStackServer
690
+
691
+ # Enforce safety: If no authentication is configured, prevent infinite run
692
+ has_auth = args.basic_auth or args.basic_auth_upload or args.client_certificate
693
+ if not has_auth and args.timeout <= 0:
694
+ print('\n[Security Warning] Running without authentication. Forcing auto-shutdown in 300 seconds.')
695
+ args.timeout = 300
696
+
697
+ # Auto-shutdown logic
698
+ if args.timeout > 0:
699
+ print(f'\n[Auto-Shutdown] Server will automatically shut down in {args.timeout} seconds.')
700
+ if has_auth:
701
+ print(f'[Auto-Shutdown] Pass --timeout 0 to disable this behavior.\n')
702
+ else:
703
+ print(f'[Auto-Shutdown] Authentication required to disable auto-shutdown.\n')
704
+ shutdown_timer = threading.Timer(args.timeout, lambda: os._exit(0))
705
+ shutdown_timer.daemon = True
706
+ shutdown_timer.start()
707
+ else:
708
+ print(f'\n[Auto-Shutdown] Disabled. Server will run indefinitely.\n')
709
+
710
+ if args.qr:
711
+ print_qr_codes()
712
+
713
+ intercept_first_print()
714
+ http.server.test(
715
+ HandlerClass=handler_class,
716
+ ServerClass=server_class,
717
+ port=args.port,
718
+ bind=args.bind,
719
+ )
720
+
721
+ def get_shutdown_timer_injection() -> bytes:
722
+ if args.timeout <= 0:
723
+ return b'''
724
+ <div style="position:fixed; top:10px; right:10px; background: #dc3545; color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; z-index: 9999; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
725
+ &#9888; Auto-Shutdown Disabled
726
+ </div>
727
+ '''
728
+ else:
729
+ return f'''
730
+ <div id="shutdown-timer" style="position:fixed; top:10px; right:10px; background: #28a745; color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; z-index: 9999; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
731
+ Shutdown in: <span id="time-remaining">{args.timeout}</span>s
732
+ </div>
733
+ <script>
734
+ let seconds = {args.timeout};
735
+ const timerSpan = document.getElementById('time-remaining');
736
+ const timerDiv = document.getElementById('shutdown-timer');
737
+
738
+ setInterval(() => {{
739
+ seconds--;
740
+ if (seconds < 0) {{
741
+ timerDiv.style.background = '#dc3545';
742
+ timerDiv.textContent = 'Server Shutting Down...';
743
+ return;
744
+ }}
745
+ timerSpan.textContent = seconds;
746
+
747
+ if (seconds < 60) {{
748
+ timerDiv.style.background = '#ffc107'; // yellow warning
749
+ timerDiv.style.color = 'black';
750
+ }}
751
+ if (seconds < 10) {{
752
+ timerDiv.style.background = '#dc3545'; // red critical
753
+ timerDiv.style.color = 'white';
754
+ }}
755
+ }}, 1000);
756
+ </script>
757
+ '''.encode('utf-8')
758
+
759
+ # We need to monkey-patch DIRECTORY_BODY_INJECTION usage or append to it dynamically
760
+ # But DIRECTORY_BODY_INJECTION is a constant.
761
+ # Better strategy: Modify DIRECTORY_BODY_INJECTION definition to include a placeholder or append at runtime?
762
+ # Since DIRECTORY_BODY_INJECTION is used in list_directory (ListDirectoryInterception),
763
+ # let's update ListDirectoryInterception to append this.
764
+
765
+
766
+ def print_qr_codes():
767
+ try:
768
+ import qrcode
769
+ except ImportError:
770
+ print('\nTip: Install "qrcode" to see a QR code for mobile connection:')
771
+ print(' pip install updownserver[qr]\n')
772
+ return
773
+
774
+ protocol = 'https' if args.server_certificate else 'http'
775
+ port = args.port
776
+
777
+ # Determine IPs to display
778
+ ips = []
779
+ if args.bind and args.bind != '0.0.0.0' and args.bind != '::':
780
+ ips = [args.bind]
781
+ else:
782
+ try:
783
+ # Best effort to find LAN IPs without external dependencies
784
+ hostname = socket.gethostname()
785
+ _, _, all_ips = socket.gethostbyname_ex(hostname)
786
+ ips = [ip for ip in all_ips if not ip.startswith('127.')]
787
+ except Exception:
788
+ pass
789
+
790
+ if not ips:
791
+ return
792
+
793
+ print('\nScan to connect from mobile:')
794
+ for ip in ips:
795
+ url = f'{protocol}://{ip}:{port}/'
796
+ # qrcode.make() returns an image, relying on terminal support is tricky
797
+ # Better to use qrcode.ConsoleASCIIQRCode (if available) or print_ascii
798
+ qr = qrcode.QRCode()
799
+ qr.add_data(url)
800
+ qr.make(fit=True)
801
+
802
+ print(f' > {url}')
803
+ try:
804
+ # Try to print inverted for better compatibility with dark terminals
805
+ qr.print_ascii(invert=True)
806
+ except Exception:
807
+ qr.print_ascii()
808
+ print('')
809
+
810
+ def main():
811
+ global args
812
+
813
+ parser = argparse.ArgumentParser()
814
+ parser.add_argument('port', type=int, default=8000, nargs='?',
815
+ help='Specify alternate port [default: 8000]')
816
+ parser.add_argument('--cgi', action='store_true',
817
+ help='Run as CGI Server')
818
+ parser.add_argument('--allow-replace', action='store_true', default=False,
819
+ help='Replace existing file if uploaded file has the same name. Auto '
820
+ 'rename by default.')
821
+ parser.add_argument('--bind', '-b', metavar='ADDRESS',
822
+ help='Specify alternate bind address [default: all interfaces]')
823
+ parser.add_argument('--directory', '-d', default=os.getcwd(),
824
+ help='Specify alternative directory [default:current directory]')
825
+ parser.add_argument('--theme', type=str, default='auto',
826
+ choices=['light', 'auto', 'dark'],
827
+ help='Specify a light or dark theme for the upload page '
828
+ '[default: auto]')
829
+ parser.add_argument('--server-certificate', '--certificate', '-c',
830
+ help='Specify HTTPS server certificate to use [default: none]')
831
+ parser.add_argument('--client-certificate',
832
+ help='Specify HTTPS client certificate to accept for mutual TLS '
833
+ '[default: none]')
834
+ parser.add_argument('--basic-auth',
835
+ help='Specify user:pass for basic authentication (downloads and '
836
+ 'uploads)')
837
+ parser.add_argument('--basic-auth-upload',
838
+ help='Specify user:pass for basic authentication (uploads only)')
839
+ parser.add_argument('--timeout', type=int, default=300,
840
+ help='Auto-shutdown server after N seconds (0 to disable) [default: 300]')
841
+ parser.add_argument('--qr', action='store_true',
842
+ help='Show QR code at startup')
843
+
844
+ args = parser.parse_args()
845
+ if not hasattr(args, 'directory'): args.directory = os.getcwd()
846
+
847
+ serve_forever()