├── .htaccess ├── LICENSE ├── README-en.md ├── README.md └── index.php /.htaccess: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | 4 | # 如果请求的不是真实存在的文件或目录 5 | RewriteCond %{REQUEST_FILENAME} !-f 6 | RewriteCond %{REQUEST_FILENAME} !-d 7 | 8 | # 将所有请求重定向到 index.php 9 | RewriteRule ^(.*)$ index.php [L,QSA] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 imhcg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-en.md: -------------------------------------------------------------------------------- 1 | # PHP S3 Server 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 4 | 5 | A lightweight S3-compatible object storage server implemented in PHP, using local filesystem as storage backend. 6 | 7 | ## Key Features 8 | 9 | - ✅ S3 OBJECT API compatibility (PUT/GET/DELETE/POST) 10 | - ✅ Multipart upload support 11 | - ✅ No database required - pure filesystem storage 12 | - ✅ Simple AWS V4 signature authentication 13 | - ✅ Lightweight single-file deployment 14 | 15 | 16 | ## TLDR 17 | 18 | Simply create a new website on your virtual host, place the `index.php` file from the GitHub repository into the website's root directory, modify the password configuration at the beginning of `index.php`, then config the rewite rule set all route to index.php, and you're ready to use it. 19 | 20 | - **Endpoint**: Your website domain 21 | - **Access Key**: The password you configured 22 | - **Secret Key**: Can be any value (not used in this project) 23 | - **Region**: Can be any value (not used in this project) 24 | 25 | For example, if an object has: 26 | - `bucket="music"` 27 | - `key="hello.mp3"` 28 | 29 | It will be stored at: `./data/music/hello.mp3` 30 | 31 | You can also combine this with Cloudflare's CDN for faster and more stable performance. 32 | 33 | 34 | 35 | ## Quick Start 36 | 37 | ### Requirements 38 | 39 | - PHP 8.0+ 40 | - Apache/Nginx (with mod_rewrite enabled) 41 | 42 | ### Installation 43 | 44 | 1. Set up a website 45 | 46 | 2. Download `index.php` to your website root directory 47 | 48 | 3. Create data directory 49 | Create a `data` folder in your website root directory 50 | 51 | 4. Configure URL rewriting (DirectAdmin example): 52 | Create `.htaccess` in root directory with: 53 | ```apache 54 | 55 | RewriteEngine On 56 | # If request is not for existing file/directory 57 | RewriteCond %{REQUEST_FILENAME} !-f 58 | RewriteCond %{REQUEST_FILENAME} !-d 59 | # Redirect all requests to index.php 60 | RewriteRule ^(.*)$ index.php [L,QSA] 61 | 62 | ``` 63 | > For other web servers, consult documentation on how to configure rewrite rules to redirect all requests to index.php 64 | 65 | ### Configuration 66 | 67 | Edit the top section of `index.php`: 68 | ```php 69 | define('ALLOWED_ACCESS_KEYS', ['your-access-key-here']); 70 | // Allowed access keys - when using third-party OSS tools, only the access-key is required. 71 | // Other fields like region and secret-key can be arbitrary values. 72 | ``` 73 | 74 | ### Start Using It! 75 | 76 | #### Demo: Using in Minio 77 | 78 | ```python 79 | oss_client = Minio("your-domain.com", access_key="your-access-key-here", secret_key="*", secure=True) 80 | 81 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP S3 Server 2 | 3 | 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 5 | 6 | [ENGLISH](./README-en.md) 7 | 8 | 一个轻量级的 PHP 实现的 S3 兼容对象存储服务器,使用本地文件系统作为存储后端。 9 | 10 | ## 功能特性 11 | 12 | - ✅ S3 对象处理相关 API 兼容(PUT/GET/DELETE/POST) 13 | - ✅ 支持分片上传(Multipart Upload) 14 | - ✅ 无需数据库,纯文件系统存储 15 | - ✅ 简单的 AWS V4 兼容签名认证 16 | - ✅ 轻量级,单文件部署 17 | 18 | 19 | 20 | ## 太长不看版说明 21 | 22 | 只需在虚拟主机上新建一个网站,要把Github仓库内 index.php 放入这个网站根目录,修改 index.php 开头的密码配置,配置好路由重写,即可开始使用。 23 | endpoint 就是你的网站域名 24 | access_key 就是你配置的密码 25 | secret_key 随便填,对本项目无意义 26 | region 随便填,对本项目无意义 27 | 28 | 假如一个对象的 bucket="music" key="hello.mp3" 29 | 将被储存为 ./data/music/hello.mp3 30 | 31 | 你还可以搭配上 Cloudflare 的 CDN ,又快又稳。 32 | 33 | 34 | ## 快速开始 35 | 36 | ### 环境要求 37 | 38 | - PHP 8.0+ 39 | - Apache/Nginx(需启用 mod_rewrite) 40 | 41 | 42 | ### 安装 43 | 44 | 1. 配置好一个网站 45 | 46 | 2. 下载 `index.php` 到你的网站根目录: 47 | 48 | 3. 创建数据目录 49 | 在网站根目录下创建 data 文件夹 50 | 51 | 4. 配置路由重写(以 DA面板 为例): 52 | ```网站根目录下 创建 .htaccess 写入以下命令 53 | 54 | RewriteEngine On 55 | 56 | # 如果请求的不是真实存在的文件或目录 57 | RewriteCond %{REQUEST_FILENAME} !-f 58 | RewriteCond %{REQUEST_FILENAME} !-d 59 | 60 | # 将所有请求重定向到 index.php 61 | RewriteRule ^(.*)$ index.php [L,QSA] 62 | 63 | 64 | ``` 65 | > 其它的虚拟主机,请询问AI如何配置规则重写,将全部的路由都重定向到 index.php 66 | 67 | ### 配置 68 | 69 | 编辑 `index.php` 顶部配置项: 70 | ```php 71 | define('ALLOWED_ACCESS_KEYS', ['改为你想要的访问密钥']); 72 | // 允许的访问密钥,在第三方OSS工具中使用时只需要填写 access-key 即可,其它的 region 和 secret-key 随意填写 73 | 74 | ``` 75 | 76 | 77 | 78 | ### 开始尝试使用吧 79 | 80 | #### 示例:在 Minio 中使用 81 | 82 | ```python 83 | oss_client = Minio("your-domain.com", access_key="改为你想要的访问密钥", secret_key="*", secure=True) 84 | 85 | ``` 86 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | 0 && !empty($parts[0])) { 22 | return $parts[0]; 23 | } 24 | } 25 | 26 | return null; 27 | } 28 | 29 | function auth_check() 30 | { 31 | $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; 32 | $access_key_id = extract_access_key_id($authorization); 33 | if (!$access_key_id || !in_array($access_key_id, ALLOWED_ACCESS_KEYS)) { 34 | http_response_code(401); 35 | exit; 36 | } 37 | return true; 38 | } 39 | 40 | function generate_s3_error_response($code, $message, $resource = '') 41 | { 42 | $xml = new SimpleXMLElement(''); 43 | $xml->addChild('Code', $code); 44 | $xml->addChild('Message', $message); 45 | $xml->addChild('Resource', $resource); 46 | 47 | header('Content-Type: application/xml'); 48 | http_response_code((int) $code); 49 | echo $xml->asXML(); 50 | exit; 51 | } 52 | 53 | function generate_s3_list_objects_response($files, $bucket, $prefix = '') 54 | { 55 | $xml = new SimpleXMLElement(''); 56 | $xml->addChild('Name', $bucket); 57 | $xml->addChild('Prefix', $prefix); 58 | $xml->addChild('MaxKeys', '1000'); 59 | $xml->addChild('IsTruncated', 'false'); 60 | 61 | foreach ($files as $file) { 62 | $contents = $xml->addChild('Contents'); 63 | $contents->addChild('Key', $file['key']); 64 | $contents->addChild('LastModified', date('Y-m-d\TH:i:s.000\Z', $file['timestamp'])); 65 | $contents->addChild('Size', $file['size']); 66 | $contents->addChild('StorageClass', 'STANDARD'); 67 | } 68 | 69 | header('Content-Type: application/xml'); 70 | echo $xml->asXML(); 71 | exit; 72 | } 73 | 74 | function generate_s3_create_multipart_upload_response($bucket, $key, $uploadId) 75 | { 76 | $xml = new SimpleXMLElement(''); 77 | $xml->addChild('Bucket', $bucket); 78 | $xml->addChild('Key', $key); 79 | $xml->addChild('UploadId', $uploadId); 80 | 81 | header('Content-Type: application/xml'); 82 | echo $xml->asXML(); 83 | exit; 84 | } 85 | 86 | function generate_s3_complete_multipart_upload_response($bucket, $key, $uploadId) 87 | { 88 | $xml = new SimpleXMLElement(''); 89 | $xml->addChild('Location', "http://{$_SERVER['HTTP_HOST']}/{$bucket}/{$key}"); 90 | $xml->addChild('Bucket', $bucket); 91 | $xml->addChild('Key', $key); 92 | $xml->addChild('UploadId', $uploadId); 93 | 94 | header('Content-Type: application/xml'); 95 | echo $xml->asXML(); 96 | exit; 97 | } 98 | 99 | function list_files($bucket, $prefix = '') 100 | { 101 | $dir = DATA_DIR . "/{$bucket}"; 102 | $files = []; 103 | 104 | if (!file_exists($dir)) 105 | return $files; 106 | 107 | $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); 108 | 109 | foreach ($iterator as $file) { 110 | if ($file->isDir() || strpos($file->getFilename(), '.') === 0) 111 | continue; 112 | 113 | $relativePath = substr($file->getPathname(), strlen($dir) + 1); 114 | 115 | if ($prefix && strpos($relativePath, $prefix) !== 0) 116 | continue; 117 | 118 | $files[] = [ 119 | 'key' => $relativePath, 120 | 'size' => $file->getSize(), 121 | 'timestamp' => $file->getMTime() 122 | ]; 123 | } 124 | 125 | return $files; 126 | } 127 | 128 | // Ensure DATA_DIR exists 129 | if (!file_exists(DATA_DIR)) { 130 | mkdir(DATA_DIR, 0777, true); 131 | } 132 | 133 | 134 | // 主请求处理逻辑 135 | $method = $_SERVER['REQUEST_METHOD']; 136 | 137 | // 修复1:更健壮的路径解析 138 | $request_uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); 139 | $path_parts = explode('/', trim($request_uri, '/')); 140 | $bucket = $path_parts[0] ?? ''; 141 | $key = implode('/', array_slice($path_parts, 1)); 142 | 143 | // 修复2:验证 bucket 和 key 的合法性 144 | if ($method !== 'GET' && empty($bucket)) { 145 | generate_s3_error_response('400', 'Bucket name not specified', '/'); 146 | } 147 | 148 | // 修复3:处理根路径的 LIST 请求 149 | if ($method === 'GET' && empty($bucket)) { 150 | // 这里可以返回所有 Bucket 列表(如需) 151 | generate_s3_error_response('400', 'Bucket name required', '/'); 152 | } 153 | 154 | // 认证检查 155 | auth_check(); 156 | 157 | 158 | // Check request size 159 | if (isset($_SERVER['CONTENT_LENGTH']) && $_SERVER['CONTENT_LENGTH'] > MAX_REQUEST_SIZE) { 160 | generate_s3_error_response('413', 'Request too large'); 161 | } 162 | 163 | // Route requests 164 | switch ($method) { 165 | case 'PUT': 166 | // Handle PUT (upload object or part) 167 | if (isset($_GET['partNumber']) && isset($_GET['uploadId'])) { 168 | // Upload part 169 | $uploadId = $_GET['uploadId']; 170 | $partNumber = $_GET['partNumber']; 171 | $uploadDir = DATA_DIR . "/{$bucket}/{$key}-temp/{$uploadId}"; 172 | 173 | if (!file_exists($uploadDir)) { 174 | generate_s3_error_response('404', 'Upload ID not found', "/{$bucket}/{$key}"); 175 | } 176 | 177 | $partPath = "{$uploadDir}/{$partNumber}"; 178 | file_put_contents($partPath, file_get_contents('php://input')); 179 | 180 | header('ETag: ' . md5_file($partPath)); 181 | http_response_code(200); 182 | exit; 183 | } else { 184 | // Upload single object 185 | $filePath = DATA_DIR . "/{$bucket}/{$key}"; 186 | $dir = dirname($filePath); 187 | 188 | if (!file_exists($dir)) { 189 | mkdir($dir, 0777, true); 190 | } 191 | 192 | file_put_contents($filePath, file_get_contents('php://input')); 193 | http_response_code(200); 194 | exit; 195 | } 196 | break; 197 | 198 | case 'POST': 199 | // Handle POST (multipart upload) 200 | if (isset($_GET['uploads'])) { 201 | // Initiate multipart upload 202 | $uploadId = bin2hex(random_bytes(16)); 203 | $uploadDir = DATA_DIR . "/{$bucket}/{$key}-temp/{$uploadId}"; 204 | mkdir($uploadDir, 0777, true); 205 | 206 | generate_s3_create_multipart_upload_response($bucket, $key, $uploadId); 207 | } elseif (isset($_GET['uploadId'])) { 208 | // Complete multipart upload 209 | $uploadId = $_GET['uploadId']; 210 | $uploadDir = DATA_DIR . "/{$bucket}/{$key}-temp/{$uploadId}"; 211 | 212 | if (!file_exists($uploadDir)) { 213 | generate_s3_error_response('404', 'Upload ID not found', "/{$bucket}/{$key}"); 214 | } 215 | 216 | // Parse parts from XML 217 | $xml = simplexml_load_string(file_get_contents('php://input')); 218 | $parts = []; 219 | foreach ($xml->Part as $part) { 220 | $parts[(int) $part->PartNumber] = (string) $part->ETag; 221 | } 222 | ksort($parts); 223 | 224 | // Merge parts 225 | $filePath = DATA_DIR . "/{$bucket}/{$key}"; 226 | $dir = dirname($filePath); 227 | if (!file_exists($dir)) 228 | mkdir($dir, 0777, true); 229 | 230 | $fp = fopen($filePath, 'w'); 231 | foreach (array_keys($parts) as $partNumber) { 232 | $partPath = "{$uploadDir}/{$partNumber}"; 233 | if (!file_exists($partPath)) { 234 | generate_s3_error_response('500', "Part file missing: {$partNumber}", "/{$bucket}/{$key}"); 235 | } 236 | fwrite($fp, file_get_contents($partPath)); 237 | } 238 | fclose($fp); 239 | 240 | // Clean up 241 | system("rm -rf " . escapeshellarg(DATA_DIR . "/{$bucket}/{$key}-temp")); 242 | 243 | generate_s3_complete_multipart_upload_response($bucket, $key, $uploadId); 244 | } else { 245 | generate_s3_error_response('400', 'Invalid POST request: missing uploads or uploadId parameter', "/{$bucket}/{$key}"); 246 | } 247 | break; 248 | 249 | case 'GET': 250 | // Handle GET (download or list) 251 | if (empty($key)) { 252 | // List objects 253 | $prefix = $_GET['prefix'] ?? ''; 254 | $files = list_files($bucket, $prefix); 255 | generate_s3_list_objects_response($files, $bucket, $prefix); 256 | } else { 257 | // Download object with streaming and range support 258 | $filePath = DATA_DIR . "/{$bucket}/{$key}"; 259 | if (!file_exists($filePath)) { 260 | generate_s3_error_response('404', 'Object not found', "/{$bucket}/{$key}"); 261 | } 262 | 263 | // Get file size 264 | $filesize = filesize($filePath); 265 | 266 | // Set default headers 267 | $mimeType = mime_content_type($filePath) ?: 'application/octet-stream'; 268 | $fp = fopen($filePath, 'rb'); 269 | 270 | if ($fp === false) { 271 | generate_s3_error_response('500', 'Failed to open file', "/{$bucket}/{$key}"); 272 | } 273 | 274 | // Default response: full file 275 | $start = 0; 276 | $end = $filesize - 1; 277 | $length = $filesize; 278 | 279 | // Check for Range header 280 | $range = $_SERVER['HTTP_RANGE'] ?? ''; 281 | if ($range && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) { 282 | http_response_code(206); // Partial Content 283 | 284 | $start = $matches[1] === '' ? 0 : intval($matches[1]); 285 | $end = $matches[2] === '' ? $filesize - 1 : min(intval($matches[2]), $filesize - 1); 286 | 287 | if ($start > $end || $start < 0) { 288 | header("Content-Range: bytes */$filesize"); 289 | http_response_code(416); // Requested Range Not Satisfiable 290 | exit; 291 | } 292 | 293 | $length = $end - $start + 1; 294 | 295 | header("Content-Range: bytes {$start}-{$end}/{$filesize}"); 296 | header("Content-Length: " . $length); 297 | } else { 298 | http_response_code(200); 299 | header("Content-Length: " . $filesize); 300 | } 301 | 302 | header('Accept-Ranges: bytes'); 303 | header("Content-Type: $mimeType"); 304 | 305 | header("Content-Disposition: attachment; filename=\"" . basename($key) . "\""); 306 | header("Cache-Control: private"); 307 | header("Pragma: public"); 308 | header('X-Powered-By: S3'); 309 | 310 | // Seek to the requested range 311 | fseek($fp, $start); 312 | 313 | $remaining = $length; 314 | $chunkSize = 8 * 1024 * 1024; // 8MB per chunk 315 | while (!feof($fp) && $remaining > 0 && connection_aborted() == false) { 316 | $buffer = fread($fp, min($chunkSize, $remaining)); 317 | echo $buffer; 318 | $remaining -= strlen($buffer); 319 | flush(); 320 | } 321 | 322 | fclose($fp); 323 | exit; 324 | } 325 | break; 326 | 327 | 328 | case 'HEAD': 329 | // Handle HEAD (metadata) 330 | $filePath = DATA_DIR . "/{$bucket}/{$key}"; 331 | 332 | if (!file_exists($filePath)) { 333 | generate_s3_error_response('404', 'Resource not found', "/{$bucket}/{$key}"); 334 | } 335 | 336 | header('Content-Length: ' . filesize($filePath)); 337 | header('Content-Type: ' . mime_content_type($filePath)); 338 | http_response_code(200); 339 | exit; 340 | 341 | case 'DELETE': 342 | // Handle DELETE (delete object or abort upload) 343 | if (isset($_GET['uploadId'])) { 344 | // Abort multipart upload 345 | $uploadId = $_GET['uploadId']; 346 | $uploadDir = DATA_DIR . "/{$bucket}/{$key}-temp/{$uploadId}"; 347 | 348 | if (!file_exists($uploadDir)) { 349 | generate_s3_error_response('404', 'Upload ID not found', "/{$bucket}/{$key}"); 350 | } 351 | 352 | system("rm -rf " . escapeshellarg($uploadDir)); 353 | http_response_code(204); 354 | exit; 355 | } else { 356 | // Delete object 357 | $filePath = DATA_DIR . "/{$bucket}/{$key}"; 358 | 359 | if (file_exists($filePath)) { 360 | unlink($filePath); 361 | } 362 | 363 | http_response_code(204); 364 | exit; 365 | } 366 | break; 367 | 368 | default: 369 | generate_s3_error_response('405', 'Method not allowed'); 370 | } --------------------------------------------------------------------------------