feat: thumbnails added
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -66,6 +66,19 @@ async def video(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/thumbnail")
|
||||
async def thumbnail(
|
||||
path: str,
|
||||
service: FileOpsService = Depends(get_file_ops_service),
|
||||
) -> StreamingResponse:
|
||||
prepared = service.prepare_thumbnail_stream(path=path)
|
||||
return StreamingResponse(
|
||||
prepared["content"],
|
||||
headers=prepared["headers"],
|
||||
media_type=prepared["content_type"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/save", response_model=SaveResponse)
|
||||
async def save(
|
||||
request: SaveRequest,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest
|
||||
from backend.app.dependencies import get_settings_service
|
||||
from backend.app.services.settings_service import SettingsService
|
||||
|
||||
router = APIRouter(prefix="/settings")
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsResponse)
|
||||
async def get_settings(
|
||||
service: SettingsService = Depends(get_settings_service),
|
||||
) -> SettingsResponse:
|
||||
return service.get_settings()
|
||||
|
||||
|
||||
@router.post("", response_model=SettingsResponse)
|
||||
async def update_settings(
|
||||
request: SettingsUpdateRequest,
|
||||
service: SettingsService = Depends(get_settings_service),
|
||||
) -> SettingsResponse:
|
||||
return service.update_settings(request)
|
||||
@@ -94,6 +94,14 @@ class FileInfoResponse(BaseModel):
|
||||
group: str | None = None
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
show_thumbnails: bool
|
||||
|
||||
|
||||
class SettingsUpdateRequest(BaseModel):
|
||||
show_thumbnails: bool
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
id: str
|
||||
operation: str
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SettingsRepository:
|
||||
def __init__(self, db_path: str):
|
||||
self._db_path = db_path
|
||||
self._ensure_schema()
|
||||
|
||||
def get_settings(self) -> dict[str, str]:
|
||||
with self._connection() as conn:
|
||||
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
||||
return {row["key"]: row["value"] for row in rows}
|
||||
|
||||
def set_setting(self, key: str, value: str) -> None:
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
db_path = Path(self._db_path)
|
||||
if db_path.parent and str(db_path.parent) not in {"", "."}:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _connection(self):
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -5,6 +5,7 @@ from functools import lru_cache
|
||||
from backend.app.config import Settings, get_settings
|
||||
from backend.app.db.bookmark_repository import BookmarkRepository
|
||||
from backend.app.db.history_repository import HistoryRepository
|
||||
from backend.app.db.settings_repository import SettingsRepository
|
||||
from backend.app.db.task_repository import TaskRepository
|
||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||
from backend.app.security.path_guard import PathGuard
|
||||
@@ -15,6 +16,7 @@ from backend.app.services.file_ops_service import FileOpsService
|
||||
from backend.app.services.history_service import HistoryService
|
||||
from backend.app.services.move_task_service import MoveTaskService
|
||||
from backend.app.services.search_service import SearchService
|
||||
from backend.app.services.settings_service import SettingsService
|
||||
from backend.app.services.task_service import TaskService
|
||||
from backend.app.tasks_runner import TaskRunner
|
||||
|
||||
@@ -47,6 +49,12 @@ def get_history_repository() -> HistoryRepository:
|
||||
return HistoryRepository(db_path=settings.task_db_path)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings_repository() -> SettingsRepository:
|
||||
settings: Settings = get_settings()
|
||||
return SettingsRepository(db_path=settings.task_db_path)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_task_runner() -> TaskRunner:
|
||||
return TaskRunner(
|
||||
@@ -100,3 +108,7 @@ async def get_history_service() -> HistoryService:
|
||||
|
||||
async def get_search_service() -> SearchService:
|
||||
return SearchService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
|
||||
|
||||
|
||||
async def get_settings_service() -> SettingsService:
|
||||
return SettingsService(repository=get_settings_repository())
|
||||
|
||||
Binary file not shown.
@@ -147,6 +147,14 @@ class FilesystemAdapter:
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
async def stream_file(self, path: Path, chunk_size: int = 1024 * 1024):
|
||||
with path.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
@staticmethod
|
||||
def modified_iso(path: Path) -> str:
|
||||
stat = path.stat()
|
||||
|
||||
@@ -14,6 +14,7 @@ from backend.app.api.routes_files import router as files_router
|
||||
from backend.app.api.routes_history import router as history_router
|
||||
from backend.app.api.routes_move import router as move_router
|
||||
from backend.app.api.routes_search import router as search_router
|
||||
from backend.app.api.routes_settings import router as settings_router
|
||||
from backend.app.api.routes_tasks import router as tasks_router
|
||||
from backend.app.logging import configure_logging
|
||||
|
||||
@@ -31,6 +32,7 @@ app.include_router(files_router, prefix="/api")
|
||||
app.include_router(copy_router, prefix="/api")
|
||||
app.include_router(move_router, prefix="/api")
|
||||
app.include_router(search_router, prefix="/api")
|
||||
app.include_router(settings_router, prefix="/api")
|
||||
app.include_router(bookmarks_router, prefix="/api")
|
||||
app.include_router(history_router, prefix="/api")
|
||||
app.include_router(tasks_router, prefix="/api")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,12 @@ SPECIAL_TEXT_FILENAMES = {
|
||||
"dockerfile": "text/plain",
|
||||
"containerfile": "text/plain",
|
||||
}
|
||||
THUMBNAIL_CONTENT_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
VIDEO_CONTENT_TYPES = {
|
||||
".mp4": "video/mp4",
|
||||
".mkv": "video/x-matroska",
|
||||
@@ -370,6 +376,39 @@ class FileOpsService:
|
||||
"content": self._filesystem.stream_file_range(resolved_target.absolute, start, end),
|
||||
}
|
||||
|
||||
def prepare_thumbnail_stream(self, path: str) -> dict:
|
||||
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 thumbnail",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
content_type = self._thumbnail_content_type_for(resolved_target.absolute)
|
||||
if content_type is None:
|
||||
raise AppError(
|
||||
code="unsupported_type",
|
||||
message="File type is not supported for thumbnail",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
return {
|
||||
"headers": {"Content-Length": str(int(resolved_target.absolute.stat().st_size))},
|
||||
"content_type": content_type,
|
||||
"content": self._filesystem.stream_file(resolved_target.absolute),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _join_relative(base: str, name: str) -> str:
|
||||
return f"{base}/{name}" if base else name
|
||||
@@ -385,6 +424,10 @@ class FileOpsService:
|
||||
def _video_content_type_for(path: Path) -> str | None:
|
||||
return VIDEO_CONTENT_TYPES.get(path.suffix.lower())
|
||||
|
||||
@staticmethod
|
||||
def _thumbnail_content_type_for(path: Path) -> str | None:
|
||||
return THUMBNAIL_CONTENT_TYPES.get(path.suffix.lower())
|
||||
|
||||
def _record_history(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest
|
||||
from backend.app.db.settings_repository import SettingsRepository
|
||||
|
||||
|
||||
class SettingsService:
|
||||
def __init__(self, repository: SettingsRepository):
|
||||
self._repository = repository
|
||||
|
||||
def get_settings(self) -> SettingsResponse:
|
||||
values = self._repository.get_settings()
|
||||
return SettingsResponse(show_thumbnails=self._as_bool(values.get("show_thumbnails"), default=False))
|
||||
|
||||
def update_settings(self, request: SettingsUpdateRequest) -> SettingsResponse:
|
||||
self._repository.set_setting("show_thumbnails", "true" if request.show_thumbnails else "false")
|
||||
return self.get_settings()
|
||||
|
||||
@staticmethod
|
||||
def _as_bool(value: str | None, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from backend.app.dependencies import get_settings_service
|
||||
from backend.app.db.settings_repository import SettingsRepository
|
||||
from backend.app.main import app
|
||||
from backend.app.services.settings_service import SettingsService
|
||||
|
||||
|
||||
class SettingsApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
repository = SettingsRepository(str(Path(self.temp_dir.name) / "tasks.db"))
|
||||
service = SettingsService(repository=repository)
|
||||
|
||||
async def _override_settings_service() -> SettingsService:
|
||||
return service
|
||||
|
||||
app.dependency_overrides[get_settings_service] = _override_settings_service
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _request(self, method: str, url: str, 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)
|
||||
return await client.post(url, json=payload)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
def test_settings_default_response(self) -> None:
|
||||
response = self._request("GET", "/api/settings")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"show_thumbnails": False})
|
||||
|
||||
def test_settings_update_persistence(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"show_thumbnails": True})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"show_thumbnails": True})
|
||||
self.assertEqual(self._request("GET", "/api/settings").json(), {"show_thumbnails": True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
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 ThumbnailApiGoldenTest(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)
|
||||
service = FileOpsService(path_guard=PathGuard({"storage1": str(self.root)}), filesystem=FilesystemAdapter())
|
||||
|
||||
async def _override_file_ops_service() -> FileOpsService:
|
||||
return service
|
||||
|
||||
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _request(self, path: str) -> httpx.Response:
|
||||
async def _run() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
return await client.get("/api/files/thumbnail", params={"path": path})
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
def test_thumbnail_success_for_supported_image(self) -> None:
|
||||
image = self.root / "poster.jpg"
|
||||
image.write_bytes(b"\xff\xd8\xff\xe0thumbnail")
|
||||
|
||||
response = self._request("storage1/poster.jpg")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["content-type"], "image/jpeg")
|
||||
self.assertEqual(response.content, b"\xff\xd8\xff\xe0thumbnail")
|
||||
|
||||
def test_thumbnail_not_found(self) -> None:
|
||||
response = self._request("storage1/missing.jpg")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_not_found")
|
||||
|
||||
def test_thumbnail_traversal_blocked(self) -> None:
|
||||
response = self._request("storage1/../etc/passwd")
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
|
||||
|
||||
def test_thumbnail_invalid_root_alias(self) -> None:
|
||||
response = self._request("unknown/file.jpg")
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_root_alias")
|
||||
|
||||
def test_thumbnail_non_image_blocked(self) -> None:
|
||||
text_file = self.root / "notes.txt"
|
||||
text_file.write_text("hello", encoding="utf-8")
|
||||
|
||||
response = self._request("storage1/notes.txt")
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "unsupported_type")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -63,6 +63,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('id="rename-apply-btn"', body)
|
||||
self.assertIn('id="settings-general-tab"', body)
|
||||
self.assertIn('id="settings-logs-tab"', body)
|
||||
self.assertIn('id="settings-show-thumbnails"', body)
|
||||
self.assertIn("Show thumbnails", body)
|
||||
self.assertIn('id="settings-logs-list"', body)
|
||||
self.assertIn('id="viewer-content"', body)
|
||||
self.assertIn('id="editor-modal"', body)
|
||||
@@ -113,6 +115,12 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn("document.documentElement.dataset.theme", app_js)
|
||||
self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js)
|
||||
self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js)
|
||||
self.assertIn('async function loadSettings()', app_js)
|
||||
self.assertIn('await loadSettings();', app_js)
|
||||
self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js)
|
||||
self.assertIn('"/api/settings"', app_js)
|
||||
self.assertIn('`/api/files/thumbnail?', app_js)
|
||||
self.assertIn('function createMediaSlot(entry)', app_js)
|
||||
self.assertIn('function openSearch()', app_js)
|
||||
self.assertIn('async function submitSearch()', app_js)
|
||||
self.assertIn('async function openInfo()', app_js)
|
||||
@@ -149,6 +157,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('#theme-toggle', style_css)
|
||||
self.assertIn('.settings-card', style_css)
|
||||
self.assertIn('.settings-tabs', style_css)
|
||||
self.assertIn('.entry-media-slot', style_css)
|
||||
self.assertIn('.entry-media-icon.folder', style_css)
|
||||
self.assertIn('.entry-media-icon.file', style_css)
|
||||
|
||||
app_js_url = app.url_path_for("ui", path="/app.js")
|
||||
style_css_url = app.url_path_for("ui", path="/style.css")
|
||||
|
||||
Reference in New Issue
Block a user