diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..cfcb5f1 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,76 @@ +name: Docker CI/CD + +on: + push: + branches: + - '**' + release: + types: + - published + +permissions: + packages: write + contents: read + +jobs: + build-and-save: + runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build Docker image + run: | + IMAGE_NAME=$(echo ${{ github.repository }} | cut -d'/' -f2) + docker build -t $IMAGE_NAME:${{ github.sha }} . + + - name: Save Docker image as .tar artifact + run: | + IMAGE_NAME=$(echo ${{ github.repository }} | cut -d'/' -f2) + docker save $IMAGE_NAME:${{ github.sha }} > docker-image-${{ github.sha }}.tar + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: docker-image-${{ github.sha }} + path: docker-image-${{ github.sha }}.tar + + build-and-push: + runs-on: ubuntu-latest + if: github.event_name == 'release' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + registry: docker.io + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + tags: | + ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} + ghcr.io/${{ github.repository }}:latest + docker.io/your-dockerhub-username:${{ github.event.release.tag_name }} + docker.io/your-dockerhub-username:latest + push: true \ No newline at end of file diff --git a/.github/workflows/ncc_workflow.yml b/.github/workflows/ncc_workflow.yml index 83ec6dc..c6529c8 100644 --- a/.github/workflows/ncc_workflow.yml +++ b/.github/workflows/ncc_workflow.yml @@ -57,7 +57,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: release - path: build/release/net.nosial.file_server.ncc + path: build/release/net.nosial.fileserver.ncc debug: runs-on: ubuntu-latest container: @@ -106,7 +106,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: debug - path: build/debug/net.nosial.file_server.ncc + path: build/debug/net.nosial.fileserver.ncc release_executable: runs-on: ubuntu-latest container: @@ -327,7 +327,7 @@ jobs: - name: Install NCC packages run: | - ncc package install --package="release/net.nosial.file_server.ncc" --build-source --reinstall -y --log-level debug + ncc package install --package="release/net.nosial.fileserver.ncc" --build-source --reinstall -y --log-level debug - name: Run PHPUnit tests run: | diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..76966ad --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,22 @@ + + + + + mariadb + true + org.mariadb.jdbc.Driver + jdbc:mariadb://127.0.0.1:3306/fileserver + + + + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/src/FileServer/Classes/Resources/database.sqlite + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml index f405972..7c7d6f7 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -12,6 +12,7 @@ + diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..c99a5f1 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/webResources.xml b/.idea/webResources.xml new file mode 100644 index 0000000..a2bdf6d --- /dev/null +++ b/.idea/webResources.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6ddd53 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,107 @@ +# ----------------------------------------------------------------------------- +# Dockerfile for PHP 8.3 + FPM with Cron support and Supervisor +# ----------------------------------------------------------------------------- + +# Base image: Official PHP 8.3 with FPM +FROM php:8.3-fpm AS base + +# ----------------------------- Metadata labels ------------------------------ +LABEL maintainer="Netkas " \ + version="1.0" \ + description="FileServer Docker image based off PHP 8.3 FPM and NCC" \ + application="FileServer" \ + base_image="php:8.3-fpm" + +# Environment variable for non-interactive installations +ENV DEBIAN_FRONTEND=noninteractive + +# ----------------------------- System Dependencies -------------------------- +# Update system packages and install required dependencies in one step +RUN apt-get update -yqq && apt-get install -yqq --no-install-recommends \ + git \ + libpq-dev \ + libzip-dev \ + zip \ + make \ + wget \ + gnupg \ + cron \ + supervisor \ + mariadb-client \ + libcurl4-openssl-dev \ + python3-colorama \ + nginx \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# ----------------------------- PHP Extensions ------------------------------- +# Install PHP extensions and enable additional ones +RUN docker-php-ext-install -j$(nproc) \ + pdo \ + pdo_mysql \ + pdo_sqlite \ + mysqli \ + curl \ + opcache \ + sockets \ + zip \ + pcntl + +# ----------------------------- Additional Tools ----------------------------- +# Install Phive (Package Manager for PHAR libraries) and global tools in one step +RUN wget -O /usr/local/bin/phive https://phar.io/releases/phive.phar && \ + wget -O /usr/local/bin/phive.asc https://phar.io/releases/phive.phar.asc && \ + gpg --keyserver hkps://keys.openpgp.org --recv-keys 0x9D8A98B29B2D5D79 && \ + gpg --verify /usr/local/bin/phive.asc /usr/local/bin/phive && \ + chmod +x /usr/local/bin/phive && \ + phive install phpab --global --trust-gpg-keys 0x2A8299CE842DD38C + +# ----------------------------- Clone and Build NCC -------------------------- +# Clone the NCC repository, build the project, and install it +RUN git clone https://git.n64.cc/nosial/ncc.git && \ + cd ncc && \ + make redist && \ + NCC_DIR=$(find build/ -type d -name "ncc_*" | head -n 1) && \ + if [ -z "$NCC_DIR" ]; then \ + echo "NCC build directory not found"; \ + exit 1; \ + fi && \ + php "$NCC_DIR/INSTALL" --auto && \ + cd .. && rm -rf ncc + +# ----------------------------- Project Build --------------------------------- +# Set build directory and copy pre-needed project files +WORKDIR /tmp/build +COPY . . + +RUN ncc build --config release --build-source --log-level debug && \ + ncc package install --package=build/release/net.nosial.fileserver.ncc --build-source -y --log-level=debug + +# Clean up +RUN rm -rf /tmp/build && rm -rf /var/www/html/* + +# Copy over the required files +COPY docker/nginx.conf /etc/nginx/nginx.conf +COPY public/index.php /var/www/html/index.php +RUN chown -R www-data:www-data /var/www/html && chmod -R 755 /var/www/html + +# Copy Supervisor configuration +COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +# Copy the logging server script over +COPY docker/logger.py /logger.py + +# ----------------------------- Cleanup --------------------- +WORKDIR / + +# ----------------------------- Port Exposing --------------------------------- +EXPOSE 8081 + +# UDP Logging Server +ENV LOGLIB_UDP_ENABLED="true" +ENV LOGLIB_UDP_HOST="127.0.0.1" +ENV LOGLIB_UDP_PORT="5131" +ENV LOGLIB_UDP_TRACE_FORMAT="full" +ENV LOGLIB_CONSOLE_ENABLED="true" +ENV LOGLIB_CONSOLE_TRACE_FORMAT="full" + +# Set the entrypoint +ENTRYPOINT ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/Makefile b/Makefile index 19fe18d..53c71bb 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ debug_executable: install: release - ncc package install --package=build/release/net.nosial.file_server.ncc --skip-dependencies --build-source --reinstall -y --log-level $(LOG_LEVEL) + ncc package install --package=build/release/net.nosial.fileserver.ncc --skip-dependencies --build-source --reinstall -y --log-level $(LOG_LEVEL) test: release [ -f phpunit.xml ] || { echo "phpunit.xml not found"; exit 1; } diff --git a/bootstrap.php b/bootstrap.php index d95ac22..7a5ed7c 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -1,3 +1,3 @@ int: + return { + cls.DEBUG: logging.DEBUG, + cls.VERBOSE: logging.DEBUG, + cls.INFO: logging.INFO, + cls.WARNING: logging.WARNING, + cls.ERROR: logging.ERROR, + cls.CRITICAL: logging.CRITICAL + }.get(level, logging.INFO) + + @classmethod + def get_color(cls, level: str) -> str: + return { + cls.DEBUG: Fore.CYAN, + cls.VERBOSE: Fore.BLUE, + cls.INFO: Fore.GREEN, + cls.WARNING: Fore.YELLOW, + cls.ERROR: Fore.RED, + cls.CRITICAL: Fore.RED + Back.WHITE + }.get(level, Fore.WHITE) + + +@dataclass +class StackFrame: + file: Optional[str] + line: Optional[int] + function: Optional[str] + args: Optional[List[Any]] + class_name: Optional[str] + call_type: str = 'static' + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'StackFrame': + return cls( + file=str(data.get('file')) if data.get('file') else None, + line=int(data['line']) if data.get('line') is not None else None, + function=str(data.get('function')) if data.get('function') else None, + args=data.get('args'), + class_name=str(data.get('class')) if data.get('class') else None, + call_type=str(data.get('callType', 'static')) + ) + + def format(self) -> str: + location = f"{self.file or '?'}:{self.line or '?'}" + if self.class_name: + call = f"{self.class_name}{self.call_type}{self.function or ''}" + else: + call = self.function or '' + + args_str = "" + if self.args: + args_str = f"({', '.join(str(arg) for arg in self.args)})" + + return f"{Fore.BLUE}{call}{Style.RESET_ALL}{args_str} in {Fore.CYAN}{location}{Style.RESET_ALL}" + + +class ExceptionDetails: + def __init__(self, name: str, message: str, code: Optional[int], + file: Optional[str], line: Optional[int], + trace: List[StackFrame], previous: Optional['ExceptionDetails']): + self.name = name + self.message = message + self.code = code + self.file = file + self.line = line + self.trace = trace + self.previous = previous + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Optional['ExceptionDetails']: + if not data: + return None + + trace = [] + if 'trace' in data and isinstance(data['trace'], list): + trace = [StackFrame.from_dict(frame) for frame in data['trace'] + if isinstance(frame, dict)] + + previous = None + if 'previous' in data and isinstance(data['previous'], dict): + previous = cls.from_dict(data['previous']) + + return cls( + name=str(data.get('name', '')), + message=str(data.get('message', '')), + code=int(data['code']) if data.get('code') is not None else None, + file=str(data.get('file')) if data.get('file') else None, + line=int(data['line']) if data.get('line') is not None else None, + trace=trace, + previous=previous + ) + + def format(self, level: int = 0) -> str: + indent = " " * level + parts = [] + + # Exception header + header = f"{indent}{Fore.RED}{self.name}" + if self.code is not None and 0: + header += f" {Fore.YELLOW}:{self.code}{Style.RESET_ALL}" + + # Message + header += f"{Fore.WHITE}:{Style.RESET_ALL} {self.message}" + + # Location + if self.file and self.line: + header += f"{Fore.WHITE} at {Style.RESET_ALL}{self.file}:{self.line}" + + parts.append(header) + + # Stack trace + if self.trace: + parts.append(f"{indent}{Fore.WHITE}Stack trace:{Style.RESET_ALL}") + for frame in self.trace: + parts.append(f"{indent} → {frame.format()}") + + # Previous exception + if self.previous: + parts.append(f"{indent}{Fore.YELLOW}Caused by:{Style.RESET_ALL}") + parts.append(self.previous.format(level + 1)) + + return "\n".join(parts) + + +class ColoredLogger(logging.Logger): + def __init__(self, name: str): + super().__init__(name) + self.formatter = logging.Formatter( + f'%(asctime)s {Fore.WHITE}[%(levelname)s]{Style.RESET_ALL} %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(self.formatter) + self.addHandler(console_handler) + + +class MultiProtocolServer: + def __init__(self, host: str, port: int, working_directory: str): + self.host = host + self.port = port + self.working_directory = working_directory + self.log_queue: Queue = Queue() + self.current_date = datetime.now().strftime('%Y-%m-%d') + self.log_file = None + self.stop_event = threading.Event() + + os.makedirs(self.working_directory, exist_ok=True) + + # Set up colored logging + logging.setLoggerClass(ColoredLogger) + self.logger = logging.getLogger("MultiProtocolServer") + self.logger.setLevel(logging.DEBUG) + + def _handle_log_event(self, data: Dict[str, Any], address: tuple) -> None: + """Process and format a structured log event with colors and proper formatting.""" + try: + app_name = data.get('application_name', 'Unknown') + timestamp = data.get('timestamp') + if timestamp: + try: + timestamp = datetime.fromtimestamp(int(timestamp)) + timestamp = timestamp.strftime('%Y-%m-%d %H:%M:%S') + except (ValueError, TypeError): + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + else: + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + level = data.get('level', 'INFO') + message = data.get('message', '') + + # Format the log message with colors + color = LogLevel.get_color(level) + log_message = f"{color}[{app_name}]{Style.RESET_ALL} {message}" + + # Handle exception if present + exception_data = data.get('exception') + if exception_data: + exception = ExceptionDetails.from_dict(exception_data) + if exception: + log_message += f"\n{exception.format()}" + + # Log with appropriate level + python_level = LogLevel.to_python_level(level) + self.logger.log(python_level, log_message) + + # Add to log queue for file logging + self.log_queue.put({ + "timestamp": timestamp, + "address": address, + "data": data + }) + + except Exception as e: + self.logger.error(f"Error processing log event: {e}", exc_info=True) + + def _handle_data(self, data: bytes, address: tuple) -> None: + """Process incoming data and attempt to parse as JSON.""" + try: + decoded_data = data.decode('utf-8').strip() + + try: + json_data = json.loads(decoded_data) + # Handle structured log event + self._handle_log_event(json_data, address) + except json.JSONDecodeError: + # Log raw data if not valid JSON + self.logger.info(f"Received non-JSON data from {address}: {decoded_data}") + self.log_queue.put({ + "timestamp": datetime.now().isoformat(), + "address": address, + "data": decoded_data + }) + + except Exception as e: + self.logger.error(f"Data handling error: {e}") + + # Rest of the class remains the same... + def _get_log_file(self): + date = datetime.now().strftime('%Y-%m-%d') + if date != self.current_date or self.log_file is None: + if self.log_file: + self.log_file.close() + self.current_date = date + filename = os.path.join(self.working_directory, f"log{date}.jsonl") + self.log_file = open(filename, 'a') + return self.log_file + + def _log_writer(self): + while not self.stop_event.is_set() or not self.log_queue.empty(): + try: + data = self.log_queue.get(timeout=1) + log_file = self._get_log_file() + json.dump(data, log_file) + log_file.write('\n') + log_file.flush() + except Empty: + continue + except Exception as e: + self.logger.error(f"Error writing to log file: {e}") + + def _handle_tcp_client(self, client_socket, address): + self.logger.info(f"TCP connection established from {address}") + try: + with client_socket: + while True: + data = client_socket.recv(4096) + if not data: + break + self._handle_data(data, address) + except Exception as e: + self.logger.error(f"TCP client error: {e}") + self.logger.info(f"TCP connection closed from {address}") + + def _handle_udp_client(self, data, address): + try: + self._handle_data(data, address) + except Exception as e: + self.logger.error(f"UDP client error: {e}") + + def _start_tcp_server(self): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tcp_socket: + tcp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_socket.bind((self.host, self.port)) + tcp_socket.listen(5) + self.logger.debug(f"TCP server running on {self.host}:{self.port}") + + while not self.stop_event.is_set(): + try: + client_socket, address = tcp_socket.accept() + threading.Thread(target=self._handle_tcp_client, + args=(client_socket, address), + daemon=True).start() + except Exception as e: + self.logger.error(f"TCP server error: {e}") + + def _start_udp_server(self): + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp_socket: + udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024) + udp_socket.bind((self.host, self.port)) + self.logger.debug(f"UDP server running on {self.host}:{self.port}") + + while not self.stop_event.is_set(): + try: + data, address = udp_socket.recvfrom(65535) + self._handle_udp_client(data, address) + except Exception as e: + self.logger.error(f"UDP server error: {e}") + + def start(self): + self.logger.info("Starting MultiProtocolServer...") + threading.Thread(target=self._log_writer, daemon=True).start() + tcp_thread = threading.Thread(target=self._start_tcp_server, daemon=True) + udp_thread = threading.Thread(target=self._start_udp_server, daemon=True) + tcp_thread.start() + udp_thread.start() + + try: + tcp_thread.join() + udp_thread.join() + except KeyboardInterrupt: + self.stop() + + def stop(self): + self.logger.info("Stopping Logging Server...") + self.stop_event.set() + if self.log_file: + self.log_file.close() + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Logging Server") + parser.add_argument("-p", "--port", type=int, default=8080, + help="Port to listen on") + parser.add_argument("-w", "--working-directory", type=str, + default="./logs", help="Directory to store log files") + args = parser.parse_args() + + server = MultiProtocolServer("0.0.0.0", args.port, args.working_directory) + server.start() \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..89478eb --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,37 @@ +http { + include mime.types; + default_type application/octet-stream; + + server { + listen 8081; + server_name localhost; + + access_log /var/log/access.log; + error_log /var/log/error.log; + + root /var/www/html; + index index.php; + + # Handle all requests + location / { + try_files $uri $uri/ /index.php?$query_string =503; + autoindex off; + } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + # Block any .ht* files + location ~ /\.ht { + deny all; + } + } +} + +events { + worker_connections 1024; +} \ No newline at end of file diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 0000000..b2090ed --- /dev/null +++ b/docker/supervisord.conf @@ -0,0 +1,61 @@ +[supervisord] +logfile=/var/logd.log +logfile_maxbytes=50MB +logfile_backups=10 +loglevel=info +user=root +pidfile=/var/run/supervisord.pid +umask=022 +nodaemon=true +minfds=1024 +minprocs=200 + +[program:logger] +command=python3 /logger.py --port 5131 +autostart=true +autorestart=true +priority=1 +stdout_events_enabled=true +stderr_events_enabled=true +stdout_logfile=/var/log/fileserver.log +stderr_logfile=/var/log/fileserver_error.log +stdout_logfile_maxbytes=20MB +stdout_logfile_backups=5 +stderr_logfile_maxbytes=20MB +stderr_logfile_backups=5 + +[program:php-fpm] +command=/usr/local/sbin/php-fpm --nodaemonize +autostart=true +autorestart=true +priority=20 +stdout_logfile=/var/log/fpm.log +stderr_logfile=/var/log/fpm_error.log +stdout_logfile_maxbytes=0 +stdout_logfile_backups=5 +stderr_logfile_maxbytes=0 +stderr_logfile_backups=5 + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf +autostart=true +autorestart=true +priority=10 +stdout_logfile=/var/log/nginx.log +stderr_logfile=/var/log/nginx_error.log +stdout_logfile_maxbytes=20MB +stdout_logfile_backups=5 +stderr_logfile_maxbytes=20MB +stderr_logfile_backups=5 + +[program:cron] +command=cron -f -L 15 +autostart=true +autorestart=true +priority=30 +stdout_logfile=/var/log/cron.log +stderr_logfile=/var/log/cron_error.log +stdout_logfile_maxbytes=20MB +stdout_logfile_backups=5 +stderr_logfile_maxbytes=20MB +stderr_logfile_backups=5 \ No newline at end of file diff --git a/main b/main index ec02a42..c3d55df 100644 --- a/main +++ b/main @@ -2,7 +2,7 @@ if (PHP_SAPI !== 'cli') { - print('net.nosial.file_server must be run from the command line.' . PHP_EOL); + print('net.nosial.fileserver must be run from the command line.' . PHP_EOL); exit(1); } @@ -14,11 +14,11 @@ } else { - print('net.nosial.file_server failed to run, no $argv found.' . PHP_EOL); + print('net.nosial.fileserver failed to run, no $argv found.' . PHP_EOL); exit(1); } } require('ncc'); - \ncc\Classes\Runtime::import('net.nosial.file_server', 'latest'); + \ncc\Classes\Runtime::import('net.nosial.fileserver', 'latest'); exit(\FileServer\Program::main($argv)); \ No newline at end of file diff --git a/project.json b/project.json index 777f0ae..3558330 100644 --- a/project.json +++ b/project.json @@ -11,7 +11,7 @@ }, "assembly": { "name": "FileServer", - "package": "net.nosial.file_server", + "package": "net.nosial.fileserver", "description": "A simple standard HTTP file upload server", "version": "1.0.0", "uuid": "8ee504ac-b27f-4f67-9730-ab3904be916c" diff --git a/src/FileServer/Classes/Configuration.php b/src/FileServer/Classes/Configuration.php new file mode 100644 index 0000000..1c25009 --- /dev/null +++ b/src/FileServer/Classes/Configuration.php @@ -0,0 +1,168 @@ +setDefault('server.name', 'FileServer'); + self::$configuration->setDefault('server.storage_type', 'local'); + self::$configuration->setDefault('server.password', null); + self::$configuration->setDefault('server.upload_password', null); + self::$configuration->setDefault('server.read_only', false); + self::$configuration->setDefault('server.filter_extensions', false); + self::$configuration->setDefault('server.allowed_extensions', ['jpg', 'jpeg', 'png', 'pdf', 'docx', 'xlsx', 'txt']); + self::$configuration->setDefault('server.max_execution_time', 3600); // 1 hour + self::$configuration->setDefault('server.max_file_size', 1073741824); // 1GB + self::$configuration->setDefault('server.working_path', '/etc/fileserver/tmp'); // If null, will default to a temporary directory + + // Database Configuration + self::$configuration->setDefault('database.driver', 'sql'); + // PDO Configuration + self::$configuration->setDefault('database.sql.host', '127.0.0.1'); + self::$configuration->setDefault('database.sql.port', '3306'); + self::$configuration->setDefault('database.sql.username', 'root'); + self::$configuration->setDefault('database.sql.password', 'root'); + self::$configuration->setDefault('database.sql.database', 'fileserver'); + // SQLITE Configuration + self::$configuration->setDefault('database.sqlite.path', '/etc/fileserver/database.sqlite'); + + // Local storage configuration + self::$configuration->setDefault('local_storage.storage_directory', '/var/www/uploads'); + self::$configuration->setDefault('local_storage.max_file_size', 2147483648); // 2GB + self::$configuration->setDefault('local_storage.max_storage_size', 536870912000); // 500GB Max storage size + self::$configuration->setDefault('local_storage.return_content_size', true); // If True, Content-Size is returned + + // Proxy storage configuration + self::$configuration->setDefault('proxy_storage.endpoint', 'https://proxy.example.com/upload'); + self::$configuration->setDefault('proxy_storage.upload_password', null); + self::$configuration->setDefault('proxy_storage.admin_password', null); + self::$configuration->setDefault('proxy_storage.max_file_size', 2147483648); // 2GB + self::$configuration->setDefault('proxy_storage.curl_timeout', 3600); // 1 hour + self::$configuration->setDefault('proxy_storage.return_content_size', true); // If True, Content-Size is returnedp + + // Custom storage configuration + self::$configuration->setDefault('custom_storage.package', null); + self::$configuration->setDefault('custom_storage.class', null); + self::$configuration->setDefault('custom_storage.config', []); + + // Save & load the configuration + self::$configuration->save(); + self::$serverConfiguration = new ServerConfiguration(self::$configuration->getConfiguration()['server']); + self::$localStorageConfiguration = new LocalStorageConfiguration(self::$configuration->getConfiguration()['local_storage']); + self::$proxyStorageConfiguration = new ProxyStorageConfiguration(self::$configuration->getConfiguration()['proxy_storage']); + self::$customStorageConfiguration = new CustomStorageConfiguration(self::$configuration->getConfiguration()['custom_storage']); + self::$databaseConfiguration = new DatabaseConfiguration(self::$configuration->getConfiguration()['database']); + } + + /** + * Returns the main ConfigurationLib instance for this instance + * + * @return \ConfigLib\Configuration The ConfigurationLib object + */ + public static function getConfigurationLib(): \ConfigLib\Configuration + { + if(self::$configuration === null) + { + self::initializeConfiguration(); + } + + return self::$configuration; + } + + /** + * Returns the server configuration object + * + * @return ServerConfiguration The server configuration object + */ + public static function getServerConfiguration(): ServerConfiguration + { + if(self::$serverConfiguration === null) + { + self::initializeConfiguration(); + } + + return self::$serverConfiguration; + } + + /** + * Returns the local storage configuration object + * + * @return LocalStorageConfiguration The local storage configuration object + */ + public static function getLocalStorageConfiguration(): LocalStorageConfiguration + { + if(self::$localStorageConfiguration === null) + { + self::initializeConfiguration(); + } + + return self::$localStorageConfiguration; + } + + /** + * Returns the proxy storage configuration object + * + * @return ProxyStorageConfiguration The proxy storage configuration object + */ + public static function getProxyStorageConfiguration(): ProxyStorageConfiguration + { + if(self::$proxyStorageConfiguration === null) + { + self::initializeConfiguration(); + } + + return self::$proxyStorageConfiguration; + } + + /** + * Returns the custom storage configuration object + * + * @return CustomStorageConfiguration The custom storage configuration object + */ + public static function getCustomStorageConfiguration(): CustomStorageConfiguration + { + if(self::$customStorageConfiguration === null) + { + self::initializeConfiguration(); + } + + return self::$customStorageConfiguration; + } + + /** + * Returns the database configuration object + * + * @return DatabaseConfiguration The database configuration object + */ + public static function getDatabaseConfiguration(): DatabaseConfiguration + { + if(self::$databaseConfiguration === null) + { + self::initializeConfiguration(); + } + + return self::$databaseConfiguration; + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Configuration/CustomStorageConfiguration.php b/src/FileServer/Classes/Configuration/CustomStorageConfiguration.php new file mode 100644 index 0000000..ee4ac0b --- /dev/null +++ b/src/FileServer/Classes/Configuration/CustomStorageConfiguration.php @@ -0,0 +1,52 @@ +package = $data['package']; + $this->class = $data['class']; + $this->config = $data['config']; + } + + /** + * Returns the package name of the custom storage + * + * @return string|null The package name + */ + public function getPackage(): ?string + { + return $this->package; + } + + /** + * Returns the class name of the custom storage + * + * @return string|null The class name + */ + public function getClass(): ?string + { + return $this->class; + } + + /** + * Returns the options for the custom storage + * + * @return array|null The options + */ + public function getConfig(): ?array + { + return $this->config; + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Configuration/DatabaseConfiguration.php b/src/FileServer/Classes/Configuration/DatabaseConfiguration.php new file mode 100644 index 0000000..1ce326a --- /dev/null +++ b/src/FileServer/Classes/Configuration/DatabaseConfiguration.php @@ -0,0 +1,54 @@ +driver = DatabaseDriverType::from(mb_strtoupper($data['driver'])); + $this->sqlDatabaseConfiguration = new SqlDatabaseConfiguration($data['sql']); + $this->sqliteDatabaseConfiguration = new SqliteDatabaseConfiguration($data['sqlite']); + } + + /** + * Returns the driver type used for the database configuration + * + * @return DatabaseDriverType The driver type used + */ + public function getDriver(): DatabaseDriverType + { + return $this->driver; + } + + /** + * Returns the SQL configuration + * + * @return SqlDatabaseConfiguration The SQL Configuration + */ + public function getSqlDatabaseConfiguration(): SqlDatabaseConfiguration + { + return $this->sqlDatabaseConfiguration; + } + + /** + * Returns the SQLITE configuration + * + * @return SqliteDatabaseConfiguration The SQLITE Configuration + */ + public function getSqliteDatabaseConfiguration(): SqliteDatabaseConfiguration + { + return $this->sqliteDatabaseConfiguration; + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Configuration/LocalStorageConfiguration.php b/src/FileServer/Classes/Configuration/LocalStorageConfiguration.php new file mode 100644 index 0000000..ac61779 --- /dev/null +++ b/src/FileServer/Classes/Configuration/LocalStorageConfiguration.php @@ -0,0 +1,64 @@ +storageDirectory = $data['storage_directory']; + $this->maxFileSize = $data['max_file_size']; + $this->maxStorageSize = $data['max_storage_size']; + $this->returnContentSize = $data['return_content_size'] ?? false; + } + + /** + * Get the path to the local storage directory. + * + * @return string The path to the local storage directory. + */ + public function getStorageDirectory(): string + { + return $this->storageDirectory; + } + + /** + * Gets the maximum size of a file upload + * + * @return int The maximum size of a file that can be uploaded + */ + public function getMaxFileSize(): int + { + return $this->maxFileSize; + } + + /** + * Gets the maximum storage size of the local storage in byets + * + * @return int The maximum size of the local storage + */ + public function getMaxStorageSize(): int + { + return $this->maxStorageSize; + } + + /** + * Check if the content size should be returned. + * + * @return bool True if the content size should be returned, false otherwise. + */ + public function shouldReturnContentSize(): bool + { + return $this->returnContentSize; + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Configuration/ProxyStorageConfiguration.php b/src/FileServer/Classes/Configuration/ProxyStorageConfiguration.php new file mode 100644 index 0000000..227d045 --- /dev/null +++ b/src/FileServer/Classes/Configuration/ProxyStorageConfiguration.php @@ -0,0 +1,67 @@ +endpoint = (string)$data['endpoint']; + $this->uploadPassword = (string)$data['upload_password'] ?? null; + $this->adminPassword = (string)$data['admin_password'] ?? null; + $this->maxFileSize = (int)$data['max_file_size']; + $this->curlTimeout = (int)$data['curl_timeout']; + } + + /** + * Returns the endpoint of the proxy server to upload to + * + * @return string The endpoint of the proxy server + */ + public function getEndpoint(): string + { + return $this->endpoint; + } + + /** + * Optional. Returns the upload password if one is required to upload a file to the proxy server + * + * @return string|null The upload password, null if none is set. + */ + public function getUploadPassword(): ?string + { + return $this->uploadPassword; + } + + /** + * Optional. Returns the admin password if one is required to manage the proxy server + * + * @return string|null The admin password, null if none is set + */ + public function getAdminPassword(): ?string + { + return $this->adminPassword; + } + + + public function getMaxFileSize(): int + { + return $this->maxFileSize; + } + + public function getCurlTimeout(): int + { + return $this->curlTimeout; + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Configuration/ServerConfiguration.php b/src/FileServer/Classes/Configuration/ServerConfiguration.php new file mode 100644 index 0000000..0094ee3 --- /dev/null +++ b/src/FileServer/Classes/Configuration/ServerConfiguration.php @@ -0,0 +1,144 @@ +name = $data['name']; + $this->storageType = StorageType::from(mb_strtoupper($data['storage_type'])); + $this->password = $data['password']; + $this->uploadPassword = $data['upload_password']; + $this->readOnly = (bool)$data['read_only']; + $this->filterExtensions = (bool)$data['filter_extensions']; + $this->allowedExtensions = $data['allowed_extensions']; + $this->maxExecutionTime = (int)$data['max_execution_time']; + $this->maxFileSize = (int)$data['max_file_size']; + $this->workingPath = $data['working_path']; + } + + /** + * Returns the name of the server + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Returns the storage type of the server. + * + * @return StorageType The storage type + */ + public function getStorageType(): StorageType + { + return $this->storageType; + } + + /** + * Returns the password for the server. + * + * @return string|null The password or null if not set + */ + public function getPassword(): ?string + { + return $this->password; + } + + /** + * Returns the upload password or null if not set + * + * @return string|null + */ + public function getUploadPassword(): ?string + { + return $this->uploadPassword; + } + + /** + * Returns true if the server is in read-only mode. + * + * @return bool True if read-only, false otherwise + */ + public function isReadOnly(): bool + { + return $this->readOnly; + } + + /** + * Returns true if the server is filtering file extensions. + * + * @return bool True if filtering extensions, false otherwise + */ + public function isFilteringExtensions(): bool + { + return $this->filterExtensions; + } + + /** + * Returns the allowed file extensions. + * + * @return array The allowed file extensions + */ + public function getAllowedExtensions(): array + { + return $this->allowedExtensions; + } + + /** + * Returns the maximum execution time in seconds. + * + * @return int The maximum execution time + */ + public function getMaxExecutionTime(): int + { + return $this->maxExecutionTime; + } + + /** + * Returns the maximum file size in bytes. + * + * @return int The maximum file size + */ + public function getMaxFileSize(): int + { + return $this->maxFileSize; + } + + /** + * Returns the working path for working with temporary files, if null the method will return the system's + * temporary directory instead. + * + * @return string The working path + */ + public function getWorkingPath(): string + { + if($this->workingPath === null) + { + return sys_get_temp_dir(); + } + + return $this->workingPath; + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Configuration/SqlDatabaseConfiguration.php b/src/FileServer/Classes/Configuration/SqlDatabaseConfiguration.php new file mode 100644 index 0000000..7a8c753 --- /dev/null +++ b/src/FileServer/Classes/Configuration/SqlDatabaseConfiguration.php @@ -0,0 +1,86 @@ +host = $data['host']; + $this->port = (int)$data['port']; + $this->username = $data['username']; + $this->password = $data['password']; + $this->database = $data['database']; + } + + /** + * Returns the host of the database + * + * @return string + */ + public function getHost(): string + { + return $this->host; + } + + /** + * Returns the port of the database + * + * @return int + */ + public function getPort(): int + { + return $this->port; + } + + /** + * Returns the username of the database + * + * @return string + */ + public function getUsername(): string + { + return $this->username; + } + + /** + * Returns the password of the database + * + * @return string + */ + public function getPassword(): string + { + return $this->password; + } + + /** + * Returns the database name + * + * @return string + */ + public function getDatabase(): string + { + return $this->database; + } + + /** + * Returns the full DSN connection string of the database + * + * @return string + */ + public function getDsn(): string + { + return sprintf('mysql:host=%s;port=%d;dbname=%s', $this->host, $this->port, $this->database); + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Configuration/SqliteDatabaseConfiguration.php b/src/FileServer/Classes/Configuration/SqliteDatabaseConfiguration.php new file mode 100644 index 0000000..2540d10 --- /dev/null +++ b/src/FileServer/Classes/Configuration/SqliteDatabaseConfiguration.php @@ -0,0 +1,28 @@ +path = $data['path']; + } + + /** + * Returns the path of the SQLite database + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Database.php b/src/FileServer/Classes/Database.php new file mode 100644 index 0000000..205b6f9 --- /dev/null +++ b/src/FileServer/Classes/Database.php @@ -0,0 +1,167 @@ +getDriver() === DatabaseDriverType::SQL) + { + self::$connection = new PDO( + dsn: Configuration::getDatabaseConfiguration()->getSqlDatabaseConfiguration()->getDsn(), + username: Configuration::getDatabaseConfiguration()->getSqlDatabaseConfiguration()->getUsername(), + password: Configuration::getDatabaseConfiguration()->getSqlDatabaseConfiguration()->getPassword(), + ); + + // Initialization must be done after connection + if(!self::$initializationChecked) + { + self::initializeSqlDatabase(); + self::$initializationChecked = true; + } + } + + if(Configuration::getDatabaseConfiguration()->getDriver() === DatabaseDriverType::SQLITE) + { + // Initialize the SQLite database if it doesn't exist, must be done before connection + if(!self::$initializationChecked) + { + self::initializeSqliteSDatabase(); + self::$initializationChecked = true; + } + + self::$connection = new PDO( + dsn: sprintf('sqlite:%s', Configuration::getDatabaseConfiguration()->getSqliteDatabaseConfiguration()->getPath()), + ); + } + + // Set the error mode to exception and turn off emulated prepares + self::$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + } + + /** + * Initializes the SQLite database by copying the initial database file if it doesn't exist. + * + * @throws DatabaseException If an error occurs while copying the database file. + */ + private static function initializeSqliteSDatabase(): void + { + if(file_exists(Configuration::getDatabaseConfiguration()->getSqliteDatabaseConfiguration()->getPath())) + { + return; + } + + if(!copy( + __DIR__ . DIRECTORY_SEPARATOR . 'Resources' . DIRECTORY_SEPARATOR . 'database.sqlite', + Configuration::getDatabaseConfiguration()->getSqliteDatabaseConfiguration()->getPath() + )) + { + throw new DatabaseException(sprintf('Failed to copy SQLite database file to %s', Configuration::getDatabaseConfiguration()->getSqliteDatabaseConfiguration()->getPath())); + } + } + + /** + * Initializes the SQL database by creating the necessary tables if they do not exist. + * + * @throws DatabaseException If an error occurs while creating the tables. + */ + + private static function initializeSqlDatabase(): void + { + // Check + + if(!self::sqlTableExists('uploads')) + { + $uploadsTable = __DIR__ . DIRECTORY_SEPARATOR . 'Resources' . DIRECTORY_SEPARATOR . 'uploads.sql'; + if(!file_exists($uploadsTable) || !is_readable($uploadsTable)) + { + throw new RuntimeException(sprintf('Resource file %s not found', $uploadsTable)); + } + + // Safely load the SQL file to create the table + try + { + self::$connection->exec(file_get_contents($uploadsTable)); + } + catch(PDOException $e) + { + throw new DatabaseException('Error creating uploads table: ' . $e->getMessage()); + } + } + + if(!self::sqlTableExists('statistics')) + { + $statisticsTable = __DIR__ . DIRECTORY_SEPARATOR . 'Resources' . DIRECTORY_SEPARATOR . 'statistics.sql'; + if(!file_exists($statisticsTable) || !is_readable($statisticsTable)) + { + throw new RuntimeException(sprintf('Resource file %s not found', $statisticsTable)); + } + + // Safely load the SQL file to create the table + try + { + self::$connection->exec(file_get_contents($statisticsTable)); + } + catch(PDOException $e) + { + throw new DatabaseException('Error creating statistics table: ' . $e->getMessage()); + } + } + } + + /** + * Checks if the specified table exists in the database. + * + * @param string $tableName The name of the table to check. + * @return bool True if the table exists, false otherwise. + * @throws DatabaseException If an error occurs while checking the table existence. + */ + private static function sqlTableExists(string $tableName): bool + { + try + { + self::$connection->query("SELECT 1 FROM $tableName LIMIT 1"); + return true; + } + catch(PDOException $e) + { + if($e->getCode() === '42S02') // Table not found + { + return false; + } + + throw new DatabaseException('Error checking table existence: ' . $e->getMessage()); + } + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Handlers/LocalHandler.php b/src/FileServer/Classes/Handlers/LocalHandler.php new file mode 100644 index 0000000..2d1cea1 --- /dev/null +++ b/src/FileServer/Classes/Handlers/LocalHandler.php @@ -0,0 +1,345 @@ + 1) + { + throw new ServerException('Multiple files not allowed'); + } + + $fileKey = key($_FILES); + if (!$fileKey) + { + throw new ServerException('No file provided'); + } + + $file = $_FILES[$fileKey]; + + // Handle array uploads + if (is_array($file['name'])) + { + if (count($file['name']) > 1) + { + throw new ServerException('Multiple files not allowed'); + } + + $fileName = $file['name'][0]; + $tmpName = $file['tmp_name'][0]; + $uploadError = $file['error'][0]; + } + else + { + $fileName = $file['name']; + $tmpName = $file['tmp_name']; + $uploadError = $file['error']; + } + + if ($uploadError !== UPLOAD_ERR_OK) + { + throw new ServerException("Upload error: " . $uploadError); + } + + } + else + { + // Raw upload: get filename from headers + // Try X-Filename header + $fileName = self::getHeaderValue('X-Filename'); + + // If not found, check Content-Disposition + if (!$fileName) + { + $contentDisposition = self::getHeaderValue('Content-Disposition'); + if ($contentDisposition) + { + preg_match('/filename="?([^"\r\n]+)"?/', $contentDisposition, $matches); + if (!empty($matches[1])) + { + $fileName = $matches[1]; + } + } + } + } + + // Validate filename length + if (!is_null($fileName) && mb_strlen($fileName) > self::MAX_FILENAME_LENGTH) + { + throw new ServerException('Filename exceeds ' . self::MAX_FILENAME_LENGTH . ' characters'); + } + + // Sanitize filename: remove path info + if(!is_null($fileName)) + { + $fileName = basename($fileName); + + // Validate filename characters + if (!preg_match(self::FILENAME_PATTERN, $fileName)) + { + throw new ServerException('Invalid filename characters'); + } + } + + // Prepare storage path + $baseStoragePath = Configuration::getLocalStorageConfiguration()->getStorageDirectory(); + if (!is_dir($baseStoragePath) && !mkdir($baseStoragePath, 0755, true)) + { + throw new UploadException('Failed to create storage directory'); + } + + if(is_null($fileName)) + { + $targetPath = $baseStoragePath . DIRECTORY_SEPARATOR . $uuid; + } + else + { + $targetPath = $baseStoragePath . DIRECTORY_SEPARATOR . $fileName; + } + + // Initialize SHA-256 hashing context + if ($isMultipart) + { + if (!is_uploaded_file($tmpName)) + { + throw new UploadException("Invalid uploaded file"); + } + + // Open file and stream into hash context + $inputStream = fopen($tmpName, 'rb'); + if (!$inputStream) + { + throw new UploadException("Failed to open temporary file"); + } + + $fileHandle = fopen($targetPath, 'wb'); + if (!$fileHandle) + { + @fclose($inputStream); + @unlink($tmpName); + throw new UploadException("Failed to open target file for writing"); + } + + while (!feof($inputStream)) + { + if($fileSize > Configuration::getLocalStorageConfiguration()->getMaxFileSize()) + { + fclose($inputStream); + fclose($fileHandle); + unlink($targetPath); + + throw new UploadException(sprintf('File size exceeds maximum limit of %d bytes', Configuration::getLocalStorageConfiguration()->getMaxFileSize())); + } + + $buffer = fread($inputStream, self::BUFFER_SIZE); + $bytesRead = strlen($buffer); + + if ($bytesRead === 0) + { + continue; + } + + fwrite($fileHandle, $buffer); + $fileSize += $bytesRead; + } + + fclose($inputStream); + fclose($fileHandle); + } + else + { + // Stream raw upload to file + $inputStream = fopen('php://input', 'rb'); + if (!$inputStream) + { + throw new UploadException('Failed to read input stream'); + } + + $fileHandle = fopen($targetPath, 'wb'); + if (!$fileHandle) + { + fclose($inputStream); + throw new UploadException('Failed to open target file for writing'); + } + + while (!feof($inputStream)) + { + if($fileSize > Configuration::getLocalStorageConfiguration()->getMaxFileSize()) + { + fclose($inputStream); + fclose($fileHandle); + unlink($targetPath); + + throw new UploadException(sprintf('File size exceeds maximum limit of %d bytes', Configuration::getLocalStorageConfiguration()->getMaxFileSize())); + } + + $buffer = fread($inputStream, self::BUFFER_SIZE); + $bytesRead = strlen($buffer); + + if ($bytesRead === 0) + { + continue; + } + + fwrite($fileHandle, $buffer); + $fileSize += $bytesRead; + } + + fclose($inputStream); + fclose($fileHandle); + } + + // Update storage record + try + { + if(!is_null($fileName)) + { + IndexStorageManager::updateUploadName($uuid, $fileName); + } + + IndexStorageManager::updateUploadSize($uuid, $fileSize); + } + catch (Exception $e) + { + @unlink($targetPath); // Attempt to delete file + throw new UploadException('Failed to update storage record: ' . $e->getMessage(), $e->getCode(), $e); + } + finally + { + // Clean up temporary file if it exists + if ($isMultipart && file_exists($tmpName)) + { + @unlink($tmpName); + } + } + + // Success, return the pointers for later + return [ + 'filename' => $fileName, + 'filepath' => $targetPath, + 'size' => $fileSize, + ]; + } + + /** + * Helper to safely retrieve HTTP headers across server environments + * + * @param string $headerName The name of the header to retrieve + * @return string|null The value of the header or null if not set + */ + private static function getHeaderValue(string $headerName): ?string + { + $key = 'HTTP_' . strtoupper(str_replace('-', '_', $headerName)); + return $_SERVER[$key] ?? null; + } + + /** + * Calculates the total size of all files in the local storage directory + * + * @return int The total size of all files in the local storage directory in bytes + */ + private static function calculateTotalStorageSize(): int + { + $storageDirectory = Configuration::getLocalStorageConfiguration()->getStorageDirectory(); + $totalSize = 0; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($storageDirectory)); + foreach ($files as $file) + { + if ($file->isFile()) + { + $totalSize += $file->getSize(); + } + } + + return $totalSize; + } + + /** + * @inheritDoc + */ + public static function handleDownload(IndexStorageRecord $record): void + { + $filePath = $record->getPointers()['filepath']; + if (!file_exists($filePath)) + { + FileServer::basicResponse(404, 'File not found'); + return; + } + + // Set appropriate headers + header('Content-Type: application/octet-stream'); + if(Configuration::getLocalStorageConfiguration()->shouldReturnContentSize()) + { + header('Content-Length: ' . $record->getSize()); + } + + header("Content-Disposition: attachment; filename=\"" . rawurlencode($record->getName()) . "\""); + + // Stream file in chunks + $handle = fopen($filePath, 'rb'); + while (!feof($handle)) + { + echo fread($handle, 8192); + ob_flush(); + flush(); + } + + fclose($handle); + exit; + } + + /** + * @inheritDoc + */ + public static function handleDelete(IndexStorageRecord $record): void + { + $filePath = $record->getPointers()['filepath']; + + if (file_exists($filePath) && !@unlink($filePath)) + { + throw new ServerException("Failed to delete file: " . $filePath); + } + + // Clean up UUID directory if empty + $dirPath = dirname($filePath); + if (is_dir($dirPath) && count(scandir($dirPath)) === 2) + { + rmdir($dirPath); + } + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Handlers/ProxyHandler.php b/src/FileServer/Classes/Handlers/ProxyHandler.php new file mode 100644 index 0000000..424688c --- /dev/null +++ b/src/FileServer/Classes/Handlers/ProxyHandler.php @@ -0,0 +1,422 @@ + 1) + { + throw new ServerException('Multiple files not allowed'); + } + + $fileKey = key($_FILES); + if (!$fileKey) + { + throw new ServerException('No file provided'); + } + + $file = $_FILES[$fileKey]; + + // Handle array format uploads + if (is_array($file['name'])) + { + if (count($file['name']) > 1) + { + throw new ServerException('Multiple files not allowed'); + } + + $fileName = $file['name'][0]; + $tmpName = $file['tmp_name'][0]; + $uploadError = $file['error'][0]; + } + else + { + $fileName = $file['name']; + $tmpName = $file['tmp_name']; + $uploadError = $file['error']; + } + + if ($uploadError !== UPLOAD_ERR_OK) + { + throw new ServerException("Upload error: " . $uploadError); + } + + if (!is_uploaded_file($tmpName)) + { + throw new ServerException('Invalid uploaded file'); + } + } + // Handle raw uploads + else + { + // Check Content-Length early for raw uploads + $contentLength = $_SERVER['CONTENT_LENGTH'] ?? null; + if ($contentLength !== null && $contentLength > Configuration::getProxyStorageConfiguration()->getMaxFileSize()) + { + throw new UploadException(sprintf('File size exceeds maximum limit of %d bytes', Configuration::getProxyStorageConfiguration()->getMaxFileSize())); + } + + $fileName = self::getHeaderValue('X-Filename'); + if (!$fileName) + { + $contentDisposition = self::getHeaderValue('Content-Disposition'); + if ($contentDisposition) + { + preg_match('/filename="?([^"\r\n]+)"?/', $contentDisposition, $matches); + if (!empty($matches[1])) + { + $fileName = $matches[1]; + } + } + } + } + + // Validate filename + if ($fileName !== null) + { + if (mb_strlen($fileName) > self::MAX_FILENAME_LENGTH) + { + throw new ServerException("Filename exceeds " . self::MAX_FILENAME_LENGTH . " characters"); + } + + $fileName = basename($fileName); + if (!preg_match(self::FILENAME_PATTERN, $fileName)) + { + throw new ServerException("Invalid filename characters"); + } + } + + // Initialize streams and tracking + $inputStream = $isMultipart ? fopen($tmpName, 'rb') : fopen('php://input', 'rb'); + if (!$inputStream) + { + throw new UploadException("Failed to open input stream"); + } + + // Initialize hash context + $hashContext = hash_init('sha256'); + $fileSize = 0; + $maxSize = Configuration::getProxyStorageConfiguration()->getMaxFileSize(); + $sizeExceeded = false; + + // Configure cURL + $remoteUrl = Configuration::getProxyStorageConfiguration()->getEndpoint(); + if (!$remoteUrl) + { + fclose($inputStream); + throw new UploadException("Remote endpoint not configured"); + } + + $uploadPassword = Configuration::getProxyStorageConfiguration()->getUploadPassword(); + + // Setup cURL options + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $remoteUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_UPLOAD => true, + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_READFUNCTION => function ($ch, $fd, $length) use ($inputStream, $hashContext, &$fileSize, $maxSize, &$sizeExceeded) + { + if (!is_resource($inputStream) || feof($inputStream)) + { + return ''; + } + + $buffer = fread($inputStream, $length); + if ($buffer === false) + { + return ''; + } + + $bytesRead = strlen($buffer); + if ($bytesRead === 0) + { + return ''; + } + + $fileSize += $bytesRead; + + if ($fileSize > $maxSize) + { + $sizeExceeded = true; + fclose($inputStream); + return ''; + } + + hash_update($hashContext, $buffer); + return $buffer; + }, + CURLOPT_HTTPHEADER => self::buildRemoteHeaders($fileName, $uploadPassword), + CURLOPT_HEADER => true, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 300 + ]); + + // Execute upload + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + + // Parse the response header for X-UUID + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $responseHeaders = substr($response, 0, $headerSize); + $responseUuid = null; + + foreach (explode("\r\n", $responseHeaders) as $header) + { + if (str_starts_with(strtolower($header), 'x-uuid:')) + { + $responseUuid = trim(substr($header, strlen('X-UUID:'))); + break; + } + } + + curl_close($ch); + + // Close input stream + if (is_resource($inputStream)) + { + fclose($inputStream); + } + + // Handle errors + if ($curlError) + { + throw new UploadException("cURL error during upload: " . $curlError); + } + + if ($sizeExceeded) + { + throw new UploadException(sprintf('File size exceeds maximum limit of %d bytes', $maxSize)); + } + + if ($httpCode < 200 || $httpCode >= 300) + { + throw new UploadException("Remote upload failed with code $httpCode: " . ($response ?: 'No response body')); + } + + // Update storage records + try + { + if ($fileName !== null) + { + IndexStorageManager::updateUploadName($uuid, $fileName); + } + + IndexStorageManager::updateUploadSize($uuid, $fileSize); + } + catch (Exception $e) + { + throw new UploadException("Storage update failed", $e->getCode(), $e); + } + + // Clean up temporary file + if ($isMultipart && file_exists($tmpName)) + { + @unlink($tmpName); + } + + return [ + 'filename' => $fileName, + 'download_url' => $response, + 'response_uuid' => $responseUuid, + ]; + } + + /** + * Builds the remote headers of the request + * + * @param string|null $fileName The file name to upload + * @param string|null $token Optional. The upload token for Bearer authentication + * @return string[] The result headers + */ + private static function buildRemoteHeaders(?string $fileName, ?string $token): array + { + $headers = [ + 'Content-Type: application/octet-stream', + ]; + + if ($fileName !== null) + { + $headers[] = 'X-Filename: ' . rawurlencode($fileName); + } + + if ($token !== null) + { + $headers[] = 'Authorization: Bearer ' . $token; + } + + return $headers; + } + + /** + * Returns the value of a header case-insensitively + * + * @param string $headerName The name of the header to get the value from + * @return string|null The value of the header, null if not set. + */ + private static function getHeaderValue(string $headerName): ?string + { + $headers = getallheaders(); + foreach ($headers as $key => $value) + { + if (strtolower($key) === strtolower($headerName)) + { + return $value; + } + } + + return null; + } + + /** + * @inheritDoc + */ + public static function handleDownload(IndexStorageRecord $record): void + { + $pointers = $record->getPointers(); + if (empty($pointers) || !isset($pointers['download_url'])) + { + throw new ServerException("No valid remote URL found in record"); + } + + $fileSize = $record->getSize(); + $uploadPassword = Configuration::getProxyStorageConfiguration()->getUploadPassword(); + + // Set up cURL to stream the download + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $pointers['download_url'], + CURLOPT_RETURNTRANSFER => false, + CURLOPT_HEADER => false, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_WRITEFUNCTION => function($ch, $data) + { + echo $data; + return strlen($data); + }, + CURLOPT_HTTPHEADER => $uploadPassword !== null ? ['Authorization: Bearer ' . $uploadPassword] : [], + CURLOPT_TIMEOUT => Configuration::getProxyStorageConfiguration()->getCurlTimeout() + ]); + + // Set appropriate headers for the download + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . $record->getName() . '"'); + if ($fileSize > 0) + { + header('Content-Length: ' . $fileSize); + } + header('Cache-Control: no-cache, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); + + // Execute the request and stream directly to output + $success = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if (!$success || ($httpCode < 200 || $httpCode >= 300)) + { + throw new ServerException("Remote download failed: " . ($error ?: "HTTP $httpCode"), (int)$httpCode); + } + } + + /** + * @inheritDoc + */ + public static function handleDelete(IndexStorageRecord $record): void + { + $pointers = $record->getPointers(); + if (empty($pointers) || !isset($pointers['response_uuid'])) + { + throw new ServerException("No valid UUID found in record for deletion"); + } + + $uuid = $pointers['response_uuid']; + $remoteUrl = Configuration::getProxyStorageConfiguration()->getEndpoint(); + + if (!$remoteUrl) + { + throw new ServerException("Remote endpoint not configured"); + } + + // Add UUID as GET parameter + $deleteUrl = rtrim($remoteUrl, '/') . '?' . http_build_query(['uuid' => $uuid]); + $adminPassword = Configuration::getProxyStorageConfiguration()->getAdminPassword(); + + // Setup cURL options + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $deleteUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => $adminPassword !== null ? ['Authorization: Bearer ' . $adminPassword] : [], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_TIMEOUT => Configuration::getProxyStorageConfiguration()->getCurlTimeout() + ]); + + // Execute deletion request + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + curl_close($ch); + + // Handle errors + if ($error) + { + throw new ServerException("cURL error during deletion: " . $error); + } + + if ($httpCode < 200 || $httpCode >= 300) + { + throw new ServerException("Remote deletion failed with code $httpCode: " . ($response ?: 'No response body')); + } + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/IndexStorageManager.php b/src/FileServer/Classes/IndexStorageManager.php new file mode 100644 index 0000000..4d45bbe --- /dev/null +++ b/src/FileServer/Classes/IndexStorageManager.php @@ -0,0 +1,431 @@ +prepare('INSERT INTO uploads (uuid, storage_type, name, size) VALUES (:uuid, :storage_type, :name, :size)'); + $uploadStmt->bindValue(':uuid', $uuid = Uuid::v4()->toRfc4122()); + $uploadStmt->bindValue(':storage_type', $type->value); + $uploadStmt->bindValue(':name', $fileName); + $uploadStmt->bindValue(':size', $size); + $uploadStmt->execute(); + + $statisticsStmt = Database::getConnection()->prepare('INSERT INTO statistics (uuid) VALUES (:uuid)'); + $statisticsStmt->bindValue(':uuid', $uuid); + $statisticsStmt->execute(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to create index.', 0, $e); + } + + return $uuid; + } + + /** + * Retrieves an upload entry from the database by its UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry to retrieve. + * @return IndexStorageRecord|null The upload entry, or null if not found. + * @throws InvalidArgumentException If the provided UUID is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function getUpload(string|IndexStorageRecord $uuid): ?IndexStorageRecord + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('SELECT * FROM uploads WHERE uuid=:uuid'); + $stmt->bindValue(':uuid', $uuid); + $stmt->execute(); + + if($stmt->rowCount() === 0) + { + return null; + } + + return IndexStorageRecord::fromArray($stmt->fetch(PDO::FETCH_ASSOC)); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to get index.', 0, $e); + } + } + + /** + * Retrieves the download count for a given UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry. + * @return int The download count. + * @throws InvalidArgumentException If the provided UUID is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function getDownloads(string|IndexStorageRecord $uuid): int + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('SELECT downloads FROM statistics WHERE uuid=:uuid'); + $stmt->bindValue(':uuid', $uuid); + $stmt->execute(); + + return (int)$stmt->fetchColumn(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to get index.', 0, $e); + } + } + + /** + * Retrieves the last download date for a given UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry. + * @return DateTime The last download date. + * @throws InvalidArgumentException If the provided UUID is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function getLastDownload(string|IndexStorageRecord $uuid): DateTime + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('SELECT last_download FROM statistics WHERE uuid=:uuid'); + $stmt->bindValue(':uuid', $uuid); + $stmt->execute(); + + try + { + return new DateTime($stmt->fetchColumn()); + } + catch (DateMalformedStringException $e) + { + throw new DatabaseException(sprintf('Failed to parse last_download column for %s', $uuid), $e); + } + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to get index.', 0, $e); + } + } + + /** + * Checks if an upload entry exists in the database by its UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry to check. + * @return bool True if the upload entry exists, false otherwise. + * @throws InvalidArgumentException If the provided UUID is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function uploadExists(string|IndexStorageRecord $uuid): bool + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('SELECT COUNT(*) FROM uploads WHERE uuid=:uuid'); + $stmt->bindValue(':uuid', $uuid); + $stmt->execute(); + + return (bool)$stmt->fetchColumn(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to check index existence.', 0, $e); + } + } + + /** + * Deletes an upload entry from the database by its UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry to delete. + * @throws InvalidArgumentException If the provided UUID is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function deleteUpload(string|IndexStorageRecord $uuid): void + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('DELETE FROM uploads WHERE uuid=:uuid'); + $stmt->bindValue(':uuid', $uuid); + + $stmt->execute(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to delete index.', 0, $e); + } + } + + /** + * Retrieves a list of upload entries from the database. + * + * @param int $page The page number to retrieve. Default is 1. + * @param int $limit The number of entries per page. Default is 100. + * @return IndexStorageRecord[] An array of IndexStorageRecord objects representing the upload entries. + * @throws InvalidArgumentException If the provided page or limit is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function getUploads(int $page=1, int $limit=100): array + { + if($page < 1) + { + throw new InvalidArgumentException(sprintf('The given page "%s" is invalid.', $page)); + } + + if($limit < 1) + { + throw new InvalidArgumentException(sprintf('The given limit "%s" is invalid.', $limit)); + } + + try + { + $stmt = Database::getConnection()->prepare('SELECT * FROM uploads ORDER BY created LIMIT :offset, :limit'); + $stmt->bindValue(':offset', ($page - 1) * $limit, PDO::PARAM_INT); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + + $stmt->execute(); + + return array_map(fn(array $row) => IndexStorageRecord::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to get indexes.', 0, $e); + } + } + + public static function updateUploadName(string|IndexStorageRecord $uuid, string $name): void + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('UPDATE uploads SET name=:name WHERE uuid=:uuid'); + $stmt->bindValue(':name', $name); + $stmt->bindValue(':uuid', $uuid); + + $stmt->execute(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to update index size.', 0, $e); + } + } + + /** + * Updates the upload size for a given UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry. + * @param int $bytes The new size in bytes. + * @throws InvalidArgumentException If the provided UUID is invalid or the size is negative. + * @throws DatabaseException If the database operation fails. + */ + public static function updateUploadSize(string|IndexStorageRecord $uuid, int $bytes): void + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('UPDATE uploads SET size=:size WHERE uuid=:uuid'); + $stmt->bindValue(':size', $bytes); + $stmt->bindValue(':uuid', $uuid); + + $stmt->execute(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to update index size.', 0, $e); + } + } + + /** + * Updates the upload status for a given UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry. + * @param IndexStorageStatus $status The new status. + * @throws InvalidArgumentException If the provided UUID is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function updateUploadStatus(string|IndexStorageRecord $uuid, IndexStorageStatus $status): void + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('UPDATE uploads SET status=:status WHERE uuid=:uuid'); + $stmt->bindValue(':status', $status->value); + $stmt->bindValue(':uuid', $uuid); + + $stmt->execute(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to update index status.', 0, $e); + } + } + + /** + * Updates the upload pointers for a given UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry. + * @param array $pointers The new pointers. + * @throws InvalidArgumentException If the provided UUID is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function updateUploadPointers(string|IndexStorageRecord $uuid, array $pointers): void + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('UPDATE uploads SET pointers=:pointers WHERE uuid=:uuid'); + $stmt->bindValue(':pointers', json_encode($pointers)); + $stmt->bindValue(':uuid', $uuid); + + $stmt->execute(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to update index pointers.', 0, $e); + } + } + + /** + * Increments the download count for a given UUID. + * + * @param string|IndexStorageRecord $uuid The UUID of the upload entry. + * @throws InvalidArgumentException If the provided UUID is invalid. + * @throws DatabaseException If the database operation fails. + */ + public static function incrementDownload(string|IndexStorageRecord $uuid): void + { + if($uuid instanceof IndexStorageRecord) + { + $uuid = $uuid->getUuid(); + } + elseif(!Uuid::isValid($uuid)) + { + throw new InvalidArgumentException(sprintf('The given UUID "%s" is invalid.', $uuid)); + } + + try + { + $stmt = Database::getConnection()->prepare('UPDATE statistics SET downloads=downloads+1 AND last_download=CURRENT_TIMESTAMP WHERE uuid=:uuid'); + $stmt->bindValue(':uuid', $uuid); + + $stmt->execute(); + } + catch (PDOException $e) + { + throw new DatabaseException('Failed to increment download count.', 0, $e); + } + } + } \ No newline at end of file diff --git a/src/FileServer/Classes/Resources/database.sqlite b/src/FileServer/Classes/Resources/database.sqlite new file mode 100644 index 0000000..6502b01 Binary files /dev/null and b/src/FileServer/Classes/Resources/database.sqlite differ diff --git a/src/FileServer/Classes/Resources/statistics.sql b/src/FileServer/Classes/Resources/statistics.sql new file mode 100644 index 0000000..abbf32c --- /dev/null +++ b/src/FileServer/Classes/Resources/statistics.sql @@ -0,0 +1,14 @@ +create table statistics +( + uuid varchar(36) not null comment 'The UUID reference of the index storage' + primary key comment 'The Unique Universal Identifier reference index', + downloads bigint default 0 not null comment 'The amount the downloads the index has had', + last_download timestamp null comment 'The Timestamp for when the file was last downloaded, null means the file has never been downloaded.', + constraint statistics_uuid_uindex + unique (uuid) comment 'The Unique Universal Identifier reference index', + constraint statistics_index_storage_uuid_fk + foreign key (uuid) references uploads (uuid) + on update cascade on delete cascade +) + comment 'Table for housing statistics'; + diff --git a/src/FileServer/Classes/Resources/uploads.sql b/src/FileServer/Classes/Resources/uploads.sql new file mode 100644 index 0000000..65aef2d --- /dev/null +++ b/src/FileServer/Classes/Resources/uploads.sql @@ -0,0 +1,25 @@ +create table uploads +( + uuid varchar(36) default uuid() not null comment 'The Primary Universal Unique Identifier for the file upload' + primary key comment 'The Primary Unique Index for the UUID column', + status enum ('UPLOADING', 'AVAILABLE', 'DELETED', 'MISSING') default 'UPLOADING' not null comment 'The status of the file + - UPLOADING: The file is currently uploading, depending on the record creation date this can be used as a way to determine if the file upload is incomplete + - AVAILABLE: The file is available for download + - DELETED: The file was deleted from the server either manually or by the automated cleanup task + - MISSING: The file once existed but can no longer be found on the server, this file was not deleted on purpose.', + name varchar(255) null comment 'The name of the file, including the extension, if null it will fallback to the uuid with no extension', + size bigint not null comment 'The size of the file in bytes', + storage_type varchar(32) not null comment 'The storage type used to store the file', + pointers blob null comment 'Pointer data for retrieving the file when requested', + created timestamp default current_timestamp() not null comment 'The Timestamp for when this record was created', + constraint uploads_uuid_uindex + unique (uuid) comment 'The Primary Unique Index for the UUID column' +) + comment 'Table for housing indexes for file uploads'; + +create index uploads_created_index + on uploads (created); + +create index uploads_status_index + on uploads (status); + diff --git a/src/FileServer/Classes/Validator.php b/src/FileServer/Classes/Validator.php new file mode 100644 index 0000000..dca737a --- /dev/null +++ b/src/FileServer/Classes/Validator.php @@ -0,0 +1,16 @@ + self::UPLOAD, + 'download' => self::DOWNLOAD, + 'list' => self::LIST, + 'delete' => self::DELETE, + default => null + }; + } + } diff --git a/src/FileServer/Enums/StorageType.php b/src/FileServer/Enums/StorageType.php new file mode 100644 index 0000000..d5fe0f5 --- /dev/null +++ b/src/FileServer/Enums/StorageType.php @@ -0,0 +1,10 @@ +getCode(), $previous); + } + } \ No newline at end of file diff --git a/src/FileServer/Exceptions/ServerException.php b/src/FileServer/Exceptions/ServerException.php new file mode 100644 index 0000000..a0a63da --- /dev/null +++ b/src/FileServer/Exceptions/ServerException.php @@ -0,0 +1,17 @@ +getMessage()); + } + catch(InvalidArgumentException $e) + { + self::basicResponse(400, 'Bad Request: ' . $e->getMessage()); + } + } + + /** + * Handles the upload request + * + * @return void + * @throws DatabaseException + */ + private static function handleUploadRequest(): void + { + if(Configuration::getServerConfiguration()->isReadOnly()) + { + self::basicResponse(403, 'Forbidden: Server is in read-only mode'); + return; + } + + if(Configuration::getServerConfiguration()->getUploadPassword() !== null) + { + if(self::getRequestPassword() === null) + { + self::basicResponse(401, 'Unauthorized: Authentication required', [ + sprintf("WWW-Authenticate: Bearer realm=\"%s\"", Configuration::getServerConfiguration()->getName()) + ]); + return; + } + + if(self::getRequestPassword() !== Configuration::getServerConfiguration()->getUploadPassword()) + { + self::basicResponse(403, 'Forbidden: Invalid upload password'); + return; + } + } + + $indexStorageUuid = IndexStorageManager::createUpload(Configuration::getServerConfiguration()->getStorageType()); + + try + { + $pointers = match (Configuration::getServerConfiguration()->getStorageType()) + { + StorageType::LOCAL => LocalHandler::handleUpload($indexStorageUuid), + StorageType::PROXY => ProxyHandler::handleUpload($indexStorageUuid), + StorageType::CUSTOM => function () use ($indexStorageUuid) + { + /** @var UploadHandlerInterface $class */ + $class = self::getCustomHandler(); + if ($class !== null) + { + yield $class::handleUpload($indexStorageUuid); + } + + yield null; + } + }; + } + catch (Exceptions\ServerException $e) + { + IndexStorageManager::deleteUpload($indexStorageUuid); + self::basicResponse(400, 'Bad Request: ' . $e->getMessage()); + return; + } + catch (Exceptions\UploadException $e) + { + IndexStorageManager::deleteUpload($indexStorageUuid); + self::basicResponse(500, 'Upload Error: ' . $e->getMessage()); + return; + } + + if(!is_array($pointers)) + { + IndexStorageManager::deleteUpload($indexStorageUuid); + self::basicResponse(500, 'Internal Server Error: Invalid upload handler response'); + return; + } + + IndexStorageManager::updateUploadStatus($indexStorageUuid, IndexStorageStatus::AVAILABLE); + IndexStorageManager::updateUploadPointers($indexStorageUuid, $pointers); + + // Generate a local URL for the uploaded file /download?uuid= + self::basicResponse(200, sprintf('%s://%s/download?uuid=%s', $_SERVER['REQUEST_SCHEME'], $_SERVER['HTTP_HOST'], $indexStorageUuid), [ + 'Content-Type: text/plain', + 'X-UUID: ' . $indexStorageUuid + ]); + } + + /** + * Handles the download request + * + * @return void + * @throws ServerException Thrown if there was a server-side exception + */ + private static function handleDownloadRequest(): void + { + if(!isset($_GET['uuid'])) + { + self::basicResponse(400, 'Bad Request: Missing parameter \'uuid\''); + return; + } + + try + { + $indexStorageRecord = IndexStorageManager::getUpload($_GET['uuid']); + if($indexStorageRecord === null) + { + self::basicResponse(404, 'File Not Found'); + return; + } + } + catch(InvalidArgumentException $e) + { + self::basicResponse(400, 'Bad Request: ' . $e->getMessage()); + return; + } + catch(Exception $e) + { + self::basicResponse(500, 'Internal Server Error: ' . get_class($e) . ' raised'); + return; + } + + switch($indexStorageRecord->getStorageType()) + { + case StorageType::LOCAL: + LocalHandler::handleDownload($indexStorageRecord); + break; + + case StorageType::PROXY: + ProxyHandler::handleDownload($indexStorageRecord); + break; + + case StorageType::CUSTOM: + /** @var UploadHandlerInterface $class */ + $class = self::getCustomHandler(); + if ($class === null) + { + break; + } + + $class::handleDownload($indexStorageRecord); + break; + } + } + + /** + * Handles a list request, password authentication will be prompted if one is set + * + * @return void + */ + private static function handleListRequest(): void + { + if(Configuration::getServerConfiguration()->getPassword() !== null) + { + if(self::getRequestPassword() === null) + { + self::basicResponse(401, 'Unauthorized: Authentication required', [ + sprintf("WWW-Authenticate: Bearer realm=\"%s\"", Configuration::getServerConfiguration()->getName()) + ]); + return; + } + + if(self::getRequestPassword() !== Configuration::getServerConfiguration()->getPassword()) + { + self::basicResponse(403, 'Forbidden: Invalid management password'); + return; + } + } + + $page = 1; + $limit = 100; + + if(isset($_GET['page'])) + { + $page = (int)$_GET['page']; + if($page < 1) + { + self::basicResponse(400, 'Bad Request: Parameter \'page\' cannot be less than 1'); + return; + } + } + + if(isset($_GET['limit'])) + { + $limit = (int)$_GET['limit']; + if($limit < 1) + { + self::basicResponse(400, 'Bad Request: Parameter \'limit\' cannot be less than 1'); + return; + } + } + + try + { + $results = IndexStorageManager::getUploads($page, $limit); + } + catch(DatabaseException) + { + self::basicResponse(500, 'Internal Server Error: Failed to list uploads'); + return; + } + catch(InvalidArgumentException $e) + { + self::basicResponse(400, 'Bad Request:' . $e->getMessage()); + return; + } + + $returnResults = []; + foreach($results as $result) + { + try + { + $returnResults[] = [ + 'uuid' => $result->getUuid(), + 'name' => $result->getName(), + 'size' => $result->getSize(), + 'created' => $result->getCreated()->getTimestamp(), + 'last_download' => IndexStorageManager::getLastDownload($result->getUuid()), + 'downloads' => IndexStorageManager::getDownloads($result->getUuid()) + ]; + } + catch (DatabaseException) + { + self::basicResponse(500, 'Internal Server Error: Failed to retrieve statistics for ' . $result->getUuid()); + return; + } + } + + self::basicResponse( + code: 200, + text: json_encode($returnResults, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_SLASHES), + contentType: 'application/json' + ); + } + + /** + * Handles a delete resource request, a password will be prompted if one is set + * + * @return void + * @throws ServerException Thrown if there was a server-side exception + */ + private static function handleDeleteRequest(): void + { + if(Configuration::getServerConfiguration()->getPassword() !== null) + { + if(self::getRequestPassword() === null) + { + self::basicResponse(401, 'Unauthorized: Authentication required', [ + sprintf("WWW-Authenticate: Bearer realm=\"%s\"", Configuration::getServerConfiguration()->getName()) + ]); + return; + } + + if(self::getRequestPassword() !== Configuration::getServerConfiguration()->getPassword()) + { + self::basicResponse(403, 'Forbidden: Invalid upload password'); + return; + } + } + + if(!isset($_GET['uuid'])) + { + self::basicResponse(400, 'Bad Request: Missing parameter \'uuid\''); + return; + } + + try + { + $indexStorageRecord = IndexStorageManager::getUpload($_GET['uuid']); + if($indexStorageRecord === null) + { + self::basicResponse(404, 'File Not Found'); + return; + } + } + catch(InvalidArgumentException $e) + { + self::basicResponse(400, 'Bad Request: ' . $e->getMessage()); + return; + } + catch(Exception $e) + { + self::basicResponse(500, 'Internal Server Error: ' . get_class($e) . ' raised'); + return; + } + + switch($indexStorageRecord->getStorageType()) + { + case StorageType::LOCAL: + LocalHandler::handleDelete($indexStorageRecord); + break; + + case StorageType::PROXY: + ProxyHandler::handleDelete($indexStorageRecord); + break; + + case StorageType::CUSTOM: + /** @var UploadHandlerInterface $class */ + $class = self::getCustomHandler(); + if ($class === null) + { + break; + } + + $class::handleDelete($indexStorageRecord); + break; + } + } + + /** + * Returns the custom storage handler class name + * + * @return string|null The class name or null if not set + */ + private static function getCustomHandler(): ?string + { + if(Configuration::getCustomStorageConfiguration()->getPackage() !== null) + { + // Check if the package is set and exists + if(!Runtime::isImported(Configuration::getCustomStorageConfiguration()->getPackage())) + { + try + { + Runtime::import(Configuration::getCustomStorageConfiguration()->getPackage()); + } + catch(ImportException $e) + { + self::basicResponse(500, 'Internal Server Error: Custom storage package not found, ' . $e->getMessage()); + return null; + } + } + } + + $class = Configuration::getCustomStorageConfiguration()->getClass(); + + // Check if the class is set and exists + if($class === null) + { + self::basicResponse(500, 'Internal Server Error: Custom storage class not set'); + return null; + } + + if(!class_exists($class)) + { + self::basicResponse(500, 'Internal Server Error: Custom storage class not found'); + return null; + } + + // Check if the class implements UploadHandlerInterface + if(!is_subclass_of($class, UploadHandlerInterface::class)) + { + self::basicResponse(500, 'Internal Server Error: Custom storage class does not implement UploadHandlerInterface'); + return null; + } + + return $class; + } + + /** + * Returns the request password from the POST/GET parameters or from the HTTP headers + * + * @return string|null + */ + private static function getRequestPassword(): ?string + { + // Check for password in GET or POST parameters + if(isset($_GET['password'])) + { + return $_GET['password']; + } + + elseif(isset($_POST['password'])) + { + return $_POST['password']; + } + + // Check for password in HTTP headers + if(isset($_SERVER['HTTP_AUTHORIZATION'])) + { + return str_replace('Bearer ', '', $_SERVER['HTTP_AUTHORIZATION']); + } + + return null; + } + + /** + * Returns the request action of the client + * + * @return RequestAction|null The request action that was detected null if the action couldn't be determined + */ + private static function getRequestAction(): ?RequestAction + { + return match (strtoupper($_SERVER['REQUEST_METHOD'])) + { + 'GET' => self::getUriAction($_GET) ?? RequestAction::DOWNLOAD, + 'POST' => self::getUriAction($_POST) ?? RequestAction::UPLOAD, + 'PUT' => RequestAction::UPLOAD, + 'DELETE' => RequestAction::DELETE, + default => null, + }; + + } + + /** + * Extract action from request parameters or URL path + * + * @param array $params Request parameters ($_GET or $_POST) + * @return RequestAction|null The extracted action or null if not found + */ + private static function getUriAction(array $params): ?RequestAction + { + // Check for parameters + if(isset($params['action']) || isset($params['a'])) + { + return RequestAction::fromString($params['action'] ?? $params['a']); + } + + // Check for a trailing path in URL + $path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/'); + if(!empty($path)) + { + $pathParts = explode('/', $path); + $lastPart = end($pathParts); + if(!empty($lastPart)) + { + return RequestAction::fromString($lastPart); + } + } + + return null; + } + + /** + * Produces a basic HTTP response with modifiable headers + * + * @param int $code The HTTP response code to return + * @param string $text The HTTP body response to return + * @param array|null $headers Optional. The headers to provide + * @param string $contentType Optional. The content type, by default: text/plain + * @return void + */ + public static function basicResponse(int $code, string $text, ?array $headers=null, string $contentType='text/plain'): void + { + http_response_code($code); + if($headers !== null) + { + foreach($headers as $header) + { + header($header); + } + } + + header('Content-Type: ' . $contentType); + header('X-ServerName: ' . Configuration::getServerConfiguration()->getName()); + print($text); + } } \ No newline at end of file diff --git a/src/FileServer/Interfaces/SerializableInterface.php b/src/FileServer/Interfaces/SerializableInterface.php new file mode 100644 index 0000000..d28b9fc --- /dev/null +++ b/src/FileServer/Interfaces/SerializableInterface.php @@ -0,0 +1,21 @@ +uuid = $data['uuid']; + $this->status = IndexStorageStatus::from(mb_strtoupper($data['status'])); + $this->name = $data['name']; + $this->size = $data['size']; + $this->storageType = StorageType::from(mb_strtoupper($data['storage_type'])); + $this->pointers = $data['pointers']; + + try + { + $this->created = new DateTime($data['created']); + } + catch (DateMalformedStringException $e) + { + throw new InvalidArgumentException('The given created field is not a valid date.', $e->getCode(), $e); + } + } + + /** + * Returns the UUID of the index storage record. + * + * @return string The UUID of the index storage record. + */ + public function getUuid(): string + { + return $this->uuid; + } + + /** + * Sets the UUID of the index storage record. + * + * @param string $uuid The UUID of the index storage record. + */ + public function setUuid(string $uuid): void + { + $this->uuid = $uuid; + } + + /** + * Returns the status of the index storage record. + * + * @return IndexStorageStatus The status of the index storage record. + */ + public function getStatus(): IndexStorageStatus + { + return $this->status; + } + + public function setStatus(IndexStorageStatus $status): void + { + $this->status = $status; + } + + /** + * Returns the name of the index storage record. + * + * @return string The name of the index storage record. + */ + public function getName(): string + { + if($this->name === null) + { + return $this->uuid; + } + + return $this->name; + } + + /** + * Sets the name of the index storage record. + * + * @param string|null $name The name of the index storage record. + */ + public function setName(?string $name): void + { + $this->name = $name; + } + + /** + * Returns the size of the index storage record. + * + * @return int The size of the index storage record. + */ + public function getSize(): int + { + return $this->size; + } + + /** + * Sets the size of the index storage record. + * + * @param int $size The size of the index storage record. + */ + public function setSize(int $size): void + { + $this->size = $size; + } + + /** + * Returns the storage type of the index storage record. + * + * @return StorageType The storage type of the index storage record. + */ + public function getStorageType(): StorageType + { + return $this->storageType; + } + + /** + * Sets the storage type of the index storage record. + * + * @param StorageType $storageType The storage type of the index storage record. + */ + public function setStorageType(StorageType $storageType): void + { + $this->storageType = $storageType; + } + + /** + * Returns the pointers of the index storage record. + * + * @return array|null The pointers of the index storage record. + */ + public function getPointers(): ?array + { + return $this->pointers; + } + + /** + * Sets the pointers of the index storage record. + * + * @param array|null $pointers The pointers of the index storage record. + */ + public function setPointers(?array $pointers): void + { + $this->pointers = $pointers; + } + + /** + * Returns the created date of the index storage record. + * + * @return DateTime The created date of the index storage record. + */ + public function getCreated(): DateTime + { + return $this->created; + } + + /** + * Sets the created date of the index storage record. + * + * @param DateTime $created The created date of the index storage record. + */ + public function setCreated(DateTime $created): void + { + $this->created = $created; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'status' => $this->status->value, + 'name' => $this->name, + 'size' => $this->size, + 'storage_type' => $this->storageType->value, + 'pointers' => $this->pointers, + 'created' => $this->created->format('Y-m-d H:i:s') + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $data): IndexStorageRecord + { + return new self($data); + } + } \ No newline at end of file diff --git a/src/FileServer/Objects/UploadResult.php b/src/FileServer/Objects/UploadResult.php new file mode 100644 index 0000000..67ffcfb --- /dev/null +++ b/src/FileServer/Objects/UploadResult.php @@ -0,0 +1,96 @@ +fileName = $fileName; + $this->fileSize = $fileSize; + $this->storageType = $storageType; + $this->pointers = $pointers; + } + + /** + * Get the name of the uploaded file. + * + * @return string The name of the uploaded file. + */ + public function getFileName(): string + { + return $this->fileName; + } + + /** + * Get the size of the uploaded file. + * + * @return int The size of the uploaded file in bytes. + */ + public function getFileSize(): int + { + return $this->fileSize; + } + + /** + * Get the storage type of the uploaded file. + * + * @return StorageType The storage type of the uploaded file. + */ + public function getStorageType(): StorageType + { + return $this->storageType; + } + + /** + * Get the pointers to the uploaded file. + * + * @return array The pointers to the uploaded file. + */ + public function getPointers(): array + { + return $this->pointers; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'file_name' => $this->fileName, + 'file_size' => $this->fileSize, + 'storage_type' => $this->storageType->value, + 'pointers' => $this->pointers + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $data): SerializableInterface + { + return new self( + $data['file_name'], + $data['file_size'], + StorageType::from(mb_strtoupper($data['storage_type'])), + $data['pointers'] + ); + } + } \ No newline at end of file diff --git a/src/FileServer/Program.php b/src/FileServer/Program.php index cb783dd..7ef8fa2 100644 --- a/src/FileServer/Program.php +++ b/src/FileServer/Program.php @@ -2,6 +2,8 @@ namespace FileServer; + use FileServer\Classes\Configuration; + class Program { /** @@ -12,7 +14,7 @@ */ public static function main(array $args): int { - print("Hello World from net.nosial.file_server!" . PHP_EOL); + Configuration::getConfigurationLib(); return 0; } } \ No newline at end of file diff --git a/www/index.php b/www/index.php new file mode 100644 index 0000000..a82278a --- /dev/null +++ b/www/index.php @@ -0,0 +1,20 @@ +getMessage())); + } \ No newline at end of file