feat: file edit added

This commit is contained in:
kodi
2026-03-11 14:09:44 +01:00
parent ba6a369f78
commit b93cb01879
18 changed files with 701 additions and 16 deletions
+15 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, ViewResponse
from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse
from backend.app.dependencies import get_file_ops_service
from backend.app.services.file_ops_service import FileOpsService
@@ -36,6 +36,19 @@ async def delete(
@router.get("/view", response_model=ViewResponse)
async def view(
path: str,
for_edit: bool = False,
service: FileOpsService = Depends(get_file_ops_service),
) -> ViewResponse:
return service.view(path=path)
return service.view(path=path, for_edit=for_edit)
@router.post("/save", response_model=SaveResponse)
async def save(
request: SaveRequest,
service: FileOpsService = Depends(get_file_ops_service),
) -> SaveResponse:
return service.save(
path=request.path,
content=request.content,
expected_modified=request.expected_modified,
)
+13
View File
@@ -65,9 +65,22 @@ class ViewResponse(BaseModel):
encoding: str
truncated: bool
size: int
modified: str
content: str
class SaveRequest(BaseModel):
path: str
content: str
expected_modified: str
class SaveResponse(BaseModel):
path: str
size: int
modified: str
class TaskListItem(BaseModel):
id: str
operation: str
@@ -65,11 +65,25 @@ class FilesystemAdapter:
limit = max_bytes + 1
with path.open("rb") as in_f:
raw = in_f.read(limit)
modified = self.modified_iso(path)
truncated = size > max_bytes or len(raw) > max_bytes
if truncated:
raw = raw[:max_bytes]
return {
"size": size,
"modified": modified,
"truncated": truncated,
"content": raw.decode(encoding, errors="replace"),
}
def write_text_file(self, path: Path, content: str, encoding: str = "utf-8") -> dict:
path.write_text(content, encoding=encoding)
return {
"size": int(path.stat().st_size),
"modified": self.modified_iso(path),
}
@staticmethod
def modified_iso(path: Path) -> str:
stat = path.stat()
return datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z")
+73 -2
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
from pathlib import Path
from backend.app.api.errors import AppError
from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, ViewResponse
from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard
TEXT_PREVIEW_MAX_BYTES = 256 * 1024
TEXT_EDIT_MAX_BYTES = 256 * 1024
TEXT_CONTENT_TYPES = {
".txt": "text/plain",
".log": "text/plain",
@@ -146,7 +147,7 @@ class FileOpsService:
return DeleteResponse(path=resolved_target.relative)
def view(self, path: str) -> ViewResponse:
def view(self, path: str, for_edit: bool = False) -> ViewResponse:
resolved_target = self._path_guard.resolve_existing_path(path)
if resolved_target.absolute.is_dir():
@@ -173,6 +174,14 @@ class FileOpsService:
details={"path": resolved_target.relative},
)
if for_edit and resolved_target.absolute.stat().st_size > TEXT_EDIT_MAX_BYTES:
raise AppError(
code="file_too_large",
message="File is too large for edit",
status_code=409,
details={"path": resolved_target.relative},
)
try:
preview = self._filesystem.read_text_preview(
resolved_target.absolute,
@@ -194,9 +203,71 @@ class FileOpsService:
encoding="utf-8",
truncated=preview["truncated"],
size=preview["size"],
modified=preview["modified"],
content=preview["content"],
)
def save(self, path: str, content: str, expected_modified: str) -> SaveResponse:
resolved_target = self._path_guard.resolve_existing_path(path)
if resolved_target.absolute.is_dir():
raise AppError(
code="type_conflict",
message="Source must be a file",
status_code=409,
details={"path": resolved_target.relative},
)
if not resolved_target.absolute.is_file():
raise AppError(
code="type_conflict",
message="Unsupported path type for save",
status_code=409,
details={"path": resolved_target.relative},
)
if self._content_type_for(resolved_target.absolute) is None:
raise AppError(
code="unsupported_type",
message="File type is not supported for edit",
status_code=409,
details={"path": resolved_target.relative},
)
if len(content.encode("utf-8")) > TEXT_EDIT_MAX_BYTES:
raise AppError(
code="file_too_large",
message="File is too large for edit",
status_code=409,
details={"path": resolved_target.relative},
)
current_modified = self._filesystem.modified_iso(resolved_target.absolute)
if current_modified != expected_modified:
raise AppError(
code="conflict",
message="File changed since it was opened",
status_code=409,
details={"path": resolved_target.relative},
)
try:
saved = self._filesystem.write_text_file(
resolved_target.absolute,
content=content,
encoding="utf-8",
)
except OSError as exc:
raise AppError(
code="io_error",
message="Filesystem operation failed",
status_code=500,
details={"reason": str(exc)},
)
return SaveResponse(
path=resolved_target.relative,
size=saved["size"],
modified=saved["modified"],
)
@staticmethod
def _join_relative(base: str, name: str) -> str:
return f"{base}/{name}" if base else name
@@ -0,0 +1,168 @@
from __future__ import annotations
import asyncio
import sys
import tempfile
import time
import unittest
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.dependencies import get_file_ops_service
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.main import app
from backend.app.security.path_guard import PathGuard
from backend.app.services.file_ops_service import FileOpsService
class FailingWriteFilesystemAdapter(FilesystemAdapter):
def write_text_file(self, path: Path, content: str, encoding: str = "utf-8") -> dict:
raise OSError("forced write failure")
class EditApiGoldenTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name) / "root"
self.root.mkdir(parents=True, exist_ok=True)
self.path_guard = PathGuard({"storage1": str(self.root)})
self._set_service(FilesystemAdapter())
def tearDown(self) -> None:
app.dependency_overrides.clear()
self.temp_dir.cleanup()
def _set_service(self, filesystem: FilesystemAdapter) -> None:
service = FileOpsService(path_guard=self.path_guard, filesystem=filesystem)
async def _override_file_ops_service() -> FileOpsService:
return service
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
def _request(self, method: str, url: str, params: dict | None = None, payload: dict | None = None) -> httpx.Response:
async def _run() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
if method == "GET":
return await client.get(url, params=params)
return await client.post(url, json=payload)
return asyncio.run(_run())
def test_edit_view_success(self) -> None:
file_path = self.root / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
response = self._request("GET", "/api/files/view", params={"path": "storage1/notes.txt", "for_edit": "true"})
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["path"], "storage1/notes.txt")
self.assertEqual(body["name"], "notes.txt")
self.assertEqual(body["content"], "hello")
self.assertFalse(body["truncated"])
self.assertIn("modified", body)
def test_save_success(self) -> None:
file_path = self.root / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
initial = self._request("GET", "/api/files/view", params={"path": "storage1/notes.txt", "for_edit": "true"}).json()
response = self._request(
"POST",
"/api/files/save",
payload={
"path": "storage1/notes.txt",
"content": "changed",
"expected_modified": initial["modified"],
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(file_path.read_text(encoding="utf-8"), "changed")
self.assertEqual(response.json()["path"], "storage1/notes.txt")
self.assertEqual(response.json()["size"], len("changed".encode("utf-8")))
def test_unsupported_type(self) -> None:
(self.root / "report.pdf").write_bytes(b"%PDF-1.4")
response = self._request("GET", "/api/files/view", params={"path": "storage1/report.pdf", "for_edit": "true"})
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "unsupported_type")
def test_directory_type_conflict(self) -> None:
(self.root / "docs").mkdir()
response = self._request("GET", "/api/files/view", params={"path": "storage1/docs", "for_edit": "true"})
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "type_conflict")
def test_path_not_found(self) -> None:
response = self._request("POST", "/api/files/save", payload={"path": "storage1/missing.txt", "content": "x", "expected_modified": "2026-01-01T00:00:00Z"})
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["error"]["code"], "path_not_found")
def test_traversal_attempt(self) -> None:
response = self._request("POST", "/api/files/save", payload={"path": "storage1/../etc/passwd", "content": "x", "expected_modified": "2026-01-01T00:00:00Z"})
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
def test_conflict_when_file_changed(self) -> None:
file_path = self.root / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
initial = self._request("GET", "/api/files/view", params={"path": "storage1/notes.txt", "for_edit": "true"}).json()
time.sleep(0.02)
file_path.write_text("changed elsewhere", encoding="utf-8")
response = self._request(
"POST",
"/api/files/save",
payload={
"path": "storage1/notes.txt",
"content": "local edit",
"expected_modified": initial["modified"],
},
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "conflict")
def test_io_error_on_save_failure(self) -> None:
file_path = self.root / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
initial = self._request("GET", "/api/files/view", params={"path": "storage1/notes.txt", "for_edit": "true"}).json()
self._set_service(FailingWriteFilesystemAdapter())
response = self._request(
"POST",
"/api/files/save",
payload={
"path": "storage1/notes.txt",
"content": "local edit",
"expected_modified": initial["modified"],
},
)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["error"]["code"], "io_error")
def test_file_too_large_for_edit(self) -> None:
content = "x" * (300 * 1024)
(self.root / "big.txt").write_text(content, encoding="utf-8")
response = self._request("GET", "/api/files/view", params={"path": "storage1/big.txt", "for_edit": "true"})
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "file_too_large")
if __name__ == "__main__":
unittest.main()
@@ -49,18 +49,15 @@ class ViewApiGoldenTest(unittest.TestCase):
response = self._request("storage1/notes.md")
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json(),
{
"path": "storage1/notes.md",
"name": "notes.md",
"content_type": "text/markdown",
"encoding": "utf-8",
"truncated": False,
"size": len("# title\nhello\n".encode("utf-8")),
"content": "# title\nhello\n",
},
)
body = response.json()
self.assertEqual(body["path"], "storage1/notes.md")
self.assertEqual(body["name"], "notes.md")
self.assertEqual(body["content_type"], "text/markdown")
self.assertEqual(body["encoding"], "utf-8")
self.assertFalse(body["truncated"])
self.assertEqual(body["size"], len("# title\nhello\n".encode("utf-8")))
self.assertEqual(body["content"], "# title\nhello\n")
self.assertIn("modified", body)
def test_view_unsupported_type(self) -> None:
(self.root / "report.pdf").write_bytes(b"%PDF-1.4")
@@ -37,6 +37,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="edit-btn"', body)
self.assertIn('id="viewer-modal"', body)
self.assertIn('id="viewer-content"', body)
self.assertIn('id="editor-modal"', body)
self.assertIn('id="editor-content"', body)
self.assertIn('id="editor-save-btn"', body)
self.assertIn('id="editor-cancel-btn"', body)
self.assertIn('id="mkdir-btn"', body)
self.assertIn('id="copy-btn"', body)
self.assertIn('id="move-btn"', body)