Add configuration classes, enums, and database handling for FileServer
Some checks failed
Docker CI/CD / build-and-save (push) Has been cancelled
Docker CI/CD / build-and-push (push) Has been cancelled
CI / release (push) Has been cancelled
CI / debug (push) Has been cancelled
CI / release_executable (push) Has been cancelled
CI / debug_executable (push) Has been cancelled
CI / check-phpunit (push) Has been cancelled
CI / check-phpdoc (push) Has been cancelled
CI / generate-phpdoc (push) Has been cancelled
CI / test (push) Has been cancelled
CI / release-documentation (push) Has been cancelled
CI / release-artifacts (push) Has been cancelled

This commit is contained in:
netkas 2025-05-15 21:05:40 -04:00
parent 8db44e90d5
commit 72da412737
Signed by: netkas
GPG key ID: 4D8629441B76E4CC
45 changed files with 3837 additions and 10 deletions

76
.github/workflows/docker.yml vendored Normal file
View file

@ -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

View file

@ -57,7 +57,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release name: release
path: build/release/net.nosial.file_server.ncc path: build/release/net.nosial.fileserver.ncc
debug: debug:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@ -106,7 +106,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: debug name: debug
path: build/debug/net.nosial.file_server.ncc path: build/debug/net.nosial.fileserver.ncc
release_executable: release_executable:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@ -327,7 +327,7 @@ jobs:
- name: Install NCC packages - name: Install NCC packages
run: | 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 - name: Run PHPUnit tests
run: | run: |

22
.idea/dataSources.xml generated Normal file
View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="fileserver@127.0.0.1" uuid="4986c4c2-f51c-4b89-b812-58ec37d71c36">
<driver-ref>mariadb</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mariadb://127.0.0.1:3306/fileserver</jdbc-url>
<jdbc-additional-properties>
<property name="database.introspection.mysql.dbe5060" value="true" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="Sqlite Database" uuid="4bb9a5ec-5d42-4f5d-bb52-f1bd0268fe17">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/src/FileServer/Classes/Resources/database.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

1
.idea/php.xml generated
View file

@ -12,6 +12,7 @@
</component> </component>
<component name="PhpIncludePathManager"> <component name="PhpIncludePathManager">
<include_path> <include_path>
<path value="/usr/share/ncc" />
<path value="/var/ncc/packages/com.symfony.uid=v7.2.0" /> <path value="/var/ncc/packages/com.symfony.uid=v7.2.0" />
<path value="/var/ncc/packages/net.nosial.configlib=1.1.8" /> <path value="/var/ncc/packages/net.nosial.configlib=1.1.8" />
<path value="/var/ncc/packages/net.nosial.loglib2=1.0.3" /> <path value="/var/ncc/packages/net.nosial.loglib2=1.0.3" />

9
.idea/sqldialects.xml generated Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/src/FileServer/Classes/Database.php" dialect="MySQL" />
<file url="file://$PROJECT_DIR$/src/FileServer/Classes/IndexStorageManager.php" dialect="MariaDB" />
<file url="file://$PROJECT_DIR$/src/FileServer/Classes/Resources/statistics.sql" dialect="MariaDB" />
<file url="file://$PROJECT_DIR$/src/FileServer/Classes/Resources/uploads.sql" dialect="MariaDB" />
</component>
</project>

14
.idea/webResources.xml generated Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebResourcesPaths">
<contentEntries>
<entry url="file://$PROJECT_DIR$">
<entryData>
<resourceRoots>
<path value="file://$PROJECT_DIR$/src/FileServer/Classes/Resources" />
</resourceRoots>
</entryData>
</entry>
</contentEntries>
</component>
</project>

107
Dockerfile Normal file
View file

@ -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 <netkas@n64.cc>" \
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"]

View file

@ -17,7 +17,7 @@ debug_executable:
install: release 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 test: release
[ -f phpunit.xml ] || { echo "phpunit.xml not found"; exit 1; } [ -f phpunit.xml ] || { echo "phpunit.xml not found"; exit 1; }

View file

@ -1,3 +1,3 @@
<?php <?php
require 'ncc'; require 'ncc';
import('net.nosial.file_server'); import('net.nosial.fileserver');

57
docker-compose.yml Normal file
View file

@ -0,0 +1,57 @@
services:
fileserver:
container_name: fileserver
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080" # Unique port for File Server instance
depends_on:
mariadb:
condition: service_healthy
networks:
shared_network:
aliases:
- fileserver
restart: unless-stopped
volumes:
- ./fileserver/config:/etc/config:z
- ./fileserver/uploads:/etc/fileserver:z
- ./fileserver/logs:/var/log:z
environment:
# No need to change these values
LOG_LEVEL: ${LOG_LEVEL:-debug}
CONFIGLIB_PATH: /etc/config
mariadb:
container_name: fileserver_mariadb
image: mariadb:10.5
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-fileserver_root}
MYSQL_DATABASE: ${MYSQL_DATABASE:-fileserver}
MYSQL_USER: ${MYSQL_USER:-fileserver}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-fileserver}
volumes:
- mariadb_data:/var/lib/mysql
networks:
- shared_network
ports:
- "3308:3306"
expose:
- "3306"
healthcheck:
test: [ "CMD", "mysqladmin", "ping", "-h", "fileserver_mariadb", "-u", "${MYSQL_USER:-fileserver}", "-p${MYSQL_PASSWORD:-fileserver}" ]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
volumes:
mariadb_data:
driver: local
networks:
shared_network:
name: fileserver_network
driver: bridge

346
docker/logger.py Normal file
View file

@ -0,0 +1,346 @@
import argparse
import json
import logging
import os
import socket
import threading
from datetime import datetime
from queue import Queue, Empty
from enum import Enum
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
import colorama
from colorama import Fore, Back, Style
# Initialize colorama for cross-platform color support
colorama.init()
class LogLevel(str, Enum):
DEBUG = "DBG"
VERBOSE = "VRB"
INFO = "INFO"
WARNING = "WRN"
ERROR = "ERR"
CRITICAL = "CRT"
@classmethod
def to_python_level(cls, level: str) -> 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()

37
docker/nginx.conf Normal file
View file

@ -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;
}

61
docker/supervisord.conf Normal file
View file

@ -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

6
main
View file

@ -2,7 +2,7 @@
if (PHP_SAPI !== 'cli') 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); exit(1);
} }
@ -14,11 +14,11 @@
} }
else 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); exit(1);
} }
} }
require('ncc'); require('ncc');
\ncc\Classes\Runtime::import('net.nosial.file_server', 'latest'); \ncc\Classes\Runtime::import('net.nosial.fileserver', 'latest');
exit(\FileServer\Program::main($argv)); exit(\FileServer\Program::main($argv));

View file

@ -11,7 +11,7 @@
}, },
"assembly": { "assembly": {
"name": "FileServer", "name": "FileServer",
"package": "net.nosial.file_server", "package": "net.nosial.fileserver",
"description": "A simple standard HTTP file upload server", "description": "A simple standard HTTP file upload server",
"version": "1.0.0", "version": "1.0.0",
"uuid": "8ee504ac-b27f-4f67-9730-ab3904be916c" "uuid": "8ee504ac-b27f-4f67-9730-ab3904be916c"

View file

@ -0,0 +1,168 @@
<?php
namespace FileServer\Classes;
use FileServer\Classes\Configuration\CustomStorageConfiguration;
use FileServer\Classes\Configuration\DatabaseConfiguration;
use FileServer\Classes\Configuration\LocalStorageConfiguration;
use FileServer\Classes\Configuration\ProxyStorageConfiguration;
use FileServer\Classes\Configuration\ServerConfiguration;
class Configuration
{
private static ?\ConfigLib\Configuration $configuration = null;
private static ?ServerConfiguration $serverConfiguration = null;
private static ?LocalStorageConfiguration $localStorageConfiguration = null;
private static ?ProxyStorageConfiguration $proxyStorageConfiguration = null;
private static ?CustomStorageConfiguration $customStorageConfiguration = null;
private static ?DatabaseConfiguration $databaseConfiguration = null;
/**
* Initializes the default settings for the configuration and constructs the private properties
* for the configuration class
*
* @return void
*/
private static function initializeConfiguration(): void
{
self::$configuration = new \ConfigLib\Configuration('fileserver');
self::$configuration->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;
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace FileServer\Classes\Configuration;
class CustomStorageConfiguration
{
private ?string $package;
private ?string $class;
private ?array $config;
/**
* CustomStorageConfiguration constructor.
*
* @param array $data The configuration data
*/
public function __construct(array $data)
{
$this->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;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace FileServer\Classes\Configuration;
use FileServer\Enums\DatabaseDriverType;
class DatabaseConfiguration
{
private DatabaseDriverType $driver;
private SqlDatabaseConfiguration $sqlDatabaseConfiguration;
private SqliteDatabaseConfiguration $sqliteDatabaseConfiguration;
/**
* DatabaseConfiguration Public Constructor
*
* @param array $data The array data to construct with
*/
public function __construct(array $data)
{
$this->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;
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace FileServer\Classes\Configuration;
class LocalStorageConfiguration
{
private string $storageDirectory;
private int $maxFileSize;
private int $maxStorageSize;
private bool $returnContentSize;
/**
* Constructor for LocalStorageConfiguration.
*
* @param array $data The configuration data containing 'path' and 'max_size'.
*/
public function __construct(array $data)
{
$this->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;
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace FileServer\Classes\Configuration;
class ProxyStorageConfiguration
{
private string $endpoint;
private ?string $uploadPassword;
private ?string $adminPassword;
private int $maxFileSize;
private int $curlTimeout;
/**
* Constructor to initialize the ProxyConfiguration object with data from the configuration array.
*
* @param array $data The configuration data array containing keys 'endpoint', 'password', 'stream_chunk_size', and 'curl_timeout'.
*/
public function __construct(array $data)
{
$this->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;
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace FileServer\Classes\Configuration;
use FileServer\Enums\StorageType;
class ServerConfiguration
{
private string $name;
private StorageType $storageType;
private ?string $uploadPassword;
private ?string $password;
private bool $readOnly;
private bool $filterExtensions;
private array $allowedExtensions;
private int $maxExecutionTime;
private int $maxFileSize;
private ?string $workingPath;
/**
* ServerConfiguration constructor.
*
* @param array $data The configuration data
*/
public function __construct(array $data)
{
$this->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;
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace FileServer\Classes\Configuration;
class SqlDatabaseConfiguration
{
private string $host;
private int $port;
private string $username;
private string $password;
private string $database;
/**
* Constructs the SqlDatabaseConfiguration object
*
* @param array $data
*/
public function __construct(array $data)
{
$this->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);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace FileServer\Classes\Configuration;
class SqliteDatabaseConfiguration
{
private string $path;
/**
* Constructs the SqliteDatabaseConfiguration object
*
* @param array $data
*/
public function __construct(array $data)
{
$this->path = $data['path'];
}
/**
* Returns the path of the SQLite database
*
* @return string
*/
public function getPath(): string
{
return $this->path;
}
}

View file

@ -0,0 +1,167 @@
<?php
namespace FileServer\Classes;
use FileServer\Enums\DatabaseDriverType;
use FileServer\Exceptions\DatabaseException;
use PDO;
use PDOException;
use RuntimeException;
class Database
{
private static ?PDO $connection;
private static bool $initializationChecked = false;
/**
* Returns the PDO connection to the database.
*
* @return PDO The PDO connection object.
* @throws DatabaseException If an error occurs while creating the connection.
*/
public static function getConnection(): PDO
{
if(self::$connection === null)
{
self::createConnection();
}
return self::$connection;
}
/**
* @return void
* @throws DatabaseException
*/
private static function createConnection(): void
{
if(Configuration::getDatabaseConfiguration()->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());
}
}
}

View file

@ -0,0 +1,345 @@
<?php
namespace FileServer\Classes\Handlers;
use Exception;
use FileServer\Classes\Configuration;
use FileServer\Classes\IndexStorageManager;
use FileServer\Exceptions\ServerException;
use FileServer\Exceptions\UploadException;
use FileServer\FileServer;
use FileServer\Interfaces\UploadHandlerInterface;
use FileServer\Objects\IndexStorageRecord;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class LocalHandler implements UploadHandlerInterface
{
// Configurable constants
private const int MAX_FILENAME_LENGTH = 255;
private const string FILENAME_PATTERN = '/^[a-zA-Z0-9_\-. ]+$/u'; // Safe characters only
private const int BUFFER_SIZE = 8192; // 8 KB buffer size
/**
* @inheritDoc
*/
public static function handleUpload(string $uuid): array
{
// Ensure the request is POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST' && $_SERVER['REQUEST_METHOD'] !== 'PUT')
{
FileServer::basicResponse(405, 'Method Not Allowed');
exit;
}
$isMultipart = str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'multipart/form-data');
$tmpName = null;
$fileSize = 0;
if ($isMultipart)
{
// Check for multiple files
if (count($_FILES) > 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);
}
}
}

View file

@ -0,0 +1,422 @@
<?php
namespace FileServer\Classes\Handlers;
use Exception;
use FileServer\Classes\Configuration;
use FileServer\Classes\IndexStorageManager;
use FileServer\Exceptions\ServerException;
use FileServer\Exceptions\UploadException;
use FileServer\FileServer;
use FileServer\Interfaces\UploadHandlerInterface;
use FileServer\Objects\IndexStorageRecord;
use RuntimeException;
class ProxyHandler implements UploadHandlerInterface
{
public const int MAX_FILENAME_LENGTH = 255;
public const string FILENAME_PATTERN = '/^[a-zA-Z0-9_\-.]+$/';
public const string UUID_PATTERN = '/^[a-f0-9]{8}-[a-f0-9]{4}-[4][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/';
/**
* @inheritDoc
*/
public static function handleUpload(string $uuid): array
{
// Validate UUID format
if (!preg_match(self::UUID_PATTERN, $uuid))
{
FileServer::basicResponse(400, 'Invalid UUID format');
exit;
}
// Validate request method
if ($_SERVER['REQUEST_METHOD'] !== 'POST' && $_SERVER['REQUEST_METHOD'] !== 'PUT')
{
throw new UploadException('Method not allowed', 400);
}
// Detect upload type
$isMultipart = str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'multipart/form-data');
$tmpName = null;
// Handle multipart uploads
if ($isMultipart)
{
if (count($_FILES) > 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'));
}
}
}

View file

@ -0,0 +1,431 @@
<?php
namespace FileServer\Classes;
use DateMalformedStringException;
use DateTime;
use FileServer\Enums\IndexStorageStatus;
use FileServer\Enums\StorageType;
use FileServer\Exceptions\DatabaseException;
use FileServer\Objects\IndexStorageRecord;
use InvalidArgumentException;
use PDO;
use PDOException;
use Symfony\Component\Uid\Uuid;
class IndexStorageManager
{
/**
* Creates a new upload entry in the database.
*
* @param StorageType $type The storage type of the upload.
* @param string|null $fileName Optional. The name of the file to be uploaded. If not provided, the UUID will be used as the filename.
* @param int $size Optional. The size of the file to be uploaded in bytes. Default is 0.
* @return string The UUID of the created upload entry.
* @throws DatabaseException If the database operation fails.
* @throws InvalidArgumentException If the provided filename is invalid or the size is negative.
*/
public static function createUpload(StorageType $type, ?string $fileName=null, int $size=0): string
{
if($fileName !== null)
{
$fileName = Validator::sanitizeFilename($fileName);
if(!Validator::validFilename($fileName))
{
throw new InvalidArgumentException(sprintf('The given filename "%s" is invalid.', $fileName));
}
}
if($size !== null && $size < 0)
{
throw new InvalidArgumentException(sprintf('The given filename "%s" is too small.', $fileName));
}
try
{
$uploadStmt = Database::getConnection()->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);
}
}
}

Binary file not shown.

View file

@ -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';

View file

@ -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);

View file

@ -0,0 +1,16 @@
<?php
namespace FileServer\Classes;
class Validator
{
public static function sanitizeFilename(string $filename): string
{
// TODO: Implement this in the most secured and compatible way possible to prevent everything but the filename, excluding paths, just the filename itself.
}
public static function validFilename(string $filename): bool
{
// TODO: Implement this to check if the given filename is a valid filename and does not include anything like a sub-path or escape strings or invalid/illegal characters
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace FileServer\Enums;
enum DatabaseDriverType : string
{
case SQL = 'SQL';
case SQLITE = 'SQLITE';
}

View file

@ -0,0 +1,11 @@
<?php
namespace FileServer\Enums;
enum IndexStorageStatus : string
{
case UPLOADING = 'UPLOADING';
case AVAILABLE = 'AVAILABLE';
case DELETED = 'DELETED';
case MISSING = 'MISSING';
}

View file

@ -0,0 +1,29 @@
<?php
namespace FileServer\Enums;
enum RequestAction
{
case UPLOAD;
case DOWNLOAD;
case LIST;
case DELETE;
/**
* Attempts to parse the input from a string
*
* @param string $input The string input
* @return RequestAction|null The result, null if no matches were found
*/
public static function fromString(string $input): ?RequestAction
{
return match(strtolower($input))
{
'upload' => self::UPLOAD,
'download' => self::DOWNLOAD,
'list' => self::LIST,
'delete' => self::DELETE,
default => null
};
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace FileServer\Enums;
enum StorageType : string
{
case LOCAL = 'LOCAL';
case PROXY = 'PROXY';
case CUSTOM = 'CUSTOM';
}

View file

@ -0,0 +1,17 @@
<?php
namespace FileServer\Exceptions;
use Exception;
use Throwable;
class DatabaseException extends Exception
{
/**
* @inheritDoc
*/
public function __construct(string $message = "", ?Throwable $previous = null)
{
parent::__construct($message, $previous->getCode(), $previous);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace FileServer\Exceptions;
use Exception;
use Throwable;
class ServerException extends Exception
{
/**
* @inheritDoc
*/
public function __construct(string $message="", int $code=0, ?Throwable $previous=null)
{
parent::__construct($message, $code, $previous);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace FileServer\Exceptions;
use Exception;
use Throwable;
/**
* Class UploadException
*
* This exception is thrown when there is an internal error during the file upload process.
*
* @package FileServer\Exceptions
*/
class UploadException extends Exception
{
/**
* UploadException constructor.
*
* @param string $message The error message.
* @param int $code The error code.
* @param Throwable|null $previous The previous throwable used for exception chaining.
*/
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View file

@ -2,7 +2,513 @@
namespace FileServer; namespace FileServer;
use Exception;
use FileServer\Classes\Configuration;
use FileServer\Classes\Handlers\LocalHandler;
use FileServer\Classes\Handlers\ProxyHandler;
use FileServer\Classes\IndexStorageManager;
use FileServer\Enums\IndexStorageStatus;
use FileServer\Enums\RequestAction;
use FileServer\Enums\StorageType;
use FileServer\Exceptions\DatabaseException;
use FileServer\Exceptions\ServerException;
use FileServer\Interfaces\UploadHandlerInterface;
use InvalidArgumentException;
use ncc\Classes\Runtime;
use ncc\Exceptions\ImportException;
class FileServer class FileServer
{ {
/**
* Handles the HTTP request for the FileServer
*
* @return void
*/
public static function handleRequest(): void
{
try
{
switch(self::getRequestAction())
{
case RequestAction::UPLOAD:
self::handleUploadRequest();
break;
case RequestAction::DOWNLOAD:
self::handleDownloadRequest();
break;
case RequestAction::DELETE:
self::handleDeleteRequest();
break;
case RequestAction::LIST:
self::handleListRequest();
break;
default:
self::basicResponse(400, 'Bad Request: Unknown request action');
break;
}
}
catch(ServerException|DatabaseException $e)
{
self::basicResponse(500, 'Internal Server Error: ' . $e->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 <base>/download?uuid=<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);
}
} }

View file

@ -0,0 +1,21 @@
<?php
namespace FileServer\Interfaces;
interface SerializableInterface
{
/**
* Converts the object to an array representation.
*
* @return array The array representation of the object.
*/
public function toArray(): array;
/**
* Creates an object from an array representation.
*
* @param array $data The array representation of the object.
* @return static The created object.
*/
public static function fromArray(array $data): self;
}

View file

@ -0,0 +1,38 @@
<?php
namespace FileServer\Interfaces;
use FileServer\Exceptions\ServerException;
use FileServer\Exceptions\UploadException;
use FileServer\Objects\IndexStorageRecord;
interface UploadHandlerInterface
{
/**
* Handles the upload process.
*
* @param string $uuid The Unique Universal Identifier of the upload
* @throws UploadException Thrown if there was a client error during the upload
* @throws ServerException Thrown if there was a server error during the upload
* @return array Pointers for the uploaded file, used for retrieving the file later
*/
public static function handleUpload(string $uuid): array;
/**
* Handles the download process by transmitting the file over HTTP
*
* @param IndexStorageRecord $record The index storage record to download
* @throws ServerException Thrown if there was a server error during the download
* @return void
*/
public static function handleDownload(IndexStorageRecord $record): void;
/**
* Handles the process of deleting the uploaded files from the server
*
* @param IndexStorageRecord $record The index storage record to delete
* @throws ServerException Thrown if there was a server error during the delete operation
* @return void
*/
public static function handleDelete(IndexStorageRecord $record): void;
}

View file

@ -0,0 +1,210 @@
<?php
namespace FileServer\Objects;
use DateMalformedStringException;
use DateTime;
use FileServer\Enums\IndexStorageStatus;
use FileServer\Enums\StorageType;
use FileServer\Interfaces\SerializableInterface;
use InvalidArgumentException;
class IndexStorageRecord implements SerializableInterface
{
private string $uuid;
private IndexStorageStatus $status;
private ?string $name;
private int $size;
private StorageType $storageType;
private ?array $pointers;
private DateTime $created;
/**
* IndexStorageRecord constructor.
*
* @param array $data The data to initialize the object with
* @throws InvalidArgumentException
*/
public function __construct(array $data)
{
$this->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);
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace FileServer\Objects;
use FileServer\Enums\StorageType;
use FileServer\Interfaces\SerializableInterface;
class UploadResult implements SerializableInterface
{
private string $fileName;
private int $fileSize;
private StorageType $storageType;
private array $pointers;
/**
* Constructor for the UploadResult class.
*
* @param string $fileName The name of the uploaded file.
* @param int $fileSize The size of the uploaded file in bytes.
* @param StorageType $storageType The storage type of the uploaded file.
* @param array $pointers The pointers to the uploaded file.
*/
public function __construct(string $fileName, int $fileSize, StorageType $storageType, array $pointers)
{
$this->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']
);
}
}

View file

@ -2,6 +2,8 @@
namespace FileServer; namespace FileServer;
use FileServer\Classes\Configuration;
class Program class Program
{ {
/** /**
@ -12,7 +14,7 @@
*/ */
public static function main(array $args): int public static function main(array $args): int
{ {
print("Hello World from net.nosial.file_server!" . PHP_EOL); Configuration::getConfigurationLib();
return 0; return 0;
} }
} }

20
www/index.php Normal file
View file

@ -0,0 +1,20 @@
<?php
# This is a simple single index file for loading in FileServer and passing on the request to be handled by the
# server, modify this to your adjustments but this file in its current form will work
# Load ncc & import FileServer
require 'ncc';
import('net.nosial.fileserver');
try
{
# Pass on the request handler
\FileServer\FileServer::handleRequest();
}
catch(Exception $e)
{
# Handle the exception
http_response_code(500);
print(sprintf("Internal Server Error: %s", $e->getMessage()));
}