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}
| Name | +Category | +Size | +Created At | +Actions | +
|---|---|---|---|---|
| {file.name} | +{file.category} | +{formatSize(file.size)} | +{formatDate(file.created_at)} | ++ + Download + + + | +
| + No files found. + | +||||
or drag and drop
+ZIP, YAML, JSON up to 50MB
+