Skip to content

Instantly share code, notes, and snippets.

@john-clark
Created July 27, 2026 02:53
Show Gist options
  • Select an option

  • Save john-clark/73f4c9c9c1f9df059e4e2362d0e5ffee to your computer and use it in GitHub Desktop.

Select an option

Save john-clark/73f4c9c9c1f9df059e4e2362d0e5ffee to your computer and use it in GitHub Desktop.
simple web file server for homelab use
import os
import io
import re
import stat
import math
import shutil
import asyncio
import zipfile
import pathlib
import datetime
import mimetypes
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Form, Query
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
import uvicorn
# ---------------------------------------------------------------------------
# Server / App Setup
# ---------------------------------------------------------------------------
app = FastAPI(title="Local LAN File Manager")
# Root directory for file server - defaults to current working directory
ROOT_DIR = os.path.abspath(os.getcwd())
def safe_path(rel_path: str) -> str:
"""
Resolves relative path against ROOT_DIR and strictly prevents directory traversal.
"""
if not rel_path or rel_path == "/":
return ROOT_DIR
clean_path = rel_path.lstrip("/\\")
target_path = os.path.abspath(os.path.join(ROOT_DIR, clean_path))
if not target_path.startswith(ROOT_DIR):
raise HTTPException(status_code=403, detail="Access denied: Directory traversal detected")
return target_path
def get_file_metadata(path: str, rel_path: str):
"""Generates detailed file metadata dictionary."""
st = os.stat(path)
mode = st.st_mode
is_dir = stat.S_ISDIR(mode)
owner = "unknown"
try:
import pwd
owner = pwd.getpwuid(st.st_uid).pw_name
except Exception:
owner = str(st.st_uid)
return {
"name": os.path.basename(path) or "/",
"path": rel_path,
"is_dir": is_dir,
"size": st.st_size if not is_dir else 0,
"modified": datetime.datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
"permissions": oct(mode & 0o777)[2:],
"owner": owner,
"extension": os.path.splitext(path)[1].lower() if not is_dir else ""
}
# ---------------------------------------------------------------------------
# API Endpoints
# ---------------------------------------------------------------------------
@app.get("/api/directory")
async def list_directory(path: str = Query("")):
target = safe_path(path)
if not os.path.exists(target):
raise HTTPException(status_code=404, detail="Path not found")
if not os.path.isdir(target):
raise HTTPException(status_code=400, detail="Path is not a directory")
items = []
try:
for entry in os.scandir(target):
entry_rel = os.path.relpath(entry.path, ROOT_DIR).replace("\\", "/")
items.append(get_file_metadata(entry.path, "/" + entry_rel))
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied reading directory")
items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
return {"path": path or "/", "items": items}
@app.get("/api/file")
async def read_file(path: str = Query("")):
"""
Returns file metadata and content (if under 10MB) or an indicator if the file
is large and should be streamed via /api/file-stream.
Always returns JSON.
"""
target = safe_path(path)
if not os.path.exists(target) or os.path.isdir(target):
raise HTTPException(status_code=404, detail="File not found")
st = os.stat(target)
meta = get_file_metadata(target, path)
# If file is larger than 10MB, return JSON with a flag indicating it is large
if st.st_size > 10 * 1024 * 1024:
return JSONResponse({"is_large": True, "content": None, "meta": meta})
try:
with open(target, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
return JSONResponse({"is_large": False, "content": content, "meta": meta})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/file-stream")
async def stream_file(path: str = Query("")):
"""
Dedicated endpoint to stream raw file content chunk-by-chunk for large files.
"""
target = safe_path(path)
if not os.path.exists(target) or os.path.isdir(target):
raise HTTPException(status_code=404, detail="File not found")
def iterfile():
with open(target, 'rb') as f:
while chunk := f.read(64 * 1024):
yield chunk
return StreamingResponse(iterfile(), media_type="text/plain; charset=utf-8")
@app.put("/api/file")
async def save_file(request: Request):
data = await request.json()
rel_path = data.get("path", "")
content = data.get("content", "")
target = safe_path(rel_path)
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, 'w', encoding='utf-8') as f:
f.write(content)
return {"status": "success", "meta": get_file_metadata(target, rel_path)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/upload")
async def upload_file(files: List[UploadFile] = File(...), paths: List[str] = Form(...)):
uploaded = []
for file, target_rel in zip(files, paths):
target = safe_path(target_rel)
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, 'wb') as f:
while chunk := await file.read(64 * 1024):
f.write(chunk)
uploaded.append(target_rel)
return {"status": "success", "uploaded": uploaded}
@app.delete("/api/file")
async def delete_item(path: str = Query("")):
target = safe_path(path)
if not os.path.exists(target):
raise HTTPException(status_code=404, detail="Item not found")
try:
if os.path.isdir(target):
shutil.rmtree(target)
else:
os.remove(target)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/move")
async def move_item(request: Request):
data = await request.json()
src = safe_path(data.get("src", ""))
dst = safe_path(data.get("dst", ""))
try:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.move(src, dst)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/copy")
async def copy_item(request: Request):
data = await request.json()
src = safe_path(data.get("src", ""))
dst = safe_path(data.get("dst", ""))
try:
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/mkdir")
async def make_directory(request: Request):
data = await request.json()
target = safe_path(data.get("path", ""))
try:
os.makedirs(target, exist_ok=True)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/newfile")
async def new_file(request: Request):
data = await request.json()
target = safe_path(data.get("path", ""))
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, 'a') as f:
pass
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/search")
async def search_files(
query: str = Query(...),
path: str = Query(""),
is_content: bool = Query(False),
is_regex: bool = Query(False),
case_sensitive: bool = Query(False),
whole_word: bool = Query(False)
):
target_base = safe_path(path)
results = []
flags = 0 if case_sensitive else re.IGNORECASE
pattern_str = query
if whole_word and not is_regex:
pattern_str = r'\b' + re.escape(query) + r'\b'
elif not is_regex:
pattern_str = re.escape(query)
try:
regex = re.compile(pattern_str, flags)
except re.error as e:
raise HTTPException(status_code=400, detail=f"Invalid regex: {str(e)}")
for root, dirs, files in os.walk(target_base):
for file in files:
full_path = os.path.join(root, file)
rel_path = "/" + os.path.relpath(full_path, ROOT_DIR).replace("\\", "/")
if not is_content:
if regex.search(file):
results.append({"path": rel_path, "name": file, "match": "Filename match"})
else:
try:
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
for line_num, line in enumerate(f, 1):
if regex.search(line):
results.append({
"path": rel_path,
"name": file,
"line": line_num,
"match": line.strip()[:100]
})
if len(results) >= 200: # Limit results
break
except Exception:
continue
if len(results) >= 200:
break
return {"results": results}
@app.get("/api/download")
async def download_file(path: str = Query("")):
target = safe_path(path)
if not os.path.exists(target) or os.path.isdir(target):
raise HTTPException(status_code=404, detail="File not found")
filename = os.path.basename(target)
return StreamingResponse(
open(target, 'rb'),
media_type="application/octet-stream",
headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}
)
@app.get("/api/zip")
async def download_zip(path: str = Query("")):
target = safe_path(path)
if not os.path.exists(target):
raise HTTPException(status_code=404, detail="Path not found")
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
if os.path.isfile(target):
zip_file.write(target, os.path.basename(target))
else:
for root, dirs, files in os.walk(target):
for file in files:
full_path = os.path.join(root, file)
arcname = os.path.relpath(full_path, target)
zip_file.write(full_path, arcname)
zip_buffer.seek(0)
folder_name = os.path.basename(target.rstrip("/\\")) or "archive"
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename=\"{folder_name}.zip\""}
)
# ---------------------------------------------------------------------------
# Single-Page UI App Front-End (HTML/CSS/JS)
# ---------------------------------------------------------------------------
HTML_CONTENT = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Manager - Mainsail Dark</title>
<!-- CDN Resources with automatic inline fallbacks -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.39.0/min/vs/loader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
<style>
:root {
--bg-dark: #121316;
--bg-panel: #1a1c23;
--bg-hover: #262933;
--bg-active: #323644;
--accent: #2196f3;
--accent-hover: #1e88e5;
--text-main: #e1e4ea;
--text-muted: #8b949e;
--border: #2d313e;
--danger: #f44336;
--success: #4caf50;
--warning: #ff9800;
--font-mono: 'Consolas', 'Courier New', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Top Bar */
header {
height: 48px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.logo { font-size: 16px; font-weight: bold; color: var(--accent); display: flex; align-items: center; gap: 8px; }
.toolbar { display: flex; gap: 8px; align-items: center; }
/* Buttons & Inputs */
button {
background: var(--bg-hover);
color: var(--text-main);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
button:hover { background: var(--bg-active); border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: var(--accent-hover); }
input[type="text"], select {
background: var(--bg-dark);
color: var(--text-main);
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
outline: none;
}
input[type="text"]:focus { border-color: var(--accent); }
/* Main Workspace Grid */
.workspace {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
.panel {
background: var(--bg-panel);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Resizer Dividers */
.resizer {
width: 4px;
background: var(--border);
cursor: col-resize;
transition: background 0.2s;
z-index: 10;
}
.resizer:hover, .resizer.dragging { background: var(--accent); }
/* Left Panel - Directory Tree & Search */
#left-panel { width: 280px; min-width: 200px; border-right: 1px solid var(--border); }
.panel-header {
padding: 10px 12px;
background: rgba(0,0,0,0.15);
border-bottom: 1px solid var(--border);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
display: flex;
justify-content: space-between;
align-items: center;
}
.tree-container { flex: 1; overflow-y: auto; padding: 6px 0; }
.tree-item {
display: flex;
align-items: center;
padding: 4px 12px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
user-select: none;
gap: 6px;
}
.tree-item:hover { background: var(--bg-hover); }
.tree-item.selected { background: var(--bg-active); color: var(--accent); }
.tree-indent { display: inline-block; width: 14px; }
.icon { width: 16px; text-align: center; display: inline-block; opacity: 0.8; }
/* Search Drawer inside Left Panel */
.search-box { padding: 10px; border-bottom: 1px solid var(--border); background: var(--bg-dark); }
.search-options { display: flex; gap: 8px; margin-top: 6px; font-size: 11px; color: var(--text-muted); flex-wrap: wrap; }
.search-options label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.search-results { max-height: 200px; overflow-y: auto; background: var(--bg-dark); font-size: 12px; }
.search-result-item { padding: 6px 10px; border-bottom: 1px solid var(--border); cursor: pointer; }
.search-result-item:hover { background: var(--bg-hover); }
/* Center Panel - Editor / Preview */
#center-panel { flex: 1; min-width: 300px; display: flex; flex-direction: column; background: var(--bg-dark); }
.editor-tabs {
display: flex;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tab {
padding: 8px 16px;
font-size: 13px;
background: rgba(0,0,0,0.2);
border-right: 1px solid var(--border);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
max-width: 200px;
}
.tab.active { background: var(--bg-dark); color: var(--accent); border-top: 2px solid var(--accent); }
.tab .unsaved { width: 8px; height: 8px; border-radius: 50%; background: var(--warning); display: none; }
.tab.has-unsaved .unsaved { display: inline-block; }
.editor-container { flex: 1; position: relative; width: 100%; height: 100%; }
#monaco-target, #fallback-editor, #image-preview, #markdown-preview {
position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%;
}
#fallback-editor {
width: 100%; height: 100%; background: var(--bg-dark); color: var(--text-main);
border: none; padding: 12px; font-family: var(--font-mono); font-size: 14px; outline: none; resize: none;
display: none;
}
#image-preview { display: none; justify-content: center; align-items: center; padding: 20px; overflow: auto; }
#image-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
#markdown-preview { display: none; padding: 24px; overflow-y: auto; background: var(--bg-dark); line-height: 1.6; }
/* Right Panel - Metadata Properties */
#right-panel { width: 260px; min-width: 180px; border-left: 1px solid var(--border); }
.prop-group { padding: 12px; border-bottom: 1px solid var(--border); }
.prop-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px; }
.prop-value { font-size: 13px; word-break: break-all; }
/* Context Menu */
.context-menu {
position: fixed;
background: var(--bg-panel);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
border-radius: 4px;
padding: 4px 0;
z-index: 1000;
display: none;
min-width: 160px;
}
.context-menu-item {
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.context-menu-item:hover { background: var(--accent); color: #fff; }
/* Loading Spinner & Upload Progress */
.overlay-spinner {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); display: none; justify-content: center; align-items: center; z-index: 500;
}
.spinner {
width: 40px; height: 40px; border: 4px solid var(--border); border-top: 4px solid var(--accent);
border-radius: 50%; animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.upload-bar {
position: fixed; bottom: 20px; right: 20px; background: var(--bg-panel);
border: 1px solid var(--border); padding: 12px 16px; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: none; width: 300px; z-index: 900;
}
.progress-inner { height: 6px; background: var(--accent); width: 0%; border-radius: 3px; transition: width 0.2s; }
.progress-outer { height: 6px; background: var(--border); border-radius: 3px; margin-top: 8px; overflow: hidden; }
/* Modals */
.modal {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7); display: none; justify-content: center; align-items: center; z-index: 2000;
}
.modal-content {
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 6px;
width: 400px; padding: 20px; display: flex; flex-direction: column; gap: 12px;
}
.modal-buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
</style>
</head>
<body>
<header>
<div class="logo">⚡ Mainsail File Manager</div>
<div class="toolbar">
<button onclick="createNewFile()"><span class="icon">📄</span> New File</button>
<button onclick="createNewFolder()"><span class="icon">📁</span> New Folder</button>
<button onclick="triggerUpload()"><span class="icon">📤</span> Upload</button>
<button onclick="refreshTree()"><span class="icon">🔄</span> Refresh</button>
<button class="primary" onclick="saveCurrentFile()"><span class="icon">💾</span> Save (Ctrl+S)</button>
</div>
</header>
<div class="workspace" id="drag-drop-zone">
<!-- Left Panel: Folder Tree & Search -->
<div class="panel" id="left-panel">
<div class="panel-header">
<span>Files</span>
<span id="root-path-label">/</span>
</div>
<div class="search-box">
<input type="text" id="search-input" placeholder="Search..." style="width:100%" onkeyup="if(event.key==='Enter') executeSearch()">
<div class="search-options">
<label><input type="checkbox" id="search-content"> Content</label>
<label><input type="checkbox" id="search-regex"> Regex</label>
<label><input type="checkbox" id="search-case"> Match Case</label>
<label><input type="checkbox" id="search-word"> Whole Word</label>
</div>
</div>
<div class="search-results" id="search-results"></div>
<div class="tree-container" id="tree-container"></div>
</div>
<div class="resizer" id="resizer-1"></div>
<!-- Center Panel: Monaco / Fallback / Image / MD -->
<div class="panel" id="center-panel">
<div class="editor-tabs" id="editor-tabs">
<div class="tab active" id="current-tab">
<span id="tab-title">No File Open</span>
<span class="unsaved"></span>
</div>
</div>
<div class="editor-container">
<div id="monaco-target"></div>
<textarea id="fallback-editor" oninput="markUnsaved()"></textarea>
<div id="image-preview"><img id="img-target" src="" alt="preview"></div>
<div id="markdown-preview"></div>
<div class="overlay-spinner" id="loading-spinner">
<div class="spinner"></div>
</div>
</div>
</div>
<div class="resizer" id="resizer-2"></div>
<!-- Right Panel: Properties -->
<div class="panel" id="right-panel">
<div class="panel-header">Properties</div>
<div class="prop-group">
<div class="prop-label">Filename</div>
<div class="prop-value" id="prop-name">-</div>
</div>
<div class="prop-group">
<div class="prop-label">Full Path</div>
<div class="prop-value" id="prop-path">-</div>
</div>
<div class="prop-group">
<div class="prop-label">Size</div>
<div class="prop-value" id="prop-size">-</div>
</div>
<div class="prop-group">
<div class="prop-label">Modified</div>
<div class="prop-value" id="prop-modified">-</div>
</div>
<div class="prop-group">
<div class="prop-label">Permissions</div>
<div class="prop-value" id="prop-perms">-</div>
</div>
<div class="prop-group">
<div class="prop-label">Owner</div>
<div class="prop-value" id="prop-owner">-</div>
</div>
</div>
</div>
<!-- Context Menu -->
<div class="context-menu" id="context-menu">
<div class="context-menu-item" onclick="ctxOpen()"><span class="icon">📖</span> Open</div>
<div class="context-menu-item" onclick="ctxDownload()"><span class="icon">⬇️</span> Download</div>
<div class="context-menu-item" onclick="ctxRename()"><span class="icon">✏️</span> Rename</div>
<div class="context-menu-item" onclick="ctxDuplicate()"><span class="icon">📋</span> Duplicate</div>
<div class="context-menu-item" onclick="ctxDelete()"><span class="icon">🗑️</span> Delete</div>
</div>
<!-- Upload Progress Bar -->
<div class="upload-bar" id="upload-bar">
<div style="font-size:13px; font-weight:600;" id="upload-status">Uploading files...</div>
<div class="progress-outer"><div class="progress-inner" id="upload-progress"></div></div>
</div>
<!-- General Input Modal -->
<div class="modal" id="input-modal">
<div class="modal-content">
<h3 id="modal-title">Input</h3>
<input type="text" id="modal-input">
<div class="modal-buttons">
<button onclick="closeModal()">Cancel</button>
<button class="primary" id="modal-confirm">Confirm</button>
</div>
</div>
</div>
<input type="file" id="file-input" multiple style="display:none;" onchange="handleFileSelect(event)">
<script>
// App State
let currentFile = null;
let activeTreeItem = null;
let isUnsaved = false;
let monacoEditor = null;
let isMonacoLoaded = false;
let expandedFolders = new Set(['/']);
// Initialize Monaco Editor with CDN / Fallback handling
function initEditor() {
if (typeof require !== 'undefined') {
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.39.0/min/vs' }});
require(['vs/editor/editor.main'], function() {
monacoEditor = monaco.editor.create(document.getElementById('monaco-target'), {
value: '',
language: 'plaintext',
theme: 'vs-dark',
automaticLayout: true,
fontFamily: 'Consolas, monospace',
fontSize: 13
});
monacoEditor.onDidChangeModelContent(() => {
markUnsaved();
});
// Ctrl+S Keyboard Shortcut
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function() {
saveCurrentFile();
});
isMonacoLoaded = true;
}, function(err) {
// Fallback to embedded plain textarea
useFallbackEditor();
});
} else {
useFallbackEditor();
}
}
function useFallbackEditor() {
document.getElementById('monaco-target').style.display = 'none';
document.getElementById('fallback-editor').style.display = 'block';
}
function setEditorValue(val, ext) {
hidePreviews();
if (isMonacoLoaded && monacoEditor) {
document.getElementById('monaco-target').style.display = 'block';
const lang = getLanguageFromExtension(ext);
monaco.editor.setModelLanguage(monacoEditor.getModel(), lang);
monacoEditor.setValue(val);
} else {
const fb = document.getElementById('fallback-editor');
fb.style.display = 'block';
fb.value = val;
}
isUnsaved = false;
updateTabState();
}
function getEditorValue() {
if (isMonacoLoaded && monacoEditor && document.getElementById('monaco-target').style.display !== 'none') {
return monacoEditor.getValue();
}
return document.getElementById('fallback-editor').value;
}
function getLanguageFromExtension(ext) {
const map = {
'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'html': 'html',
'css': 'css', 'json': 'json', 'md': 'markdown', 'sh': 'shell', 'yaml': 'yaml',
'yml': 'yaml', 'xml': 'xml', 'c': 'c', 'cpp': 'cpp', 'rs': 'rust'
};
return map[ext.replace('.', '')] || 'plaintext';
}
function hidePreviews() {
document.getElementById('monaco-target').style.display = 'none';
document.getElementById('fallback-editor').style.display = 'none';
document.getElementById('image-preview').style.display = 'none';
document.getElementById('markdown-preview').style.display = 'none';
}
// Tree View Rendering
async function loadDirectory(path = '') {
showSpinner(true);
try {
const res = await fetch(`/api/directory?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error(await res.text());
return await res.json();
} catch (err) {
alert('Error loading directory: ' + err.message);
} finally {
showSpinner(false);
}
}
async function refreshTree() {
const container = document.getElementById('tree-container');
container.innerHTML = '';
const rootData = await loadDirectory('/');
if (rootData) {
renderTreeNodes(rootData.items, container, 0);
}
}
async function renderTreeNodes(items, parentEl, depth) {
for (const item of items) {
const row = document.createElement('div');
row.className = 'tree-item';
row.dataset.path = item.path;
row.dataset.isDir = item.is_dir;
// Indentation
for (let i = 0; i < depth; i++) {
const indent = document.createElement('span');
indent.className = 'tree-indent';
row.appendChild(indent);
}
// Expand/Collapse Icon or File Icon
const icon = document.createElement('span');
icon.className = 'icon';
if (item.is_dir) {
icon.textContent = expandedFolders.has(item.path) ? '📂' : '📁';
} else {
icon.textContent = getFileIcon(item.extension);
}
row.appendChild(icon);
// Label
const label = document.createElement('span');
label.textContent = item.name;
row.appendChild(label);
// Selection & Double click
row.onclick = (e) => {
e.stopPropagation();
selectTreeItem(row, item);
if (item.is_dir) toggleFolder(item.path);
};
row.ondblclick = (e) => {
e.stopPropagation();
if (!item.is_dir) openFile(item.path, item.extension);
};
// Right click Context Menu
row.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
selectTreeItem(row, item);
showContextMenu(e.clientX, e.clientY);
};
parentEl.appendChild(row);
// Render children recursively if folder is expanded
if (item.is_dir && expandedFolders.has(item.path)) {
const childData = await loadDirectory(item.path);
if (childData && childData.items) {
const childContainer = document.createElement('div');
parentEl.appendChild(childContainer);
await renderTreeNodes(childData.items, childContainer, depth + 1);
}
}
}
}
function getFileIcon(ext) {
if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(ext)) return '🖼️';
if (['.md', '.txt'].includes(ext)) return '📝';
if (['.py', '.js', '.ts', '.html', '.css', '.json'].includes(ext)) return '💻';
return '📄';
}
async function toggleFolder(path) {
if (expandedFolders.has(path)) {
expandedFolders.delete(path);
} else {
expandedFolders.add(path);
}
refreshTree();
}
function selectTreeItem(element, meta) {
if (activeTreeItem) activeTreeItem.classList.remove('selected');
element.classList.add('selected');
activeTreeItem = element;
updateProperties(meta);
}
function updateProperties(meta) {
document.getElementById('prop-name').textContent = meta.name || '-';
document.getElementById('prop-path').textContent = meta.path || '-';
document.getElementById('prop-size').textContent = meta.is_dir ? 'Directory' : formatBytes(meta.size);
document.getElementById('prop-modified').textContent = meta.modified || '-';
document.getElementById('prop-perms').textContent = meta.permissions || '-';
document.getElementById('prop-owner').textContent = meta.owner || '-';
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// File Operations
async function openFile(path, ext) {
showSpinner(true);
currentFile = path;
document.getElementById('tab-title').textContent = path.split('/').pop();
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'];
if (imageExts.includes(ext)) {
hidePreviews();
const imgDiv = document.getElementById('image-preview');
const img = document.getElementById('img-target');
img.src = `/api/download?path=${encodeURIComponent(path)}&t=${Date.now()}`;
imgDiv.style.display = 'flex';
showSpinner(false);
return;
}
try {
// /api/file now strictly returns JSON for metadata & small contents
const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
let contentText = "";
if (data.is_large) {
// Fetch plain-text content from dedicated streaming endpoint
const streamRes = await fetch(`/api/file-stream?path=${encodeURIComponent(path)}`);
if (!streamRes.ok) throw new Error(await streamRes.text());
contentText = await streamRes.text();
} else {
contentText = data.content;
}
if (ext === '.md' && typeof marked !== 'undefined') {
hidePreviews();
const mdDiv = document.getElementById('markdown-preview');
mdDiv.innerHTML = marked.parse(contentText);
mdDiv.style.display = 'block';
} else {
setEditorValue(contentText, ext);
}
} catch (err) {
alert('Error opening file: ' + err.message);
} finally {
showSpinner(false);
}
}
async function saveCurrentFile() {
if (!currentFile) return;
const content = getEditorValue();
showSpinner(true);
try {
const res = await fetch('/api/file', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentFile, content: content })
});
if (!res.ok) throw new Error(await res.text());
isUnsaved = false;
updateTabState();
} catch (err) {
alert('Error saving file: ' + err.message);
} finally {
showSpinner(false);
}
}
function markUnsaved() {
isUnsaved = true;
updateTabState();
}
function updateTabState() {
const tab = document.getElementById('current-tab');
if (isUnsaved) {
tab.classList.add('has-unsaved');
} else {
tab.classList.remove('has-unsaved');
}
}
// Context Menu Handlers
function showContextMenu(x, y) {
const menu = document.getElementById('context-menu');
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.display = 'block';
}
document.addEventListener('click', () => {
document.getElementById('context-menu').style.display = 'none';
});
function getSelectedPath() {
return activeTreeItem ? activeTreeItem.dataset.path : '/';
}
function ctxOpen() {
if (!activeTreeItem) return;
const path = activeTreeItem.dataset.path;
const isDir = activeTreeItem.dataset.isDir === 'true';
if (isDir) toggleFolder(path);
else openFile(path, '.' + path.split('.').pop());
}
function ctxDownload() {
const path = getSelectedPath();
const isDir = activeTreeItem && activeTreeItem.dataset.isDir === 'true';
if (isDir) {
window.location.href = `/api/zip?path=${encodeURIComponent(path)}`;
} else {
window.location.href = `/api/download?path=${encodeURIComponent(path)}`;
}
}
function ctxRename() {
const path = getSelectedPath();
const oldName = path.split('/').pop();
showModal('Rename Item', oldName, async (newName) => {
if (!newName || newName === oldName) return;
const parent = path.substring(0, path.lastIndexOf('/'));
const newPath = (parent ? parent : '') + '/' + newName;
await fetch('/api/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ src: path, dst: newPath })
});
refreshTree();
});
}
function ctxDuplicate() {
const path = getSelectedPath();
const isDir = activeTreeItem && activeTreeItem.dataset.isDir === 'true';
const copyPath = path + '_copy';
fetch('/api/copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ src: path, dst: copyPath })
}).then(() => refreshTree());
}
function ctxDelete() {
const path = getSelectedPath();
if (confirm(`Are you sure you want to delete ${path}?`)) {
fetch(`/api/file?path=${encodeURIComponent(path)}`, { method: 'DELETE' })
.then(() => refreshTree());
}
}
function createNewFile() {
const baseDir = getSelectedPath();
showModal('New File Name', 'untitled.txt', async (fileName) => {
if (!fileName) return;
const targetPath = (baseDir === '/' ? '' : baseDir) + '/' + fileName;
await fetch('/api/newfile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: targetPath })
});
refreshTree();
});
}
function createNewFolder() {
const baseDir = getSelectedPath();
showModal('New Folder Name', 'New_Folder', async (folderName) => {
if (!folderName) return;
const targetPath = (baseDir === '/' ? '' : baseDir) + '/' + folderName;
await fetch('/api/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: targetPath })
});
refreshTree();
});
}
// Search Execution
async function executeSearch() {
const query = document.getElementById('search-input').value;
if (!query) return;
const isContent = document.getElementById('search-content').checked;
const isRegex = document.getElementById('search-regex').checked;
const isCase = document.getElementById('search-case').checked;
const isWord = document.getElementById('search-word').checked;
const url = `/api/search?query=${encodeURIComponent(query)}&is_content=${isContent}&is_regex=${isRegex}&case_sensitive=${isCase}&whole_word=${isWord}`;
showSpinner(true);
try {
const res = await fetch(url);
const data = await res.json();
const container = document.getElementById('search-results');
container.innerHTML = '';
if (data.results.length === 0) {
container.innerHTML = '<div style="padding:8px; color:var(--text-muted)">No matches found</div>';
return;
}
data.results.forEach(item => {
const div = document.createElement('div');
div.className = 'search-result-item';
div.innerHTML = `<strong>${item.name}</strong> <span style="color:var(--text-muted)">${item.path}</span>`;
div.onclick = () => openFile(item.path, '.' + item.name.split('.').pop());
container.appendChild(div);
});
} catch (err) {
alert('Search error: ' + err.message);
} finally {
showSpinner(false);
}
}
// Drag & Drop Upload Handlers
const dropZone = document.getElementById('drag-drop-zone');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
dropZone.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
uploadFilesArray(Array.from(files));
}
function triggerUpload() {
document.getElementById('file-input').click();
}
function handleFileSelect(e) {
const files = Array.from(e.target.files);
uploadFilesArray(files);
}
async function uploadFilesArray(files) {
if (files.length === 0) return;
const formData = new FormData();
const targetDir = getSelectedPath();
files.forEach(file => {
formData.append('files', file);
const relPath = (targetDir === '/' ? '' : targetDir) + '/' + file.name;
formData.append('paths', relPath);
});
const bar = document.getElementById('upload-bar');
const progress = document.getElementById('upload-progress');
bar.style.display = 'block';
progress.style.width = '30%';
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
progress.style.width = '100%';
setTimeout(() => { bar.style.display = 'none'; }, 1000);
refreshTree();
} catch (err) {
alert('Upload failed: ' + err.message);
bar.style.display = 'none';
}
}
// Modal Helpers
function showModal(title, defaultValue, callback) {
const modal = document.getElementById('input-modal');
const input = document.getElementById('modal-input');
document.getElementById('modal-title').textContent = title;
input.value = defaultValue;
modal.style.display = 'flex';
input.focus();
document.getElementById('modal-confirm').onclick = () => {
modal.style.display = 'none';
callback(input.value);
};
}
function closeModal() {
document.getElementById('input-modal').style.display = 'none';
}
function showSpinner(show) {
document.getElementById('loading-spinner').style.display = show ? 'flex' : 'none';
}
// Resizable Panel Logic
function makeResizable(resizer, leftPanel, rightPanel) {
let x = 0;
let leftWidth = 0;
const mouseDownHandler = function(e) {
x = e.clientX;
leftWidth = leftPanel.getBoundingClientRect().width;
resizer.classList.add('dragging');
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
};
const mouseMoveHandler = function(e) {
const dx = e.clientX - x;
const newWidth = leftWidth + dx;
if (newWidth > 150 && newWidth < 600) {
leftPanel.style.width = `${newWidth}px`;
}
};
const mouseUpHandler = function() {
resizer.classList.remove('dragging');
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
};
resizer.addEventListener('mousedown', mouseDownHandler);
}
makeResizable(document.getElementById('resizer-1'), document.getElementById('left-panel'));
makeResizable(document.getElementById('resizer-2'), document.getElementById('center-panel'), document.getElementById('right-panel'));
// Initialize App
window.onload = () => {
initEditor();
refreshTree();
};
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
async def serve_index():
return HTML_CONTENT
# ---------------------------------------------------------------------------
# Main Execution Entry
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print("Starting LAN File Manager server on http://0.0.0.0:8000 ...")
uvicorn.run(app, host="0.0.0.0", port=8000)
@john-clark

Copy link
Copy Markdown
Author

Gemini writing prompt:

 You are an expert Python full-stack developer.

Create a production-quality single-file Python application named fileserver.py.


Goal

Build a modern web-based file manager that behaves similarly to the file browser/editor in Klipper's Mainsail interface.

This application is intended ONLY for a trusted local LAN.

There is NO authentication whatsoever.

The application simply runs as the Linux user that starts it and therefore inherits that user's filesystem permissions.

Running it should be as simple as:



python3 fileserver.py


Everything should must be embedded directly inside the Python file.

Do NOT create additional files.

Design Requirements

The UI should resemble:

Klipper Mainsail file editor

VS Code dark theme

Dark theme only.

Responsive.

No Bootstrap.

No jQuery.

Use plain JavaScript.

Use CSS Grid/Flexbox.

Layout

Three-panel layout.

Left panel:



Folder tree


Expand/collapse


Right-click context menu


Drag/drop support

Center panel:

Monaco Editor

Requirements:



syntax highlighting


auto indentation


search


replace


Ctrl+S


unsaved file indicator

Right panel:

Properties panel

Display



filename


size


modified date


permissions


owner


path

File Operations

Support

Browse folders

Open file

Save file

Download

Upload

Drag/drop upload

Rename

Delete

Move

Copy

New file

New folder

Recursive delete

Recursive copy

Recursive move

Refresh

Duplicate file

Duplicate folder

Search

Provide a search box.

Support

Filename search

Content search

Regex search

Case sensitive toggle

Whole word toggle

Show results with clickable filenames.

Upload

Drag files anywhere.

Show upload progress.

Allow multiple files.

Allow folder uploads.

Downloads

Single click download.

Download folders as ZIP generated on the fly.

Images

Preview

png

jpg

jpeg

gif

svg

webp

Markdown

Preview markdown.

Split editor/preview mode.

Text Files

Support very large files.

Do not load entire file into memory if unnecessary.

Monaco

Load Monaco Editor from CDN.

If offline,

fall back to Ace Editor embedded inside the Python source.

API

Use REST.

Implement:

GET directory

GET file

PUT file

POST upload

DELETE file

MOVE

COPY

MKDIR

NEWFILE

SEARCH

DOWNLOAD

ZIP

Every endpoint should return JSON except downloads.

Security

Prevent

Directory traversal


..



Use asyncio where appropriate.

Context menus.

Keyboard shortcuts.

Loading spinner.

Dark icons.

Resizable panels.

Code Quality

Everything in ONE Python file.

No generated code.

No placeholder functions.

No TODO comments.

No pseudocode.

No omitted sections.

No "...".

No "implement later."

Everything must be fully implemented.

Deliverables

Produce ONLY the Python source.

No explanations.

No markdown.

No commentary.

No partial implementation.

The result must run immediately with:


pip install fastapi uvicorn


python3 fileserver.py


and provide a complete working application.


If the implementation exceeds the model's output limit, continue automatically across multiple sequential responses without waiting for user input. Number each part (e.g., Part 1 of N, Part 2 of N), and do not stop until the entire fileserver.py source has been emitted. 

@john-clark

Copy link
Copy Markdown
Author

Console

image

Web

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment