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.
- updownserver/__init__.py +847 -0
- updownserver/__main__.py +4 -0
- updownserver/cgi.py +1015 -0
- updownserver-1.0.2.dist-info/METADATA +242 -0
- updownserver-1.0.2.dist-info/RECORD +9 -0
- updownserver-1.0.2.dist-info/WHEEL +5 -0
- updownserver-1.0.2.dist-info/entry_points.txt +2 -0
- updownserver-1.0.2.dist-info/licenses/LICENSE +22 -0
- updownserver-1.0.2.dist-info/top_level.txt +1 -0
updownserver/__init__.py
ADDED
|
@@ -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 = ' 🗑'; // 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
|
+
⚠ 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()
|