├── .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 | [](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 | [](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 | }
--------------------------------------------------------------------------------