- 20250815
- Updated script to reflect current changes. It's prettier and more user-friendly now.
- 20250221
- Created page.
February 21, 2025 at 22:09
Updated: 20250815.
I need to mention that Syncthing is my #1 go-to for moving files from one computer to another. It’s an amazing tool that allows a folder to be copied across any number of devices. Syncthing is also listed on the Things I Use page under Backup, even though it is not technically a backup solution.
The reason this python script exists is that work has blocked Syncthing for security reasons. I totally get that. However, this prevents me from sending memes from my desktop to my work laptop easily. Since most of my work communications are done through GIFs and camera pictures, I needed a way to get those files from my phone and desktop computer to work chat.
Be aware that this was 99% written by AI. I didn’t learn much doing this. It was purely out of functional necessity. It wasn’t until after looking through self-hosted file server solutions did I discover http.server was a thing.
File List:
File Delete:
File Upload:
pyserver.py
#!/usr/bin/env python3
from http.server import SimpleHTTPRequestHandler, HTTPServer
import os
import cgi
import io
import urllib.parse
import mimetypes
import datetime
import json
import html
def format_size(num, suffix='B'):
"""Convert a file size in bytes into a human-readable format."""
for unit in ['','K','M','G','T','P','E','Z']:
if abs(num) < 1024.0:
return f"{num:3.1f}{unit}{suffix}"
num /= 1024.0
return f"{num:.1f}Y{suffix}"
def get_file_icon(filename):
"""Return appropriate icon class based on file extension."""
ext = os.path.splitext(filename)[1].lower()
icons = {
'.pdf': '📄', '.doc': '📝', '.docx': '📝', '.xls': '📊', '.xlsx': '📊',
'.ppt': '📑', '.pptx': '📑', '.txt': '📄', '.jpg': '🖼️', '.jpeg': '🖼️',
'.png': '🖼️', '.gif': '🖼️', '.mp3': '🎵', '.wav': '🎵', '.mp4': '🎥',
'.avi': '🎥', '.mov': '🎥', '.zip': '📦', '.rar': '📦', '.7z': '📦',
'.py': '🐍', '.js': '📜', '.html': '🌐', '.css': '🎨',
}
return icons.get(ext, '📁')
class CustomHandler(SimpleHTTPRequestHandler):
def do_POST(self):
try:
print(f"POST request received: {self.path}")
content_type = self.headers['Content-Type']
print(f"Content-Type: {content_type}")
if 'multipart/form-data' in content_type:
self._handle_file_upload()
elif 'application/json' in content_type:
self._handle_json_request()
else:
self.send_error(400, "Unsupported content type")
except Exception as e:
print(f"POST error: {e}")
self.send_error(500, str(e))
def _handle_file_upload(self):
form = cgi.FieldStorage(
fp=self.rfile,
headers=self.headers,
environ={'REQUEST_METHOD': 'POST', 'CONTENT_TYPE': self.headers['Content-Type']}
)
path = form.getvalue('path', '/')
base_dir = os.path.abspath('.')
upload_dir = os.path.abspath(os.path.join(base_dir, path.lstrip('/')))
if not upload_dir.startswith(base_dir) or not os.path.isdir(upload_dir):
self.send_error(403, "Forbidden or invalid path")
return
if 'file' in form:
file_items = form['file']
if not isinstance(file_items, list):
file_items = [file_items]
for file_item in file_items:
if file_item.filename:
filename = os.path.basename(file_item.filename)
filepath = os.path.join(upload_dir, filename)
with open(filepath, 'wb') as f: f.write(file_item.file.read())
os.chmod(filepath, 0o777)
self._send_success_response(path)
else:
self.send_error(400, "No file was uploaded")
def _handle_json_request(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
action = data.get('action')
path = data.get('path', '/')
base_dir = os.path.abspath('.')
req_path = os.path.abspath(os.path.join(base_dir, path.lstrip('/')))
if not req_path.startswith(base_dir):
self.send_error(403, "Forbidden path")
return
if action == 'create_folder':
folder_name = data.get('name')
if folder_name:
os.makedirs(os.path.join(req_path, folder_name), exist_ok=True)
self._send_success_response(path)
else:
self.send_error(400, "Folder name is required")
elif action == 'rename':
old_name = data.get('old_name')
new_name = data.get('new_name')
if old_name and new_name:
os.rename(os.path.join(req_path, old_name), os.path.join(req_path, new_name))
self._send_success_response(path)
else:
self.send_error(400, "Both old and new names are required")
elif action == 'delete':
name = data.get('name')
if name:
file_path = os.path.join(req_path, name)
if os.path.isfile(file_path):
os.remove(file_path)
elif os.path.isdir(file_path):
os.rmdir(file_path)
else:
self.send_error(404, "File or directory not found")
return
self._send_success_response(path)
else:
self.send_error(400, "Name is required")
def _send_success_response(self, path='/'):
self.send_response(303)
self.send_header('Location', urllib.parse.quote(path))
self.end_headers()
def do_DELETE(self):
print(f"DELETE request received: {self.path}")
path = urllib.parse.unquote(self.path).lstrip('/')
print(f"DELETE path after processing: {path}")
script_filename = os.path.basename(__file__)
if not path or path == script_filename:
print("DELETE blocked: operation not permitted")
self.send_error(403, "Operation not permitted")
return
try:
full_path = os.path.abspath(path)
print(f"DELETE full_path: {full_path}")
if not full_path.startswith(os.path.abspath('.')):
print("DELETE blocked: forbidden path")
self.send_error(403, "Forbidden path")
return
if os.path.isfile(full_path):
print(f"Deleting file: {full_path}")
os.remove(full_path)
elif os.path.isdir(full_path):
print(f"Deleting directory: {full_path}")
os.rmdir(full_path)
else:
print(f"DELETE not found: {full_path}")
self.send_error(404, "Not found")
return
print("DELETE successful")
self.send_response(200)
self.end_headers()
self.wfile.write(b"Successfully deleted")
except Exception as e:
print(f"DELETE error: {e}")
self.send_error(500, str(e))
def list_directory(self, path):
try:
dir_list = sorted(os.listdir(path), key=lambda x: (not os.path.isdir(os.path.join(path, x)), x.lower()))
except OSError:
self.send_error(404, "No permission to list directory")
return None
script_filename = os.path.basename(__file__)
current_request_path = urllib.parse.unquote(self.path)
r = []
r.append('<!DOCTYPE html>')
r.append('<html lang="en">')
r.append('<head>')
r.append('<meta charset="utf-8">')
r.append('<meta name="viewport" content="width=device-width, initial-scale=1.0">')
r.append('<title>File Server</title>')
r.append('<link rel="icon" href="/favicon.svg" type="image/svg+xml">')
r.append(self.get_styles())
r.append('</head><body>')
r.append('<div class="container">')
r.append('<div class="header">')
r.append('<input type="text" id="searchInput" onkeyup="searchFiles()" placeholder="Search for files...">')
r.append('<button class="btn" onclick="showModal(\'createFolderModal\')">New Folder</button>')
r.append('</div>')
r.append('<nav class="breadcrumbs"><a href="/">Home</a>')
path_parts = [p for p in current_request_path.strip('/').split('/') if p]
path_so_far = ''
for part in path_parts:
path_so_far += f'/{part}'
r.append(f'<span> / </span><a href="{urllib.parse.quote(path_so_far)}">{html.escape(part)}</a>')
r.append('</nav>')
r.append('<div class="upload-area" id="dropZone"><p>Drag & drop to upload, or</p><input type="file" id="fileInput" multiple style="display:none;"><button class="btn" onclick="fileInput.click()">Select Files</button></div>')
r.append('<table class="file-table"><thead><tr><th>Name</th><th>Last Modified</th><th>Size</th><th class="actions-cell">Actions</th></tr></thead><tbody id="file-list">')
for name in dir_list:
if name == script_filename or name == 'favicon.svg': continue
full_path = os.path.join(path, name)
is_dir = os.path.isdir(full_path)
link_path = urllib.parse.quote(os.path.join(current_request_path.strip('/'), name))
try:
size = format_size(os.path.getsize(full_path)) if not is_dir else "—"
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(full_path)).strftime("%b %d, %Y %I:%M %p")
except:
size, mtime = "N/A", "N/A"
js_name = json.dumps(name)
r.append(f'<tr onclick="handleItemClick({js_name}, {str(is_dir).lower()})">')
r.append(f'<td><span class="file-icon">{get_file_icon(name)}</span><a href="/{link_path}">{html.escape(name)}</a></td>')
r.append(f'<td>{mtime}</td>')
r.append(f'<td>{size}</td>')
r.append('<td class="actions-cell">')
if not is_dir: r.append(f'<a href="/{link_path}" download class="btn" onclick="event.stopPropagation()">Download</a>')
r.append(f'<button class="btn" onclick="renameItem(\'{name}\')">Rename</button>')
r.append(f'<button class="btn btn-danger" onclick="deleteItem(\'{name}\')">Delete</button>')
r.append('</td></tr>')
r.append('</tbody></table>')
# Modals
r.append('<div id="createFolderModal" class="modal"><div class="modal-content"><h2>Create New Folder</h2><input type="text" id="folderName" placeholder="Folder name"><div class="modal-actions"><button class="btn" onclick="createFolder()">Create</button><button class="btn" onclick="closeModal(\'createFolderModal\')">Cancel</button></div></div></div>')
r.append('<div id="previewModal" class="modal"><div class="modal-content"><h2 id="previewTitle"></h2><div id="previewContent"></div><div class="modal-actions"><button class="btn" onclick="closeModal(\'previewModal\')">Close</button></div></div></div>')
r.append('</div>') # .container
r.append(self.get_scripts(current_request_path))
r.append('<script>console.log("Script loaded at:", new Date().toISOString());</script>')
r.append('</body></html>')
encoded = ''.join(r).encode('utf-8')
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def get_styles(self):
return """
<style>
:root { --bg-dark: #121212; --surface: #1e1e1e; --primary: #bb86fc; --on-primary: #000; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; --border: #2a2a2a; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: var(--bg-dark); color: var(--text-primary); }
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
.header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem; }
#searchInput { flex-grow: 1; padding: 0.75rem; background-color: var(--surface); border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); min-width: 200px; }
.btn { background-color: var(--primary); color: var(--on-primary); border: none; padding: 0.75rem 1.5rem; border-radius: 8px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-block; }
.btn:hover { opacity: 0.9; }
.breadcrumbs { margin-bottom: 2rem; font-size: 1.1rem; color: var(--text-secondary); }
.breadcrumbs a { color: var(--primary); text-decoration: none; }
.upload-area { text-align: center; border: 2px dashed var(--border); border-radius: 8px; padding: 2rem; margin-bottom: 2rem; background-color: var(--surface); }
.file-table { width: 100%; border-collapse: collapse; }
.file-table th, .file-table td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--border); }
.file-table th { font-weight: 600; }
.file-table tr { cursor: pointer; }
.file-table tr:hover { background-color: var(--surface); }
.file-table td a { color: var(--text-primary); text-decoration: none; font-weight: 500; }
.file-table td a:hover { text-decoration: underline; }
.file-icon { font-size: 1.5rem; vertical-align: middle; margin-right: 1rem; }
.actions-cell { text-align: right !important; }
.actions-cell .btn { margin-left: 0.5rem; padding: 0.4rem 0.8rem; }
.btn-danger { background-color: #cf6679; }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); justify-content: center; align-items: center; }
.modal-content { background-color: var(--surface); padding: 2rem; border-radius: 8px; width: 90%; max-width: 500px; }
.modal-content input { width: calc(100% - 2rem); padding: 0.75rem; margin: 1rem 0; border-radius: 8px; border: 1px solid var(--border); background-color: #2a2a2a; color: var(--text-primary);}
.modal-actions { margin-top: 1rem; text-align: right; }
.modal-actions .btn { margin-left: 0.5rem; }
#previewContent img, #previewContent video { max-width: 100%; border-radius: 8px; }
</style>
"""
def get_scripts(self, current_request_path):
return f"""
<script>
const currentPath = {json.dumps(current_request_path)};
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
['dragenter','dragover','dragleave','drop'].forEach(eName => dropZone.addEventListener(eName, e => {{e.preventDefault(); e.stopPropagation();}}, false));
['dragenter','dragover'].forEach(eName => dropZone.addEventListener(eName, () => dropZone.style.borderColor='var(--primary)'));
['dragleave','drop'].forEach(eName => dropZone.addEventListener(eName, () => dropZone.style.borderColor='var(--border)'));
dropZone.addEventListener('drop', e => handleFiles(e.dataTransfer.files));
fileInput.addEventListener('change', () => handleFiles(fileInput.files));
function handleFiles(files) {{
const formData = new FormData();
formData.append('path', currentPath);
for (let file of files) formData.append('file', file);
fetch('/', {{ method: 'POST', body: formData }}).then(res => {{ if(res.ok) window.location.reload(); else alert("Upload failed."); }});
}}
function deleteItem(name) {{
if (confirm('Are you sure you want to delete: ' + name + '?')) {{
const data = {{ action: 'delete', path: currentPath, name: name }};
console.log('Delete data:', data);
fetch('/', {{
method: 'POST', headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify(data)
}}).then(res => {{
console.log('Delete response status:', res.status);
res.ok ? window.location.reload() : alert('Error deleting item. Status: ' + res.status);
}})
.catch(err => {{
console.error('Delete error:', err);
alert('Error deleting item: ' + err.message);
}});
}}
}}
function renameItem(oldName) {{
const newName = prompt('Enter new name for:', oldName);
if (newName && newName !== oldName) {{
const data = {{ action: 'rename', path: currentPath, old_name: oldName, new_name: newName }};
fetch('/', {{
method: 'POST', headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify(data)
}}).then(res => {{
res.ok ? window.location.reload() : alert("Rename failed.");
}})
.catch(err => {{
alert('Error renaming item: ' + err.message);
}});
}}
}}
function createFolder() {{
const folderName = document.getElementById('folderName').value;
if (folderName) {{
const data = {{ action: 'create_folder', path: currentPath, name: folderName }};
fetch('/', {{
method: 'POST', headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify(data)
}}).then(res => {{
res.ok ? window.location.reload() : alert("Folder creation failed.");
}})
.catch(err => {{
alert('Error creating folder: ' + err.message);
}});
}}
}}
function showModal(id) {{
document.getElementById(id).style.display = 'flex';
}}
function closeModal(id) {{
document.getElementById(id).style.display = 'none';
}}
</script>
"""
def do_GET(self):
if self.path == '/favicon.svg':
try:
with open('favicon.svg', 'rb') as f:
self.send_response(200)
self.send_header('Content-type', 'image/svg+xml')
self.end_headers()
self.wfile.write(f.read())
return
except FileNotFoundError:
self.send_error(404, "Favicon not found")
return
if self.path == '/favicon.ico':
self.send_response(204) # No Content
self.end_headers()
return
# This is a security measure to prevent directory traversal attacks
# It ensures that the requested path is within the current working directory
if '..' in self.path:
self.send_error(403, "Forbidden")
return
super().do_GET()
def run(server_class=HTTPServer, handler_class=CustomHandler, port=8001):
server_address = ('', port)
httpd = server_class(server_address, handler_class)
print(f"Serving on http://0.0.0.0:{port}")
httpd.serve_forever()
if __name__ == '__main__':
run()
If you want to have this run perpetually, install screen and run the following in terminal:
$ screen -s pyserver
$ python3 pyserver.py
You can escape screen and leave pyserver running by holding CTRL then hitting A then D.
Questions or comments?