Skip to content

Commit 5d18c17

Browse files
Merge pull request #4 from EdouardCourty/feat/support-folder-upload
feat(upload): support file and folder upload
2 parents 0b7aece + c41dae1 commit 5d18c17

File tree

15 files changed

+388
-4
lines changed

15 files changed

+388
-4
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,22 @@ This release brings support for the `resolve` command.
5252

5353
- Added the `IPFSClient::resolve` method, which returns the path to a given IPFS name.
5454
- Added corresponding tests for the new method.
55+
56+
## v1.4.0
57+
58+
This release enhances the `add` feature, by allowing to precisely upload files and directories instead of raw data.
59+
60+
#### Additions
61+
62+
- Added the `IPFSClient::addFile` method, which allows for adding files to IPFS.
63+
- Added corresponding tests for the new method.
64+
- Added the `IPFSClient::addDirectory` method, which allows for adding directories to IPFS.
65+
- Added corresponding tests for the new method.
66+
- Added the `Directory` model and corresponding transformer.
67+
- Added corresponding tests for the new transformer.
68+
- Other minor additions such as `Helper\FilesytemHelper`
69+
- Added [code examples](examples).
70+
71+
#### Updates
72+
73+
- Updated `README.md` to use the new `IPFSClient::addFile` method instead of `IPFSClient::add` in the provided code example.

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ $client = new IPFSClient(url: 'http://localhost:5001');
2828
// $client = new IPFSClient(host: 'localhost', port: 5001);
2929

3030
// Add a file
31-
$fileContent = file_get_contents('file.txt');
32-
$file = $client->add($fileContent);
31+
$file = $client->addFile('file.txt');
3332

3433
echo 'File uploaded: ' . $file->hash;
3534
// File uploaded: QmWGeRAEgtsHW3ec7U4qW2CyVy7eA2mFRVbk1nb24jFyks

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"require": {
66
"php": ">= 8.3",
77
"symfony/http-client": "^7.2",
8-
"selective/base32": "^2.0"
8+
"selective/base32": "^2.0",
9+
"symfony/mime": "^7.2"
910
},
1011
"require-dev": {
1112
"phpunit/phpunit": "^12.0",

examples/upload_directory.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require_once __DIR__ . '/../vendor/autoload.php';
6+
7+
use IPFS\Client\IPFSClient;
8+
9+
// Instantiate the IPFS client
10+
$client = new IPFSClient(host: 'localhost', port: 5001);
11+
12+
// Add a file to IPFS
13+
$directory = $client->addDirectory(__DIR__ . '/../src');
14+
15+
echo '-- Directory added to IPFS --' . \PHP_EOL;
16+
echo 'Directory hash: ' . $directory->hash . \PHP_EOL;
17+
echo 'Directory size: ' . $directory->size . \PHP_EOL;
18+
echo 'Number of files in this directory: ' . \count($directory->files) . \PHP_EOL;

examples/upload_file.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require_once __DIR__ . '/../vendor/autoload.php';
6+
7+
use IPFS\Client\IPFSClient;
8+
9+
// Instantiate the IPFS client
10+
$client = new IPFSClient(host: 'localhost', port: 5001);
11+
12+
// Add a file to IPFS
13+
$file = $client->addFile(__DIR__ . '/upload_file.php');
14+
15+
echo '-- File added to IPFS --' . \PHP_EOL;
16+
echo 'File hash: ' . $file->hash . \PHP_EOL;
17+
echo 'File size: ' . $file->size . \PHP_EOL;
18+
19+
echo \PHP_EOL . '-- Retrieving the file content from IPFS --' . \PHP_EOL;
20+
21+
// Retrieve the file content from IPFS
22+
$ipfsFileContent = $client->cat($file->hash);
23+
echo 'IPFS File content: ' . \PHP_EOL . $ipfsFileContent . \PHP_EOL;

src/Client/IPFSClient.php

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
namespace IPFS\Client;
66

77
use IPFS\Exception\IPFSTransportException;
8+
use IPFS\Helper\FilesystemHelper;
89
use IPFS\Model\File;
10+
use IPFS\Model\Directory;
911
use IPFS\Model\ListFileEntry;
1012
use IPFS\Model\Node;
1113
use IPFS\Model\Peer;
@@ -14,12 +16,15 @@
1416
use IPFS\Transformer\FileLinkTransformer;
1517
use IPFS\Transformer\FileListTransformer;
1618
use IPFS\Transformer\FileTransformer;
19+
use IPFS\Transformer\DirectoryTransformer;
1720
use IPFS\Transformer\NodeTransformer;
1821
use IPFS\Transformer\PeerIdentityTransformer;
1922
use IPFS\Transformer\PeerStreamTransformer;
2023
use IPFS\Transformer\PeerTransformer;
2124
use IPFS\Transformer\PingTransformer;
2225
use IPFS\Transformer\VersionTransformer;
26+
use Symfony\Component\Mime\Part\DataPart;
27+
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
2328

2429
class IPFSClient
2530
{
@@ -63,6 +68,99 @@ public function add(string $file, array $parameters = []): File
6368
return $transformer->transform($parsedResponse);
6469
}
6570

71+
public function addFile(string $path, array $parameters = []): File
72+
{
73+
if (is_file($path) === false) {
74+
throw new \InvalidArgumentException('Path must be a file.');
75+
}
76+
$parameters['wrap-with-directory'] = false;
77+
78+
$response = $this->httpClient->request('POST', '/api/v0/add', [
79+
'body' => [
80+
'file' => fopen($path, 'r'),
81+
],
82+
'query' => $parameters,
83+
'headers' => [
84+
'Content-Type' => 'multipart/form-data',
85+
],
86+
]);
87+
88+
$parsedResponse = json_decode($response, true);
89+
90+
$transformer = new FileTransformer();
91+
return $transformer->transform($parsedResponse);
92+
}
93+
94+
public function addDirectory(string $path, array $parameters = []): Directory
95+
{
96+
if (is_dir($path) === false) {
97+
throw new \InvalidArgumentException('Path must be a directory.');
98+
}
99+
$parameters['wrap-with-directory'] = true;
100+
101+
$files = FilesystemHelper::listFiles($path);
102+
103+
$dataParts = [];
104+
$basePath = realpath($path);
105+
if ($basePath === false) {
106+
throw new \UnexpectedValueException('Path ' . $path . ' does not exist.');
107+
}
108+
109+
foreach ($files as $filePath) {
110+
$filePathReal = realpath($filePath);
111+
if ($filePathReal === false) {
112+
throw new \UnexpectedValueException('Path ' . $path . ' does not exist.');
113+
}
114+
115+
if (str_starts_with($filePathReal, $basePath) === false) {
116+
throw new \RuntimeException("File $filePath is outside of base path.");
117+
}
118+
119+
$relativePath = mb_substr($filePathReal, mb_strlen($basePath) + 1); // +1 to remove leading slash
120+
$relativePath = str_replace(\DIRECTORY_SEPARATOR, '/', $relativePath); // For Windows
121+
122+
$fileHandle = fopen($filePath, 'r');
123+
if ($fileHandle === false) {
124+
throw new \RuntimeException('Unable to open file ' . $filePath);
125+
}
126+
127+
$dataParts[] = new DataPart($fileHandle, $relativePath);
128+
}
129+
130+
// Use Symfony's FormDataPart to build the body + headers
131+
$formData = new FormDataPart([
132+
'file' => $dataParts,
133+
]);
134+
135+
$response = $this->httpClient->request('POST', '/api/v0/add', [
136+
'body' => $formData->bodyToString(),
137+
'query' => $parameters,
138+
'headers' => $formData->getPreparedHeaders()->toArray(),
139+
]);
140+
141+
$parts = explode("\n", $response);
142+
$filtered = array_filter($parts, function (string $value) {
143+
return mb_strlen(trim($value)) > 0;
144+
});
145+
$deserializedParts = array_map(fn (string $part) => json_decode($part, true), $filtered);
146+
// Sort by size, larger first
147+
usort($deserializedParts, function ($a, $b) {
148+
return (int) $a['Size'] < (int) $b['Size'] ? 1 : -1;
149+
});
150+
151+
$directoryTransformer = new DirectoryTransformer();
152+
153+
$directoryData = array_shift($deserializedParts);
154+
$directory = $directoryTransformer->transform($directoryData);
155+
156+
$fileTransformer = new FileTransformer();
157+
foreach ($deserializedParts as $part) {
158+
$directory->addFile($fileTransformer->transform($part));
159+
}
160+
161+
return $directory;
162+
}
163+
66164
public function cat(string $hash, int $offset = 0, ?int $length = null): string
67165
{
68166
return $this->httpClient->request('POST', '/api/v0/cat', [

src/Helper/FilesystemHelper.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace IPFS\Helper;
6+
7+
class FilesystemHelper
8+
{
9+
/**
10+
* @return string[]
11+
*/
12+
public static function listFiles(string $directory): array
13+
{
14+
if (is_dir($directory) === false) {
15+
throw new \InvalidArgumentException('Directory not found.');
16+
}
17+
18+
$files = [];
19+
$iterator = new \RecursiveIteratorIterator(
20+
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
21+
);
22+
23+
foreach ($iterator as $fileInfo) {
24+
if ($fileInfo->isFile()) {
25+
$files[] = $fileInfo->getRealPath();
26+
}
27+
}
28+
29+
return $files;
30+
}
31+
}

src/Model/Directory.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace IPFS\Model;
6+
7+
class Directory
8+
{
9+
/**
10+
* @param File[] $files
11+
*/
12+
public function __construct(
13+
public readonly string $name,
14+
public readonly string $hash,
15+
public readonly string $size,
16+
public array $files = [],
17+
) {
18+
}
19+
20+
public function addFile(File $file): self
21+
{
22+
$this->files[] = $file;
23+
24+
return $this;
25+
}
26+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace IPFS\Transformer;
6+
7+
use IPFS\Model\Directory;
8+
9+
class DirectoryTransformer extends AbstractTransformer
10+
{
11+
public function transform(array $input): Directory
12+
{
13+
$this->assertParameters($input, ['Name', 'Hash', 'Size']);
14+
15+
return new Directory(
16+
name: $input['Name'],
17+
hash: $input['Hash'],
18+
size: $input['Size'],
19+
files: [],
20+
);
21+
}
22+
}

0 commit comments

Comments
 (0)