diff --git a/.gitignore b/.gitignore index 0b4b1f6..15a3990 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ *.egg-info /dist __pycache__/ -*.pyc \ No newline at end of file +*.pyc +.env \ No newline at end of file diff --git a/README.md b/README.md index 1b529e7..12c26c5 100644 --- a/README.md +++ b/README.md @@ -814,7 +814,7 @@ async def test_component_initialization(container, sample_component): See the complete example in `samples/http_server/`: ```python -# samples/http_server/http_server.py +# samples/http_server/__init__.py import asyncio from ioc import get_config, get_logger, inject import pydantic diff --git a/samples/http_server/.awioc/manifest.yaml b/samples/http_server/.awioc/manifest.yaml new file mode 100644 index 0000000..bb566c3 --- /dev/null +++ b/samples/http_server/.awioc/manifest.yaml @@ -0,0 +1,13 @@ +manifest_version: '1.0' +name: http_server +version: 1.0.0 +description: Auto-generated manifest for http_server +components: + - name: Http File Server + version: 2.0.0 + description: HTTP File Server with upload, download, and delete capabilities + file: http_server.py + class: HttpServerApp + wire: true + config: + - model: http_server:ServerConfig diff --git a/samples/http_server/__init__.py b/samples/http_server/__init__.py new file mode 100644 index 0000000..998f664 --- /dev/null +++ b/samples/http_server/__init__.py @@ -0,0 +1,7 @@ +""" +A sample HTTP server application using AWIOC framework. +""" + +from .http_server import HttpServerApp + +__all__ = ["HttpServerApp"] diff --git a/samples/http_server/http_server.py b/samples/http_server/http_server.py index 4a14e7c..02cc732 100644 --- a/samples/http_server/http_server.py +++ b/samples/http_server/http_server.py @@ -1,89 +1,508 @@ """ -Simple HTTP Server App Component +HTTP File Server App Component -A minimal HTTP server demonstrating the IOC framework. +A full-featured HTTP file server similar to `python -m http.server` with: +- Directory browsing +- File upload +- Folder download as ZIP +- File/folder deletion """ import asyncio +import html +import io +import mimetypes +import os +import shutil +import urllib.parse +import zipfile from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from pathlib import Path from threading import Thread from typing import Optional import pydantic -from awioc import get_config, get_logger, inject +from awioc import ( + get_config, + get_logger, + inject, + as_component +) class ServerConfig(pydantic.BaseModel): """HTTP Server configuration.""" __prefix__ = "server" - host: str = "127.0.0.1" - port: int = 8080 + host: str = pydantic.Field( + default="127.0.0.1", + description="The hostname or IP address to bind the server to" + ) + port: int = pydantic.Field( + default=8080, + description="The port number to listen on" + ) + root_dir: Path = pydantic.Field( + default=Path("./public"), + description="The root directory to serve files from" + ) + allow_upload: bool = pydantic.Field( + default=False, + description="Allow users to upload files via the web interface" + ) + allow_delete: bool = pydantic.Field( + default=False, + description="Allow users to delete files and folders via the web interface" + ) + allow_zip_download: bool = pydantic.Field( + default=False, + description="Allow users to download folders as ZIP archives" + ) -__metadata__ = { - "name": "http_server_app", - "version": "1.0.0", - "description": "Simple HTTP Server Application", - "wire": True, - "config": ServerConfig -} +# Global config reference (set during initialization) +_server_config: Optional[ServerConfig] = None -class RequestHandler(BaseHTTPRequestHandler): - """Simple HTTP request handler.""" + +class FileServerHandler(BaseHTTPRequestHandler): + """HTTP request handler for file server operations.""" + + @property + def root_dir(self) -> Path: + return _server_config.root_dir.resolve() if _server_config else Path(".").resolve() + + def _get_fs_path(self, url_path: str) -> Optional[Path]: + """Convert URL path to filesystem path, ensuring it's within root.""" + # Decode URL and normalize + decoded = urllib.parse.unquote(url_path) + # Remove leading slash and normalize + clean_path = decoded.lstrip("/") + # Resolve to absolute path + fs_path = (self.root_dir / clean_path).resolve() + # Security check: ensure path is within root + try: + fs_path.relative_to(self.root_dir) + except ValueError: + return None + return fs_path + + def _send_error_page(self, code: int, message: str): + """Send an error page.""" + self.send_response(code) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(f""" + +
{html.escape(message)}
+Back to root + +""".encode("utf-8")) + + def _send_json(self, data: dict, code: int = 200): + """Send a JSON response.""" + import json + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(data).encode("utf-8")) + + def _format_size(self, size: int) -> str: + """Format file size in human-readable format.""" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if size < 1024: + return f"{size:.1f} {unit}" if unit != "B" else f"{size} {unit}" + size /= 1024 + return f"{size:.1f} PB" @inject - def do_GET( - self, - logger=get_logger() - ): + def do_GET(self, logger=get_logger()): """Handle GET requests.""" + parsed = urllib.parse.urlparse(self.path) + url_path = parsed.path + query = urllib.parse.parse_qs(parsed.query) + logger.info(f"GET {self.path} FROM {self.client_address[0]}:{self.client_address[1]}") - if self.path == "/": - self.send_response(200) - self.send_header("Content-Type", "text/html") + + # Handle ZIP download request + if "zip" in query and _server_config and _server_config.allow_zip_download: + self._handle_zip_download(url_path) + return + + fs_path = self._get_fs_path(url_path) + + if fs_path is None: + self._send_error_page(403, "Access denied: Path outside root directory") + return + + if not fs_path.exists(): + self._send_error_page(404, f"Path not found: {url_path}") + return + + if fs_path.is_dir(): + self._serve_directory(url_path, fs_path) + else: + self._serve_file(fs_path) + + def _serve_directory(self, url_path: str, fs_path: Path): + """Serve a directory listing.""" + # Ensure URL path ends with / + if not url_path.endswith("/"): + self.send_response(301) + self.send_header("Location", url_path + "/") self.end_headers() - self.wfile.write(b""" - + return + + entries = [] + try: + for entry in sorted(fs_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + stat = entry.stat() + entries.append({ + "name": entry.name, + "is_dir": entry.is_dir(), + "size": stat.st_size if entry.is_file() else 0, + "mtime": stat.st_mtime, + }) + except PermissionError: + self._send_error_page(403, "Permission denied") + return + + # Generate HTML + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + + parent_link = "" + if url_path != "/": + parent = str(Path(url_path).parent) + if not parent.endswith("/"): + parent += "/" + parent_link = f'The server is running successfully!
-| Name | +Size | +ZIP | +Delete | +
|---|