Compare commits
2 Commits
4b551351e1
...
05fcb7f2f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 05fcb7f2f2 | |||
| 65622501fe |
@@ -114,18 +114,26 @@ function handleLog(entry) {
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'log-entry';
|
div.className = 'log-entry';
|
||||||
const time = new Date().toLocaleTimeString();
|
const time = new Date().toLocaleTimeString();
|
||||||
div.innerHTML = `<span class="log-time">${time}</span>${entry}`;
|
const t = document.createElement('span');
|
||||||
|
t.className = 'log-time';
|
||||||
|
t.textContent = time;
|
||||||
|
div.append(t, entry); // text, not HTML — browsers can POST entries now
|
||||||
const feed = document.getElementById('log-entries');
|
const feed = document.getElementById('log-entries');
|
||||||
feed.appendChild(div);
|
feed.appendChild(div);
|
||||||
feed.scrollTop = feed.scrollHeight;
|
feed.scrollTop = feed.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// state.json on disk is the source of truth; localStorage only covers the server being unreachable
|
||||||
|
fetch('state.json', {cache: 'no-store'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(s => { if (Object.keys(s).length) handleState(s); else throw 0; })
|
||||||
|
.catch(() => {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem('workbench-state'));
|
const saved = JSON.parse(localStorage.getItem('workbench-state'));
|
||||||
if (saved) handleState(saved);
|
if (saved) handleState(saved);
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
})
|
||||||
connect();
|
.finally(connect);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+16
-2
@@ -102,12 +102,26 @@ class WorkbenchServer:
|
|||||||
async def static_handler(request):
|
async def static_handler(request):
|
||||||
proj = request.app["project_name"]
|
proj = request.app["project_name"]
|
||||||
path = request.match_info.get("path", "index.html") or "index.html"
|
path = request.match_info.get("path", "index.html") or "index.html"
|
||||||
file_path = project_path(proj, self.workbench_dir) / path
|
root = project_path(proj, self.workbench_dir).resolve()
|
||||||
if not file_path.exists():
|
file_path = (root / path).resolve()
|
||||||
|
# served on the LAN — never let "../" escape the project dir
|
||||||
|
if not file_path.is_relative_to(root) or not file_path.is_file():
|
||||||
return web.Response(status=404, text="Not found")
|
return web.Response(status=404, text="Not found")
|
||||||
return web.FileResponse(file_path)
|
return web.FileResponse(file_path)
|
||||||
|
|
||||||
|
async def log_handler(request):
|
||||||
|
# lets the page record readings, so they outlive the browser
|
||||||
|
body = await request.json()
|
||||||
|
entry = str(body.get("entry", "")).strip()
|
||||||
|
if not entry:
|
||||||
|
return web.json_response({"error": "entry required"}, status=400)
|
||||||
|
data = body.get("data") if isinstance(body.get("data"), dict) else None
|
||||||
|
append_log(request.app["project_name"], entry, data=data, workbench_dir=self.workbench_dir)
|
||||||
|
await self._broadcast_ws(request.app["project_name"], {"type": "log", "entry": entry})
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
app.router.add_get("/ws", ws_handler)
|
app.router.add_get("/ws", ws_handler)
|
||||||
|
app.router.add_post("/log", log_handler)
|
||||||
app.router.add_get("/{path:.*}", static_handler)
|
app.router.add_get("/{path:.*}", static_handler)
|
||||||
app.router.add_get("/", static_handler)
|
app.router.add_get("/", static_handler)
|
||||||
|
|
||||||
|
|||||||
@@ -103,3 +103,40 @@ async def test_reconnect_on_startup(tmp_workbench):
|
|||||||
assert srv._active["live-proj"]["port"] == 8070
|
assert srv._active["live-proj"]["port"] == 8070
|
||||||
assert "dead-proj" not in srv._active
|
assert "dead-proj" not in srv._active
|
||||||
assert not (tmp_workbench / "dead-proj" / ".server.json").exists()
|
assert not (tmp_workbench / "dead-proj" / ".server.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def _live(server, name="p"):
|
||||||
|
import aiohttp
|
||||||
|
from workbench.project import create_project
|
||||||
|
create_project(name, "T", workbench_dir=server.workbench_dir)
|
||||||
|
port = await server._start_http_server(name)
|
||||||
|
return aiohttp.ClientSession(), f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_static_blocks_path_traversal(server, tmp_workbench):
|
||||||
|
(tmp_workbench / "secret.txt").write_text("nope")
|
||||||
|
session, base = await _live(server)
|
||||||
|
async with session:
|
||||||
|
for evil in ["/../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt"]:
|
||||||
|
async with session.get(base + evil, allow_redirects=False) as r:
|
||||||
|
assert r.status == 404, evil
|
||||||
|
async with session.get(base + "/") as r:
|
||||||
|
assert r.status == 200
|
||||||
|
await server.workbench_stop("p")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_post_log_persists_and_broadcasts(server, tmp_workbench):
|
||||||
|
from workbench.project import read_log
|
||||||
|
session, base = await _live(server)
|
||||||
|
async with session:
|
||||||
|
ws = await session.ws_connect(base + "/ws")
|
||||||
|
async with session.post(base + "/log", json={"entry": "R303 3.31M", "data": {"part": "R303"}}) as r:
|
||||||
|
assert r.status == 200
|
||||||
|
msg = await asyncio.wait_for(ws.receive_json(), 3)
|
||||||
|
assert msg == {"type": "log", "entry": "R303 3.31M"}
|
||||||
|
await ws.close()
|
||||||
|
entries = read_log("p", workbench_dir=tmp_workbench)
|
||||||
|
assert entries[-1]["entry"] == "R303 3.31M" and entries[-1]["part"] == "R303"
|
||||||
|
await server.workbench_stop("p")
|
||||||
|
|||||||
Reference in New Issue
Block a user