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