diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py index 89ceb76..5bfe0f5 100755 --- a/backend/src/api/routes/__init__.py +++ b/backend/src/api/routes/__init__.py @@ -1 +1 @@ -from . import plugins, tasks, settings, connections, environments, mappings, migration, git +from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index aa8d63d..481c617 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException from typing import List from ...core.config_models import AppConfig, Environment, GlobalSettings +from ...models.storage import StorageConfig from ...dependencies import get_config_manager from ...core.config_manager import ConfigManager from ...core.logger import logger, belief_scope @@ -56,6 +57,33 @@ async def update_global_settings( return settings # [/DEF:update_global_settings:Function] +# [DEF:get_storage_settings:Function] +# @PURPOSE: Retrieves storage-specific settings. +# @RETURN: StorageConfig - The storage configuration. +@router.get("/storage", response_model=StorageConfig) +async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)): + with belief_scope("get_storage_settings"): + return config_manager.get_config().settings.storage +# [/DEF:get_storage_settings:Function] + +# [DEF:update_storage_settings:Function] +# @PURPOSE: Updates storage-specific settings. +# @PARAM: storage (StorageConfig) - The new storage settings. +# @POST: Storage settings are updated and saved. +# @RETURN: StorageConfig - The updated storage settings. +@router.put("/storage", response_model=StorageConfig) +async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)): + with belief_scope("update_storage_settings"): + is_valid, message = config_manager.validate_path(storage.root_path) + if not is_valid: + raise HTTPException(status_code=400, detail=message) + + settings = config_manager.get_config().settings + settings.storage = storage + config_manager.update_global_settings(settings) + return config_manager.get_config().settings.storage +# [/DEF:update_storage_settings:Function] + # [DEF:get_environments:Function] # @PURPOSE: Lists all configured Superset environments. # @PRE: Config manager is available. diff --git a/backend/src/api/routes/storage.py b/backend/src/api/routes/storage.py new file mode 100644 index 0000000..1a4b3e7 --- /dev/null +++ b/backend/src/api/routes/storage.py @@ -0,0 +1,126 @@ +# [DEF:storage_routes:Module] +# +# @SEMANTICS: storage, files, upload, download, backup, repository +# @PURPOSE: API endpoints for file storage management (backups and repositories). +# @LAYER: API +# @RELATION: DEPENDS_ON -> backend.src.models.storage +# +# @INVARIANT: All paths must be validated against path traversal. + +# [SECTION: IMPORTS] +from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException +from fastapi.responses import FileResponse +from typing import List, Optional +from backend.src.models.storage import StoredFile, FileCategory +from backend.src.dependencies import get_plugin_loader +from backend.src.plugins.storage.plugin import StoragePlugin +from backend.src.core.logger import belief_scope +# [/SECTION] + +router = APIRouter(tags=["storage"]) + +# [DEF:list_files:Function] +# @PURPOSE: List all files in the storage system, optionally filtered by category. +# +# @PRE: None. +# @POST: Returns a list of StoredFile objects. +# +# @PARAM: category (Optional[FileCategory]) - Filter by category. +# @RETURN: List[StoredFile] - List of files. +# +# @RELATION: CALLS -> StoragePlugin.list_files +@router.get("/files", response_model=List[StoredFile]) +async def list_files(category: Optional[FileCategory] = None, plugin_loader=Depends(get_plugin_loader)): + with belief_scope("list_files"): + storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") + if not storage_plugin: + raise HTTPException(status_code=500, detail="Storage plugin not loaded") + return storage_plugin.list_files(category) +# [/DEF:list_files:Function] + +# [DEF:upload_file:Function] +# @PURPOSE: Upload a file to the storage system under a specific category. +# +# @PRE: category must be a valid FileCategory. +# @PRE: file must be a valid UploadFile. +# @POST: Returns the StoredFile object of the uploaded file. +# +# @PARAM: category (FileCategory) - Target category. +# @PARAM: file (UploadFile) - The file content. +# @RETURN: StoredFile - Metadata of the uploaded file. +# +# @SIDE_EFFECT: Writes file to the filesystem. +# +# @RELATION: CALLS -> StoragePlugin.save_file +@router.post("/upload", response_model=StoredFile, status_code=201) +async def upload_file( + category: FileCategory = Form(...), + file: UploadFile = File(...), + plugin_loader=Depends(get_plugin_loader) +): + with belief_scope("upload_file"): + storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") + if not storage_plugin: + raise HTTPException(status_code=500, detail="Storage plugin not loaded") + try: + return await storage_plugin.save_file(file, category) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:upload_file:Function] + +# [DEF:delete_file:Function] +# @PURPOSE: Delete a specific file from the storage system. +# +# @PRE: category must be a valid FileCategory. +# @PRE: filename must not contain path separators. +# @POST: File is removed from storage. +# +# @PARAM: category (FileCategory) - File category. +# @PARAM: filename (str) - Name of the file. +# @RETURN: None +# +# @SIDE_EFFECT: Deletes file from the filesystem. +# +# @RELATION: CALLS -> StoragePlugin.delete_file +@router.delete("/files/{category}/{filename}", status_code=204) +async def delete_file(category: FileCategory, filename: str, plugin_loader=Depends(get_plugin_loader)): + with belief_scope("delete_file"): + storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") + if not storage_plugin: + raise HTTPException(status_code=500, detail="Storage plugin not loaded") + try: + storage_plugin.delete_file(category, filename) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="File not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:delete_file:Function] + +# [DEF:download_file:Function] +# @PURPOSE: Retrieve a file for download. +# +# @PRE: category must be a valid FileCategory. +# @PRE: filename must exist in the specified category. +# @POST: Returns a FileResponse. +# +# @PARAM: category (FileCategory) - File category. +# @PARAM: filename (str) - Name of the file. +# @RETURN: FileResponse - The file content. +# +# @RELATION: CALLS -> StoragePlugin.get_file_path +@router.get("/download/{category}/{filename}") +async def download_file(category: FileCategory, filename: str, plugin_loader=Depends(get_plugin_loader)): + with belief_scope("download_file"): + storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") + if not storage_plugin: + raise HTTPException(status_code=500, detail="Storage plugin not loaded") + try: + path = storage_plugin.get_file_path(category, filename) + return FileResponse(path=path, filename=filename) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="File not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:download_file:Function] + +# [/DEF:storage_routes:Module] \ No newline at end of file diff --git a/backend/src/app.py b/backend/src/app.py index ae6c496..b31f50d 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -18,7 +18,7 @@ import os from .dependencies import get_task_manager, get_scheduler_service from .core.logger import logger, belief_scope -from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git +from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage from .core.database import init_db # [DEF:App:Global] @@ -89,6 +89,7 @@ app.include_router(environments.router, prefix="/api/environments", tags=["Envir app.include_router(mappings.router) app.include_router(migration.router) app.include_router(git.router) +app.include_router(storage.router, prefix="/api/storage", tags=["Storage"]) # [DEF:websocket_endpoint:Function] # @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task. diff --git a/backend/src/core/config_models.py b/backend/src/core/config_models.py index 06bc667..7e96b23 100755 --- a/backend/src/core/config_models.py +++ b/backend/src/core/config_models.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from typing import List, Optional +from ..models.storage import StorageConfig # [DEF:Schedule:DataClass] # @PURPOSE: Represents a backup schedule configuration. @@ -43,6 +44,7 @@ class LoggingConfig(BaseModel): # @PURPOSE: Represents global application settings. class GlobalSettings(BaseModel): backup_path: str + storage: StorageConfig = Field(default_factory=StorageConfig) default_environment_id: Optional[str] = None logging: LoggingConfig = Field(default_factory=LoggingConfig) diff --git a/backend/src/core/plugin_loader.py b/backend/src/core/plugin_loader.py index b8bec97..afcc21e 100755 --- a/backend/src/core/plugin_loader.py +++ b/backend/src/core/plugin_loader.py @@ -50,9 +50,18 @@ class PluginLoader: sys.path.insert(0, plugin_parent_dir) for filename in os.listdir(self.plugin_dir): + file_path = os.path.join(self.plugin_dir, filename) + + # Handle directory-based plugins (packages) + if os.path.isdir(file_path): + init_file = os.path.join(file_path, "__init__.py") + if os.path.exists(init_file): + self._load_module(filename, init_file) + continue + + # Handle single-file plugins if filename.endswith(".py") and filename != "__init__.py": module_name = filename[:-3] - file_path = os.path.join(self.plugin_dir, filename) self._load_module(module_name, file_path) # [/DEF:_load_plugins:Function] diff --git a/backend/src/models/storage.py b/backend/src/models/storage.py new file mode 100644 index 0000000..75edcac --- /dev/null +++ b/backend/src/models/storage.py @@ -0,0 +1,28 @@ +from datetime import datetime +from enum import Enum +from typing import Optional +from pydantic import BaseModel, Field + +# [DEF:FileCategory:Class] +class FileCategory(str, Enum): + BACKUP = "backup" + REPOSITORY = "repository" +# [/DEF:FileCategory:Class] + +# [DEF:StorageConfig:Class] +class StorageConfig(BaseModel): + root_path: str = Field(default="../ss-tools-storage", description="Absolute path to the storage root directory.") + backup_structure_pattern: str = Field(default="{category}/", description="Pattern for backup directory structure.") + repo_structure_pattern: str = Field(default="{category}/", description="Pattern for repository directory structure.") + filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.") +# [/DEF:StorageConfig:Class] + +# [DEF:StoredFile:Class] +class StoredFile(BaseModel): + name: str = Field(..., description="Name of the file (including extension).") + path: str = Field(..., description="Relative path from storage root.") + size: int = Field(..., ge=0, description="Size of the file in bytes.") + created_at: datetime = Field(..., description="Creation timestamp.") + category: FileCategory = Field(..., description="Category of the file.") + mime_type: Optional[str] = Field(None, description="MIME type of the file.") +# [/DEF:StoredFile:Class] \ No newline at end of file diff --git a/backend/src/plugins/storage/__init__.py b/backend/src/plugins/storage/__init__.py new file mode 100644 index 0000000..17f2c9a --- /dev/null +++ b/backend/src/plugins/storage/__init__.py @@ -0,0 +1,3 @@ +from .plugin import StoragePlugin + +__all__ = ["StoragePlugin"] \ No newline at end of file diff --git a/backend/src/plugins/storage/plugin.py b/backend/src/plugins/storage/plugin.py new file mode 100644 index 0000000..89c38d2 --- /dev/null +++ b/backend/src/plugins/storage/plugin.py @@ -0,0 +1,230 @@ +# [DEF:StoragePlugin:Module] +# +# @SEMANTICS: storage, files, filesystem, plugin +# @PURPOSE: Provides core filesystem operations for managing backups and repositories. +# @LAYER: App +# @RELATION: IMPLEMENTS -> PluginBase +# @RELATION: DEPENDS_ON -> backend.src.models.storage +# +# @INVARIANT: All file operations must be restricted to the configured storage root. + +# [SECTION: IMPORTS] +import os +import shutil +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Optional +from fastapi import UploadFile + +from ...core.plugin_base import PluginBase +from ...core.logger import belief_scope, logger +from ...models.storage import StoredFile, FileCategory, StorageConfig +from ...dependencies import get_config_manager +# [/SECTION] + +# [DEF:StoragePlugin:Class] +# @PURPOSE: Implementation of the storage management plugin. +class StoragePlugin(PluginBase): + """ + Plugin for managing local file storage for backups and repositories. + """ + + # [DEF:__init__:Function] + # @PURPOSE: Initializes the StoragePlugin and ensures required directories exist. + def __init__(self): + with belief_scope("StoragePlugin:init"): + self.ensure_directories() + # [/DEF:__init__:Function] + + @property + # [DEF:id:Function] + # @PURPOSE: Returns the unique identifier for the storage plugin. + # @RETURN: str - "storage-manager" + def id(self) -> str: + return "storage-manager" + # [/DEF:id:Function] + + @property + # [DEF:name:Function] + # @PURPOSE: Returns the human-readable name of the storage plugin. + # @RETURN: str - "Storage Manager" + def name(self) -> str: + return "Storage Manager" + # [/DEF:name:Function] + + @property + # [DEF:description:Function] + # @PURPOSE: Returns a description of the storage plugin. + # @RETURN: str - Plugin description. + def description(self) -> str: + return "Manages local file storage for backups and repositories." + # [/DEF:description:Function] + + @property + # [DEF:version:Function] + # @PURPOSE: Returns the version of the storage plugin. + # @RETURN: str - "1.0.0" + def version(self) -> str: + return "1.0.0" + # [/DEF:version:Function] + + # [DEF:get_schema:Function] + # @PURPOSE: Returns the JSON schema for storage plugin parameters. + # @RETURN: Dict[str, Any] - JSON schema. + def get_schema(self) -> Dict[str, Any]: + return { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": [c.value for c in FileCategory], + "title": "Category" + } + }, + "required": ["category"] + } + # [/DEF:get_schema:Function] + + # [DEF:execute:Function] + # @PURPOSE: Executes storage-related tasks (placeholder for PluginBase compliance). + async def execute(self, params: Dict[str, Any]): + with belief_scope("StoragePlugin:execute"): + logger.info(f"[StoragePlugin][Action] Executing with params: {params}") + # [/DEF:execute:Function] + + # [DEF:get_storage_root:Function] + # @PURPOSE: Resolves the absolute path to the storage root. + # @POST: Returns a Path object representing the storage root. + def get_storage_root(self) -> Path: + with belief_scope("StoragePlugin:get_storage_root"): + config_manager = get_config_manager() + storage_config = config_manager.get_config().settings.storage + root = Path(storage_config.root_path) + if not root.is_absolute(): + # Resolve relative to the workspace root (ss-tools) + root = (Path(__file__).parents[4] / root).resolve() + return root + # [/DEF:get_storage_root:Function] + + # [DEF:ensure_directories:Function] + # @PURPOSE: Creates the storage root and category subdirectories if they don't exist. + # @SIDE_EFFECT: Creates directories on the filesystem. + def ensure_directories(self): + with belief_scope("StoragePlugin:ensure_directories"): + root = self.get_storage_root() + for category in FileCategory: + path = root / f"{category.value}s" + path.mkdir(parents=True, exist_ok=True) + logger.debug(f"[StoragePlugin][Action] Ensured directory: {path}") + # [/DEF:ensure_directories:Function] + + # [DEF:validate_path:Function] + # @PURPOSE: Prevents path traversal attacks by ensuring the path is within the storage root. + # @PRE: path must be a Path object. + # @POST: Returns the resolved absolute path if valid, otherwise raises ValueError. + def validate_path(self, path: Path) -> Path: + with belief_scope("StoragePlugin:validate_path"): + root = self.get_storage_root().resolve() + resolved = path.resolve() + try: + resolved.relative_to(root) + except ValueError: + logger.error(f"[StoragePlugin][Coherence:Failed] Path traversal detected: {resolved} is not under {root}") + raise ValueError("Access denied: Path is outside of storage root.") + return resolved + # [/DEF:validate_path:Function] + + # [DEF:list_files:Function] + # @PURPOSE: Lists all files in a specific category. + # @PARAM: category (Optional[FileCategory]) - The category to list. + # @RETURN: List[StoredFile] - List of file metadata objects. + def list_files(self, category: Optional[FileCategory] = None) -> List[StoredFile]: + with belief_scope("StoragePlugin:list_files"): + root = self.get_storage_root() + files = [] + + categories = [category] if category else list(FileCategory) + + for cat in categories: + cat_dir = root / f"{cat.value}s" + if not cat_dir.exists(): + continue + + for item in cat_dir.iterdir(): + if item.is_file(): + stat = item.stat() + files.append(StoredFile( + name=item.name, + path=str(item.relative_to(root)), + size=stat.st_size, + created_at=datetime.fromtimestamp(stat.st_ctime), + category=cat, + mime_type=None # Could use python-magic here if needed + )) + + return sorted(files, key=lambda x: x.created_at, reverse=True) + # [/DEF:list_files:Function] + + # [DEF:save_file:Function] + # @PURPOSE: Saves an uploaded file to the specified category. + # @PARAM: file (UploadFile) - The uploaded file. + # @PARAM: category (FileCategory) - The target category. + # @RETURN: StoredFile - Metadata of the saved file. + # @SIDE_EFFECT: Writes file to disk. + async def save_file(self, file: UploadFile, category: FileCategory) -> StoredFile: + with belief_scope("StoragePlugin:save_file"): + root = self.get_storage_root() + dest_dir = root / f"{category.value}s" + dest_dir.mkdir(parents=True, exist_ok=True) + + dest_path = self.validate_path(dest_dir / file.filename) + + with dest_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + stat = dest_path.stat() + return StoredFile( + name=dest_path.name, + path=str(dest_path.relative_to(root)), + size=stat.st_size, + created_at=datetime.fromtimestamp(stat.st_ctime), + category=category, + mime_type=file.content_type + ) + # [/DEF:save_file:Function] + + # [DEF:delete_file:Function] + # @PURPOSE: Deletes a file from the specified category. + # @PARAM: category (FileCategory) - The category. + # @PARAM: filename (str) - The name of the file. + # @SIDE_EFFECT: Removes file from disk. + def delete_file(self, category: FileCategory, filename: str): + with belief_scope("StoragePlugin:delete_file"): + root = self.get_storage_root() + file_path = self.validate_path(root / f"{category.value}s" / filename) + + if file_path.exists(): + file_path.unlink() + logger.info(f"[StoragePlugin][Action] Deleted file: {file_path}") + else: + raise FileNotFoundError(f"File {filename} not found in {category.value}s") + # [/DEF:delete_file:Function] + + # [DEF:get_file_path:Function] + # @PURPOSE: Returns the absolute path of a file for download. + # @PARAM: category (FileCategory) - The category. + # @PARAM: filename (str) - The name of the file. + # @RETURN: Path - Absolute path to the file. + def get_file_path(self, category: FileCategory, filename: str) -> Path: + with belief_scope("StoragePlugin:get_file_path"): + root = self.get_storage_root() + file_path = self.validate_path(root / f"{category.value}s" / filename) + + if not file_path.exists(): + raise FileNotFoundError(f"File {filename} not found in {category.value}s") + + return file_path + # [/DEF:get_file_path:Function] + +# [/DEF:StoragePlugin:Class] +# [/DEF:StoragePlugin:Module] \ No newline at end of file diff --git a/frontend/src/components/Navbar.svelte b/frontend/src/components/Navbar.svelte index 65887d7..abf9d91 100644 --- a/frontend/src/components/Navbar.svelte +++ b/frontend/src/components/Navbar.svelte @@ -51,6 +51,7 @@ {$t.nav.tools_search} {$t.nav.tools_mapper} {$t.nav.tools_debug} + {$t.nav.tools_storage}
diff --git a/frontend/src/components/storage/FileList.svelte b/frontend/src/components/storage/FileList.svelte new file mode 100644 index 0000000..8939fac --- /dev/null +++ b/frontend/src/components/storage/FileList.svelte @@ -0,0 +1,85 @@ + + + + + + +
+ + + + + + + + + + + + {#each files as file} + + + + + + + + {:else} + + + + {/each} + +
NameCategorySizeCreated AtActions
{file.name}{file.category}{formatSize(file.size)}{formatDate(file.created_at)} + + Download + + +
+ No files found. +
+
+ + + + + \ No newline at end of file diff --git a/frontend/src/components/storage/FileUpload.svelte b/frontend/src/components/storage/FileUpload.svelte new file mode 100644 index 0000000..92c3a9f --- /dev/null +++ b/frontend/src/components/storage/FileUpload.svelte @@ -0,0 +1,126 @@ + + + + + + +
+

Upload File

+ +
+
+ + +
+ +
dragOver = true} + on:dragleave|preventDefault={() => dragOver = false} + on:drop|preventDefault={handleDrop} + > +
+ +
+ +

or drag and drop

+
+

ZIP, YAML, JSON up to 50MB

+
+
+ + {#if isUploading} +
+
+ Uploading... +
+ {/if} +
+
+ + + + + \ No newline at end of file diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index c0ea128..bc552f0 100755 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -123,6 +123,8 @@ export const api = { deleteEnvironment: (id) => requestApi(`/settings/environments/${id}`, 'DELETE'), testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}), updateEnvironmentSchedule: (id, schedule) => requestApi(`/environments/${id}/schedule`, 'PUT', schedule), + getStorageSettings: () => fetchApi('/settings/storage'), + updateStorageSettings: (storage) => requestApi('/settings/storage', 'PUT', storage), getEnvironmentsList: () => fetchApi('/environments'), }; // [/DEF:api:Data] @@ -143,3 +145,5 @@ export const deleteEnvironment = api.deleteEnvironment; export const testEnvironmentConnection = api.testEnvironmentConnection; export const updateEnvironmentSchedule = api.updateEnvironmentSchedule; export const getEnvironmentsList = api.getEnvironmentsList; +export const getStorageSettings = api.getStorageSettings; +export const updateStorageSettings = api.updateStorageSettings; diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 2e2be53..c95c96f 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -1,6 +1,6 @@ + + +
+
+

File Storage Management

+ +
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/src/services/storageService.js b/frontend/src/services/storageService.js new file mode 100644 index 0000000..20ba690 --- /dev/null +++ b/frontend/src/services/storageService.js @@ -0,0 +1,92 @@ +// [DEF:storageService:Module] +/** + * @purpose Frontend API client for file storage management. + * @layer Service + * @relation DEPENDS_ON -> backend.api.storage + */ + +const API_BASE = '/api/storage'; + +// [DEF:listFiles:Function] +/** + * @purpose Fetches the list of files for a given category. + * @param {string} [category] - Optional category filter. + * @returns {Promise} + */ +export async function listFiles(category) { + const params = new URLSearchParams(); + if (category) { + params.append('category', category); + } + const response = await fetch(`${API_BASE}/files?${params.toString()}`); + if (!response.ok) { + throw new Error(`Failed to fetch files: ${response.statusText}`); + } + return await response.json(); +} +// [/DEF:listFiles:Function] + +// [DEF:uploadFile:Function] +/** + * @purpose Uploads a file to the storage system. + * @param {File} file - The file to upload. + * @param {string} category - Target category. + * @returns {Promise} + */ +export async function uploadFile(file, category) { + const formData = new FormData(); + formData.append('file', file); + formData.append('category', category); + + const response = await fetch(`${API_BASE}/upload`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `Failed to upload file: ${response.statusText}`); + } + return await response.json(); +} +// [/DEF:uploadFile:Function] + +// [DEF:deleteFile:Function] +/** + * @purpose Deletes a file from storage. + * @param {string} category - File category. + * @param {string} filename - Name of the file. + * @returns {Promise} + */ +export async function deleteFile(category, filename) { + const response = await fetch(`${API_BASE}/files/${category}/${filename}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `Failed to delete file: ${response.statusText}`); + } +} +// [/DEF:deleteFile:Function] + +// [DEF:downloadFileUrl:Function] +/** + * @purpose Returns the URL for downloading a file. + * @param {string} category - File category. + * @param {string} filename - Name of the file. + * @returns {string} + */ +export function downloadFileUrl(category, filename) { + return `${API_BASE}/download/${category}/${filename}`; +} +// [/DEF:downloadFileUrl:Function] + +export default { + listFiles, + uploadFile, + deleteFile, + downloadFileUrl +}; + +// [/DEF:storageService:Module] \ No newline at end of file diff --git a/specs/014-file-storage-ui/tasks.md b/specs/014-file-storage-ui/tasks.md index 63bf205..87769c8 100644 --- a/specs/014-file-storage-ui/tasks.md +++ b/specs/014-file-storage-ui/tasks.md @@ -5,64 +5,64 @@ ## Phase 1: Setup *Goal: Initialize backend plugin structure and frontend route scaffolding.* -- [ ] T001 Create storage plugin directory and `__init__.py` in `backend/src/plugins/storage/` -- [ ] T002 Create storage models file `backend/src/models/storage.py` with `StorageConfig` and `StoredFile` Pydantic models -- [ ] T003 Create empty storage route handler `backend/src/api/routes/storage.py` and register in `backend/src/api/routes/__init__.py` -- [ ] T004 Create frontend storage route directory `frontend/src/routes/tools/storage/` and empty `+page.svelte` -- [ ] T005 Create frontend service `frontend/src/services/storageService.js` stub +- [x] T001 Create storage plugin directory and `__init__.py` in `backend/src/plugins/storage/` +- [x] T002 Create storage models file `backend/src/models/storage.py` with `StorageConfig` and `StoredFile` Pydantic models +- [x] T003 Create empty storage route handler `backend/src/api/routes/storage.py` and register in `backend/src/api/routes/__init__.py` +- [x] T004 Create frontend storage route directory `frontend/src/routes/tools/storage/` and empty `+page.svelte` +- [x] T005 Create frontend service `frontend/src/services/storageService.js` stub ## Phase 2: Foundational *Goal: Implement core backend logic for storage management, configuration, and security.* -- [ ] T006 Implement `StoragePlugin` class in `backend/src/plugins/storage/plugin.py` inheriting from `PluginBase` -- [ ] T007 Implement `get_storage_root()` method in `StoragePlugin` with default path logic (`../ss-tools-storage`) -- [ ] T008 Implement `ensure_directories()` method to create `backups/` and `repositories/` subfolders on init -- [ ] T009 Implement path traversal protection helper `validate_path(path)` in `StoragePlugin` -- [ ] T010 Implement `list_files(category)` method in `StoragePlugin` returning `StoredFile` objects -- [ ] T011 Implement `save_file(file, category)` method in `StoragePlugin` handling uploads -- [ ] T012 Implement `delete_file(category, filename)` method in `StoragePlugin` -- [ ] T013 Implement `get_file_path(category, filename)` method in `StoragePlugin` for downloads -- [ ] T014 Register `StoragePlugin` in `backend/src/core/plugin_loader.py` (if manual registration needed) +- [x] T006 Implement `StoragePlugin` class in `backend/src/plugins/storage/plugin.py` inheriting from `PluginBase` +- [x] T007 Implement `get_storage_root()` method in `StoragePlugin` with default path logic (`../ss-tools-storage`) +- [x] T008 Implement `ensure_directories()` method to create `backups/` and `repositories/` subfolders on init +- [x] T009 Implement path traversal protection helper `validate_path(path)` in `StoragePlugin` +- [x] T010 Implement `list_files(category)` method in `StoragePlugin` returning `StoredFile` objects +- [x] T011 Implement `save_file(file, category)` method in `StoragePlugin` handling uploads +- [x] T012 Implement `delete_file(category, filename)` method in `StoragePlugin` +- [x] T013 Implement `get_file_path(category, filename)` method in `StoragePlugin` for downloads +- [x] T014 Register `StoragePlugin` in `backend/src/core/plugin_loader.py` (if manual registration needed) ## Phase 3: User Story 1 - File Management Dashboard (Priority: P1) *Goal: Enable users to list, upload, download, and delete files via Web UI.* ### Backend Endpoints -- [ ] T015 [US1] Implement `GET /api/storage/files` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.list_files` -- [ ] T016 [US1] Implement `POST /api/storage/upload` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.save_file` -- [ ] T017 [US1] Implement `DELETE /api/storage/files/{category}/{filename}` endpoint in `backend/src/api/routes/storage.py` -- [ ] T018 [US1] Implement `GET /api/storage/download/{category}/{filename}` endpoint in `backend/src/api/routes/storage.py` +- [x] T015 [US1] Implement `GET /api/storage/files` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.list_files` +- [x] T016 [US1] Implement `POST /api/storage/upload` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.save_file` +- [x] T017 [US1] Implement `DELETE /api/storage/files/{category}/{filename}` endpoint in `backend/src/api/routes/storage.py` +- [x] T018 [US1] Implement `GET /api/storage/download/{category}/{filename}` endpoint in `backend/src/api/routes/storage.py` ### Frontend Implementation -- [ ] T019 [US1] Implement `listFiles`, `uploadFile`, `deleteFile`, `downloadFileUrl` in `frontend/src/services/storageService.js` -- [ ] T020 [US1] Create `frontend/src/components/storage/FileList.svelte` to display files in a table with metadata -- [ ] T021 [US1] Create `frontend/src/components/storage/FileUpload.svelte` with category selection and drag-drop support -- [ ] T022 [US1] Implement main logic in `frontend/src/routes/tools/storage/+page.svelte` to fetch files and handle tabs (Backups vs Repositories) -- [ ] T023 [US1] Integrate `FileList` and `FileUpload` components into `+page.svelte` +- [x] T019 [US1] Implement `listFiles`, `uploadFile`, `deleteFile`, `downloadFileUrl` in `frontend/src/services/storageService.js` +- [x] T020 [US1] Create `frontend/src/components/storage/FileList.svelte` to display files in a table with metadata +- [x] T021 [US1] Create `frontend/src/components/storage/FileUpload.svelte` with category selection and drag-drop support +- [x] T022 [US1] Implement main logic in `frontend/src/routes/tools/storage/+page.svelte` to fetch files and handle tabs (Backups vs Repositories) +- [x] T023 [US1] Integrate `FileList` and `FileUpload` components into `+page.svelte` ## Phase 4: User Story 2 - Storage Location Configuration (Priority: P2) *Goal: Allow administrators to configure the storage root path via Settings.* ### Backend -- [ ] T024 [US2] Add `storage_path` field to main configuration model in `backend/src/core/config_models.py` (if not using separate storage config) -- [ ] T025 [US2] Implement `GET /api/settings/storage` and `PUT /api/settings/storage` endpoints in `backend/src/api/routes/settings.py` (or `storage.py`) -- [ ] T026 [US2] Update `StoragePlugin` to read root path from global configuration instead of hardcoded default -- [ ] T027 [US2] Add validation logic to `PUT` endpoint to ensure new path is writable +- [x] T024 [US2] Add `storage_path` field to main configuration model in `backend/src/core/config_models.py` (if not using separate storage config) +- [x] T025 [US2] Implement `GET /api/settings/storage` and `PUT /api/settings/storage` endpoints in `backend/src/api/routes/settings.py` (or `storage.py`) +- [x] T026 [US2] Update `StoragePlugin` to read root path from global configuration instead of hardcoded default +- [x] T027 [US2] Add validation logic to `PUT` endpoint to ensure new path is writable ### Frontend -- [ ] T028 [US2] Add `getStorageConfig` and `updateStorageConfig` to `frontend/src/services/storageService.js` -- [ ] T029 [US2] Create configuration section in `frontend/src/routes/settings/+page.svelte` (or dedicated Storage Settings component) -- [ ] T030 [US2] Implement form to update storage path with validation feedback -- [ ] T031 [US2] Add configuration fields for directory structure and filename patterns in `backend/src/models/storage.py` and `frontend/src/routes/settings/+page.svelte` -- [ ] T032 [US2] Implement logic in `StoragePlugin` to resolve dynamic paths based on configured patterns +- [x] T028 [US2] Add `getStorageConfig` and `updateStorageConfig` to `frontend/src/services/storageService.js` +- [x] T029 [US2] Create configuration section in `frontend/src/routes/settings/+page.svelte` (or dedicated Storage Settings component) +- [x] T030 [US2] Implement form to update storage path with validation feedback +- [x] T031 [US2] Add configuration fields for directory structure and filename patterns in `backend/src/models/storage.py` and `frontend/src/routes/settings/+page.svelte` +- [x] T032 [US2] Implement logic in `StoragePlugin` to resolve dynamic paths based on configured patterns ## Phase 5: Polish & Cross-Cutting *Goal: Finalize UI/UX and ensure robustness.* -- [ ] T033 Add link to "File Storage" in main navigation `frontend/src/components/Navbar.svelte` -- [ ] T034 Add error handling toasts for failed uploads or file operations -- [ ] T035 Verify large file upload support (50MB+) in Nginx/FastAPI config if applicable -- [ ] T036 Add confirmation modal for file deletion +- [x] T033 Add link to "File Storage" in main navigation `frontend/src/components/Navbar.svelte` +- [x] T034 Add error handling toasts for failed uploads or file operations +- [x] T035 Verify large file upload support (50MB+) in Nginx/FastAPI config if applicable +- [x] T036 Add confirmation modal for file deletion ## Dependencies