From 9f97d9a931b2ba84173d6ebf898a8335d23ba301 Mon Sep 17 00:00:00 2001 From: SAD LIFE Date: Mon, 27 Jul 2026 21:54:19 +0530 Subject: [PATCH 1/5] feat(dashboard): add file upload support --- fenn/dashboard/app.py | 116 ++++++++++++++++- fenn/dashboard/static/app.js | 124 +++++++++++++++++++ fenn/dashboard/templates/base.html | 6 +- fenn/dashboard/templates/uploads.html | 86 +++++++++++++ tests/unit/dashboard/test_app.py | 171 +++++++++++++++++++++++++- 5 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 fenn/dashboard/templates/uploads.html diff --git a/fenn/dashboard/app.py b/fenn/dashboard/app.py index f428a0f..2e5a3c1 100644 --- a/fenn/dashboard/app.py +++ b/fenn/dashboard/app.py @@ -1,5 +1,3 @@ -"""Fenn Dashboard — Flask application for browsing fnxml log files.""" - from __future__ import annotations import argparse @@ -25,6 +23,7 @@ ) from flask_wtf.csrf import CSRFError, CSRFProtect from werkzeug.exceptions import HTTPException +from werkzeug.utils import secure_filename from fenn.cli.list import get_available_templates from fenn.cli.pull import pull_template @@ -64,6 +63,8 @@ from scanner import FennScanner # ty: ignore[unresolved-import] _HERE = Path(__file__).parent +_DEFAULT_UPLOAD_DIR = Path.home() / ".fenn" / "uploads" +_DEFAULT_MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB app = Flask( __name__, @@ -81,6 +82,8 @@ SESSION_COOKIE_SECURE=False, # localhost-only — HTTPS not in scope PERMANENT_SESSION_LIFETIME=timedelta(hours=12), WTF_CSRF_TIME_LIMIT=None, + UPLOAD_FOLDER=_DEFAULT_UPLOAD_DIR, + MAX_CONTENT_LENGTH=_DEFAULT_MAX_UPLOAD_SIZE, ) # CSRF on /connect/start and /logout. Even though we listen on 127.0.0.1, any @@ -230,6 +233,115 @@ def short_id_filter(session_id: str) -> str: # --------------------------------------------------------------------------- # # Routes # --------------------------------------------------------------------------- # +@app.route("/uploads") +def uploads_page() -> str: + """Render the dashboard file upload page.""" + return render_template( + "uploads.html", + projects=scanner.get_overview()["projects"], + active_page="uploads", + ) + + +@app.route("/api/uploads", methods=["GET", "POST"]) +@app.route("/api/uploads", methods=["GET", "POST"]) +def api_upload_file() -> tuple[Response, int] | Response: + """List uploaded files or upload a new file.""" + + upload_directory = Path(app.config["UPLOAD_FOLDER"]) + + if request.method == "GET": + try: + upload_directory.mkdir(parents=True, exist_ok=True) + + files = [] + + for path in upload_directory.iterdir(): + if not path.is_file(): + continue + + stat = path.stat() + + files.append( + { + "filename": path.name, + "size": stat.st_size, + "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat( + timespec="seconds" + ), + } + ) + + files.sort( + key=lambda file: file["modified_at"], + reverse=True, + ) + + except OSError as exc: + return filesystem_error(exc), 500 + + return jsonify( + { + "files": files, + "total": len(files), + } + ) + + if "file" not in request.files: + return _api_error( + "missing_file", + "No file was provided", + "file", + ) + + uploaded_file = request.files["file"] + + if uploaded_file.filename is None or uploaded_file.filename.strip() == "": + return _api_error( + "invalid_file", + "A filename is required", + "file", + ) + + filename = secure_filename(uploaded_file.filename) + + if not filename: + return _api_error( + "invalid_filename", + "The filename is not valid", + "file", + ) + + try: + upload_directory.mkdir(parents=True, exist_ok=True) + + destination = upload_directory / filename + + if destination.exists(): + return ( + error_response( + "file_exists", + f"A file named '{filename}' already exists", + "file", + ), + 409, + ) + + uploaded_file.save(destination) + + except OSError as exc: + return filesystem_error(exc), 500 + + return ( + jsonify( + { + "uploaded": True, + "filename": filename, + "size": destination.stat().st_size, + } + ), + 201, + ) @app.route("/") diff --git a/fenn/dashboard/static/app.js b/fenn/dashboard/static/app.js index 1a1a156..f806c69 100644 --- a/fenn/dashboard/static/app.js +++ b/fenn/dashboard/static/app.js @@ -668,3 +668,127 @@ updateCount(); })(); + +const uploadForm = document.getElementById("upload-form"); + +if (uploadForm) { + const fileInput = document.getElementById("upload-file"); + const submitButton = document.getElementById("upload-submit"); + const statusElement = document.getElementById("upload-status"); + const csrfToken = document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute("content"); + + uploadForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + if (!fileInput.files.length) { + statusElement.hidden = false; + statusElement.textContent = "Please select a file."; + return; + } + + const formData = new FormData(); + formData.append("file", fileInput.files[0]); + + submitButton.disabled = true; + submitButton.textContent = "Uploading..."; + + statusElement.hidden = false; + statusElement.textContent = "Uploading file..."; + + try { + const response = await fetch("/api/uploads", { + method: "POST", + headers: { + "X-CSRFToken": csrfToken, + }, + body: formData, + }); + + const data = await response.json(); + + if (!response.ok) { + const message = + data?.error?.message || "The file could not be uploaded."; + + throw new Error(message); + } + + const sizeKb = (data.size / 1024).toFixed(1); + + statusElement.textContent = + `Upload successful: ${data.filename} (${sizeKb} KB)`; + + + uploadForm.reset(); + loadUploadedFiles(); + } catch (error) { + statusElement.textContent = + error instanceof Error + ? error.message + : "The file could not be uploaded."; + } finally { + submitButton.disabled = false; + submitButton.textContent = "Upload File"; + } + }); +} +async function loadUploadedFiles() { + const uploadsList = document.getElementById("uploads-list"); + + if (!uploadsList) { + return; + } + + try { + const response = await fetch("/api/uploads"); + const data = await response.json(); + + if (!response.ok) { + uploadsList.innerHTML = + "

Could not load uploaded files.

"; + return; + } + + if (data.total === 0) { + uploadsList.innerHTML = + "

No uploaded files yet.

"; + return; + } + + let html = ` + + + + + + + + + + `; + + data.files.forEach(file => { + html += ` + + + + + + `; + }); + + html += ` + +
FilenameSizeModified
${file.filename}${(file.size / 1024).toFixed(1)} KB${file.modified_at}
+ `; + + uploadsList.innerHTML = html; + + } catch { + uploadsList.innerHTML = + "

Could not load uploaded files.

"; + } +} +loadUploadedFiles(); diff --git a/fenn/dashboard/templates/base.html b/fenn/dashboard/templates/base.html index 4144f52..289e247 100644 --- a/fenn/dashboard/templates/base.html +++ b/fenn/dashboard/templates/base.html @@ -39,7 +39,11 @@ Templates - + + + Uploads + {% if projects %} {% for p in projects %} diff --git a/fenn/dashboard/templates/uploads.html b/fenn/dashboard/templates/uploads.html new file mode 100644 index 0000000..da945a3 --- /dev/null +++ b/fenn/dashboard/templates/uploads.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} + +{% block title %}Upload Files — Fenn Dashboard{% endblock %} + +{% block breadcrumb %} +Uploads +{% endblock %} + +{% block content %} + + + +
+ Select a File +
+ +
+
+ + +
+ + + + +

+ Maximum upload size: 100 MB. +

+
+ +
+ +
+
+
+ +
+ Uploaded Files +
+ +
+
+

Loading uploaded files...

+
+
+ + +
+ +{% endblock %} \ No newline at end of file diff --git a/tests/unit/dashboard/test_app.py b/tests/unit/dashboard/test_app.py index af1c834..f3cd46a 100644 --- a/tests/unit/dashboard/test_app.py +++ b/tests/unit/dashboard/test_app.py @@ -1,5 +1,5 @@ """Tests for fenn/dashboard/app.py""" - +from io import BytesIO from unittest.mock import MagicMock, patch import pytest @@ -287,6 +287,164 @@ def test_refreshes_token_on_success(self, app): # ══════════════════════════════════════════════════════════════════════════════ +class TestUploadFile: + def test_upload_file_success(self, app, authed_client, tmp_path): + app.config["UPLOAD_FOLDER"] = tmp_path + + response = authed_client.post( + "/api/uploads", + data={ + "file": (BytesIO(b"sample data"), "train.csv"), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 201 + assert response.get_json() == { + "uploaded": True, + "filename": "train.csv", + "size": 11, + } + + uploaded_file = tmp_path / "train.csv" + assert uploaded_file.exists() + assert uploaded_file.read_bytes() == b"sample data" + + def test_upload_without_file_returns_400( + self, + app, + authed_client, + tmp_path, + ): + app.config["UPLOAD_FOLDER"] = tmp_path + + response = authed_client.post( + "/api/uploads", + data={}, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + + body = response.get_json() + assert body["error"]["code"] == "missing_file" + assert body["error"]["param"] == "file" + + def test_upload_with_empty_filename_returns_400( + self, + app, + authed_client, + tmp_path, + ): + app.config["UPLOAD_FOLDER"] = tmp_path + + response = authed_client.post( + "/api/uploads", + data={ + "file": (BytesIO(b"sample data"), ""), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + + body = response.get_json() + assert body["error"]["code"] == "invalid_file" + assert body["error"]["param"] == "file" + + def test_upload_duplicate_file_returns_409( + self, + app, + authed_client, + tmp_path, + ): + app.config["UPLOAD_FOLDER"] = tmp_path + (tmp_path / "train.csv").write_bytes(b"existing data") + + response = authed_client.post( + "/api/uploads", + data={ + "file": (BytesIO(b"new data"), "train.csv"), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 409 + + body = response.get_json() + assert body["error"]["code"] == "file_exists" + + assert (tmp_path / "train.csv").read_bytes() == b"existing data" + + def test_upload_sanitizes_filename(self, app, authed_client, tmp_path): + app.config["UPLOAD_FOLDER"] = tmp_path + + response = authed_client.post( + "/api/uploads", + data={ + "file": (BytesIO(b"sample data"), "../../train.csv"), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 201 + assert response.get_json()["filename"] == "train.csv" + assert (tmp_path / "train.csv").exists() + + def test_upload_invalid_filename_returns_400( + self, + app, + authed_client, + tmp_path, + ): + app.config["UPLOAD_FOLDER"] = tmp_path + + response = authed_client.post( + "/api/uploads", + data={ + "file": (BytesIO(b"sample data"), "../../../"), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + + body = response.get_json() + assert body["error"]["code"] == "invalid_filename" + + def test_list_uploaded_files(self, app, authed_client, tmp_path): + app.config["UPLOAD_FOLDER"] = tmp_path + + (tmp_path / "first.csv").write_bytes(b"abc") + (tmp_path / "second.txt").write_bytes(b"hello") + + response = authed_client.get("/api/uploads") + + assert response.status_code == 200 + + body = response.get_json() + assert body["total"] == 2 + + filenames = {file["filename"] for file in body["files"]} + assert filenames == {"first.csv", "second.txt"} + + for file in body["files"]: + assert "size" in file + assert "modified_at" in file + + def test_list_uploaded_files_empty(self, app, authed_client, tmp_path): + app.config["UPLOAD_FOLDER"] = tmp_path + + response = authed_client.get("/api/uploads") + + assert response.status_code == 200 + assert response.get_json() == { + "files": [], + "total": 0, + } + + + class TestApiSessions: def test_returns_200_with_default_params(self, authed_client): from fenn.dashboard import app as app_mod @@ -387,3 +545,14 @@ def test_protected_endpoint_accessible_with_auth(self, authed_client): ) resp = authed_client.get("/api/sessions") assert resp.status_code == 200 + +class TestUploadsPage: + def test_uploads_page_renders(self, authed_client): + response = authed_client.get("/uploads") + + assert response.status_code == 200 + assert b"Upload Files" in response.data + assert b'id="upload-form"' in response.data + assert b'action="/api/uploads"' in response.data + assert b'method="POST"' in response.data + assert b'name="csrf_token"' in response.data \ No newline at end of file From 3506d58fbabb0e96ea82329613076e1f918e02e2 Mon Sep 17 00:00:00 2001 From: SAD LIFE Date: Tue, 28 Jul 2026 13:12:31 +0530 Subject: [PATCH 2/5] chore: apply pre-commit fixes --- tests/unit/dashboard/test_app.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/unit/dashboard/test_app.py b/tests/unit/dashboard/test_app.py index f3cd46a..706217a 100644 --- a/tests/unit/dashboard/test_app.py +++ b/tests/unit/dashboard/test_app.py @@ -1,4 +1,5 @@ """Tests for fenn/dashboard/app.py""" + from io import BytesIO from unittest.mock import MagicMock, patch @@ -392,10 +393,10 @@ def test_upload_sanitizes_filename(self, app, authed_client, tmp_path): assert (tmp_path / "train.csv").exists() def test_upload_invalid_filename_returns_400( - self, - app, - authed_client, - tmp_path, + self, + app, + authed_client, + tmp_path, ): app.config["UPLOAD_FOLDER"] = tmp_path @@ -444,7 +445,6 @@ def test_list_uploaded_files_empty(self, app, authed_client, tmp_path): } - class TestApiSessions: def test_returns_200_with_default_params(self, authed_client): from fenn.dashboard import app as app_mod @@ -546,6 +546,7 @@ def test_protected_endpoint_accessible_with_auth(self, authed_client): resp = authed_client.get("/api/sessions") assert resp.status_code == 200 + class TestUploadsPage: def test_uploads_page_renders(self, authed_client): response = authed_client.get("/uploads") @@ -555,4 +556,4 @@ def test_uploads_page_renders(self, authed_client): assert b'id="upload-form"' in response.data assert b'action="/api/uploads"' in response.data assert b'method="POST"' in response.data - assert b'name="csrf_token"' in response.data \ No newline at end of file + assert b'name="csrf_token"' in response.data From 82475367dd15c000724c90f8bde56db9a736bc91 Mon Sep 17 00:00:00 2001 From: SAD LIFE Date: Tue, 28 Jul 2026 17:15:25 +0530 Subject: [PATCH 3/5] refactor(dashboard): encapsulate upload handling --- fenn/dashboard/app.py | 234 ++++++++++++++++++++++++++---------------- 1 file changed, 143 insertions(+), 91 deletions(-) diff --git a/fenn/dashboard/app.py b/fenn/dashboard/app.py index 2e5a3c1..a56b976 100644 --- a/fenn/dashboard/app.py +++ b/fenn/dashboard/app.py @@ -22,6 +22,7 @@ url_for, ) from flask_wtf.csrf import CSRFError, CSRFProtect +from werkzeug.datastructures import FileStorage from werkzeug.exceptions import HTTPException from werkzeug.utils import secure_filename @@ -183,6 +184,146 @@ def _parse_int_arg( return v +# --------------------------------------------------------------------------- # +# Helper Functions +# --------------------------------------------------------------------------- # + + +def _list_uploaded_files( + upload_directory: Path, +) -> list[dict[str, str | int]]: + upload_directory.mkdir(parents=True, exist_ok=True) + + files: list[dict[str, str | int]] = [] + + for path in upload_directory.iterdir(): + if not path.is_file(): + continue + + stat = path.stat() + + files.append( + { + "filename": path.name, + "size": stat.st_size, + "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat( + timespec="seconds" + ), + } + ) + + files.sort( + key=lambda file: str(file["modified_at"]), + reverse=True, + ) + + return files + + +def _save_uploaded_file( + upload_directory: Path, + uploaded_file: FileStorage, + filename: str, +) -> Path: + upload_directory.mkdir(parents=True, exist_ok=True) + + destination = upload_directory / filename + + if destination.exists(): + raise FileExistsError(filename) + + uploaded_file.save(destination) + + return destination + + +def _validate_upload_filename( + uploaded_file: FileStorage, +) -> str | tuple[Response, int]: + raw_filename = uploaded_file.filename + + if raw_filename is None or raw_filename.strip() == "": + return _api_error( + "invalid_file", + "A filename is required", + "file", + ) + + filename = secure_filename(raw_filename) + + if not filename: + return _api_error( + "invalid_filename", + "The filename is not valid", + "file", + ) + + return filename + + +def _handle_list_uploads( + upload_directory: Path, +) -> tuple[Response, int] | Response: + try: + files = _list_uploaded_files(upload_directory) + except OSError as exc: + return filesystem_error(exc), 500 + + return jsonify( + { + "files": files, + "total": len(files), + } + ) + + +def _handle_file_upload( + upload_directory: Path, +) -> tuple[Response, int] | Response: + uploaded_file = request.files.get("file") + + if uploaded_file is None: + return _api_error( + "missing_file", + "No file was provided", + "file", + ) + + filename = _validate_upload_filename(uploaded_file) + + if not isinstance(filename, str): + return filename + + try: + destination = _save_uploaded_file( + upload_directory, + uploaded_file, + filename, + ) + except FileExistsError: + return ( + error_response( + "file_exists", + f"A file named '{filename}' already exists", + "file", + ), + 409, + ) + except OSError as exc: + return filesystem_error(exc), 500 + + return ( + jsonify( + { + "uploaded": True, + "filename": filename, + "size": destination.stat().st_size, + } + ), + 201, + ) + + @app.before_request def _require_login() -> werkzeug.wrappers.response.Response | None: endpoint = request.endpoint @@ -243,7 +384,6 @@ def uploads_page() -> str: ) -@app.route("/api/uploads", methods=["GET", "POST"]) @app.route("/api/uploads", methods=["GET", "POST"]) def api_upload_file() -> tuple[Response, int] | Response: """List uploaded files or upload a new file.""" @@ -251,97 +391,9 @@ def api_upload_file() -> tuple[Response, int] | Response: upload_directory = Path(app.config["UPLOAD_FOLDER"]) if request.method == "GET": - try: - upload_directory.mkdir(parents=True, exist_ok=True) - - files = [] - - for path in upload_directory.iterdir(): - if not path.is_file(): - continue - - stat = path.stat() - - files.append( - { - "filename": path.name, - "size": stat.st_size, - "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat( - timespec="seconds" - ), - } - ) - - files.sort( - key=lambda file: file["modified_at"], - reverse=True, - ) - - except OSError as exc: - return filesystem_error(exc), 500 - - return jsonify( - { - "files": files, - "total": len(files), - } - ) - - if "file" not in request.files: - return _api_error( - "missing_file", - "No file was provided", - "file", - ) - - uploaded_file = request.files["file"] + return _handle_list_uploads(upload_directory) - if uploaded_file.filename is None or uploaded_file.filename.strip() == "": - return _api_error( - "invalid_file", - "A filename is required", - "file", - ) - - filename = secure_filename(uploaded_file.filename) - - if not filename: - return _api_error( - "invalid_filename", - "The filename is not valid", - "file", - ) - - try: - upload_directory.mkdir(parents=True, exist_ok=True) - - destination = upload_directory / filename - - if destination.exists(): - return ( - error_response( - "file_exists", - f"A file named '{filename}' already exists", - "file", - ), - 409, - ) - - uploaded_file.save(destination) - - except OSError as exc: - return filesystem_error(exc), 500 - - return ( - jsonify( - { - "uploaded": True, - "filename": filename, - "size": destination.stat().st_size, - } - ), - 201, - ) + return _handle_file_upload(upload_directory) @app.route("/") From 4da2d319619bf6397d5b9acbb752127a6e01d8ee Mon Sep 17 00:00:00 2001 From: SAD LIFE Date: Tue, 28 Jul 2026 17:39:11 +0530 Subject: [PATCH 4/5] fix(dashboard): resolve upload listing imports --- fenn/dashboard/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fenn/dashboard/app.py b/fenn/dashboard/app.py index 81a28a3..63cbd4c 100644 --- a/fenn/dashboard/app.py +++ b/fenn/dashboard/app.py @@ -3,6 +3,7 @@ import argparse import logging import secrets +from datetime import datetime as DateTime from datetime import timedelta from pathlib import Path from typing import Any @@ -207,7 +208,7 @@ def _list_uploaded_files( { "filename": path.name, "size": stat.st_size, - "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat( + "modified_at": DateTime.fromtimestamp(stat.st_mtime).isoformat( timespec="seconds" ), } From 716f0cfb86de415f070c20d7a4b12dc7b6b79aa8 Mon Sep 17 00:00:00 2001 From: SAD LIFE Date: Tue, 28 Jul 2026 18:18:11 +0530 Subject: [PATCH 5/5] refactor(dashboard): use whenever for upload timestamps --- fenn/dashboard/app.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fenn/dashboard/app.py b/fenn/dashboard/app.py index 63cbd4c..d0fa507 100644 --- a/fenn/dashboard/app.py +++ b/fenn/dashboard/app.py @@ -3,7 +3,6 @@ import argparse import logging import secrets -from datetime import datetime as DateTime from datetime import timedelta from pathlib import Path from typing import Any @@ -26,7 +25,7 @@ from werkzeug.datastructures import FileStorage from werkzeug.exceptions import HTTPException from werkzeug.utils import secure_filename -from whenever import PlainDateTime +from whenever import Instant, PlainDateTime from fenn.cli.list import get_available_templates from fenn.cli.pull import pull_template @@ -203,14 +202,17 @@ def _list_uploaded_files( continue stat = path.stat() + modified_at = ( + Instant.from_timestamp(stat.st_mtime) + .round("second") + .format_iso(unit="second") + ) files.append( { "filename": path.name, "size": stat.st_size, - "modified_at": DateTime.fromtimestamp(stat.st_mtime).isoformat( - timespec="seconds" - ), + "modified_at": modified_at, } )