added IPFS image settings
This commit is contained in:
parent
7ca99356fb
commit
ea83f8eb0a
|
|
@ -1,3 +1,4 @@
|
|||
/api.env
|
||||
/vendor/
|
||||
/storage/security-state.json
|
||||
/storage/nft-cache/
|
||||
|
|
|
|||
75
README.md
75
README.md
|
|
@ -443,6 +443,81 @@ original byte count, and transaction type.
|
|||
| `GET` | `/api/v1/tokens/details?name=...` | Details for one token |
|
||||
| `GET` | `/api/v1/nfts` | NFT/RWA list, ownership type, and supply |
|
||||
| `GET` | `/api/v1/nfts/details?name=...&series=...` | Details for one NFT/RWA item |
|
||||
| `GET` | `/api/v1/nfts/media?name=...&series=...` | NFT/RWA metadata and API image location |
|
||||
| `GET` | `/api/v1/nfts/image?name=...&series=...` | Cached NFT/RWA image or common placeholder |
|
||||
|
||||
#### NFT And RWA Media
|
||||
|
||||
NFT and RWA transactions store an IPFS CID rather than placing media directly
|
||||
on the blockchain. API operators choose whether their API retrieves and caches
|
||||
that media:
|
||||
|
||||
```ini
|
||||
LOCAL_CACHE=NO
|
||||
IPFS_GATEWAY=https://gateway.pinata.cloud/ipfs/
|
||||
NFT_CACHE_PATH=storage/nft-cache
|
||||
NFT_PLACEHOLDER_IMAGE=public/images/nft-placeholder.svg
|
||||
NFT_IPFS_TIMEOUT_SECONDS=10
|
||||
NFT_METADATA_MAX_BYTES=1048576
|
||||
NFT_DOWNLOAD_MAX_BYTES=25000000
|
||||
NFT_IMAGE_MAX_BYTES=5000000
|
||||
NFT_IMAGE_MAX_WIDTH=1200
|
||||
NFT_IMAGE_MAX_HEIGHT=1200
|
||||
NFT_IMAGE_MAX_SOURCE_PIXELS=40000000
|
||||
```
|
||||
|
||||
The default IPFS gateway is the same gateway used by the Contractless GUI
|
||||
wallet.
|
||||
|
||||
When `LOCAL_CACHE=NO`, the API does not contact the IPFS gateway. It returns
|
||||
the CID from the NFT transaction, `image_available: false`, and an image URL
|
||||
that serves the common placeholder.
|
||||
|
||||
When `LOCAL_CACHE=YES`, the API:
|
||||
|
||||
1. Gets the CID from the NFT transaction returned by the configured node.
|
||||
2. Checks `NFT_CACHE_PATH` using the CID and series number.
|
||||
3. Retrieves missing metadata and media through `IPFS_GATEWAY`.
|
||||
4. Returns the placeholder when the gateway times out or the content is
|
||||
unavailable or invalid.
|
||||
5. Uses PHP GD to reduce images that exceed the configured dimensions or
|
||||
cached-image byte limit.
|
||||
6. Stores the metadata and processed image locally.
|
||||
7. Serves future requests from the local cache.
|
||||
|
||||
For an NFT series, the API follows the GUI wallet convention and requests
|
||||
`CID/metadata/SERIES.json`. A one-of-one NFT or RWA uses the transaction CID
|
||||
as its metadata location.
|
||||
|
||||
Example media response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"name": "EXAMPLE",
|
||||
"cid": "bafy...",
|
||||
"series": 0,
|
||||
"image_available": true,
|
||||
"image_url": "/api/v1/nfts/image?name=EXAMPLE&series=0",
|
||||
"metadata": {
|
||||
"name": "Example NFT",
|
||||
"image": "ipfs://bafy.../image.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The CID is always returned, including when the API serves its placeholder.
|
||||
Applications may therefore retrieve the original media through another IPFS
|
||||
gateway.
|
||||
|
||||
`NFT_CACHE_PATH` must be writable by the PHP process and should remain outside
|
||||
the public web directory. Operators may serve the image route through a CDN if
|
||||
desired. Cached content is not committed to Git.
|
||||
|
||||
The PHP API requires the cURL and GD extensions when local NFT caching is
|
||||
enabled. `IPFS_GATEWAY` is not used while `LOCAL_CACHE=NO`.
|
||||
|
||||
### Loans And Marketing
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,21 @@ APP_ENV=production
|
|||
CONTRACTLESS_RPC_NODES=node-1=127.0.0.1:50050,node-2=192.0.2.10:50050
|
||||
CONTRACTLESS_RPC_TIMEOUT=10
|
||||
|
||||
# NFT and RWA media is only retrieved when LOCAL_CACHE is enabled. The gateway
|
||||
# default matches the Contractless GUI wallet. When caching is disabled or
|
||||
# IPFS cannot be reached, the API returns its common placeholder and the CID.
|
||||
LOCAL_CACHE=NO
|
||||
IPFS_GATEWAY=https://gateway.pinata.cloud/ipfs/
|
||||
NFT_CACHE_PATH=storage/nft-cache
|
||||
NFT_PLACEHOLDER_IMAGE=public/images/nft-placeholder.svg
|
||||
NFT_IPFS_TIMEOUT_SECONDS=10
|
||||
NFT_METADATA_MAX_BYTES=1048576
|
||||
NFT_DOWNLOAD_MAX_BYTES=25000000
|
||||
NFT_IMAGE_MAX_BYTES=5000000
|
||||
NFT_IMAGE_MAX_WIDTH=1200
|
||||
NFT_IMAGE_MAX_HEIGHT=1200
|
||||
NFT_IMAGE_MAX_SOURCE_PIXELS=40000000
|
||||
|
||||
# An endpoint enters a temporary cooldown after this many consecutive
|
||||
# connection or transport failures. Protocol and validation failures do not
|
||||
# count because retrying them against another node could conceal bad data.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
"ext-json": "*",
|
||||
"contractless/contractless-php-rpc": "^1.0.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": "Required when LOCAL_CACHE=YES to retrieve IPFS metadata and media.",
|
||||
"ext-gd": "Required when LOCAL_CACHE=YES to validate and resize NFT images."
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Contractless\\Api\\": "src/"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 800 800">
|
||||
<rect width="800" height="800" fill="#0b1820"/>
|
||||
<rect x="72" y="72" width="656" height="656" fill="#142934" stroke="#47d7e8" stroke-width="8"/>
|
||||
<path d="M248 526l112-130 83 87 62-69 91 112H248z" fill="#286da8"/>
|
||||
<circle cx="518" cy="282" r="54" fill="#47d7e8"/>
|
||||
<text x="400" y="635" fill="#ffffff" font-family="Arial, sans-serif" font-size="44" text-anchor="middle">NFT MEDIA</text>
|
||||
<text x="400" y="685" fill="#9eb5c1" font-family="Arial, sans-serif" font-size="25" text-anchor="middle">Use the IPFS CID to retrieve this asset</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 643 B |
|
|
@ -11,6 +11,8 @@ use Contractless\Api\Http\RequestContext;
|
|||
use Contractless\Api\Http\Router;
|
||||
use Contractless\Api\Rpc\RpcReplyDecoder;
|
||||
use Contractless\Api\Rpc\RequestCredentials;
|
||||
use Contractless\Api\Nft\NftMediaService;
|
||||
use Contractless\Api\Routes\NftMediaRoutes;
|
||||
use Contractless\Api\Routes\RemainingRoutes;
|
||||
use Contractless\Api\Security\SecurityMiddleware;
|
||||
use Contractless\Api\Security\TransactionPolicy;
|
||||
|
|
@ -266,6 +268,11 @@ try {
|
|||
});
|
||||
|
||||
RemainingRoutes::register($router, $application);
|
||||
NftMediaRoutes::register(
|
||||
$router,
|
||||
$application,
|
||||
new NftMediaService($config->nftMedia),
|
||||
);
|
||||
$router->dispatch($method, $path);
|
||||
} catch (Throwable $error) {
|
||||
ExceptionResponder::respond($error);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ final class ApiConfig
|
|||
public readonly float $rpcTimeout,
|
||||
public readonly SecurityConfig $security,
|
||||
public readonly ReliabilityConfig $reliability,
|
||||
public readonly NftMediaConfig $nftMedia,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ final class ApiConfig
|
|||
$environment->float('CONTRACTLESS_RPC_TIMEOUT', 10.0),
|
||||
SecurityConfig::fromEnvironment($environment, dirname(__DIR__, 2)),
|
||||
ReliabilityConfig::fromEnvironment($environment),
|
||||
NftMediaConfig::fromEnvironment($environment, dirname(__DIR__, 2)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Api\Config;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class NftMediaConfig
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $localCache,
|
||||
public readonly string $ipfsGateway,
|
||||
public readonly string $cachePath,
|
||||
public readonly string $placeholderPath,
|
||||
public readonly int $timeoutSeconds,
|
||||
public readonly int $maximumMetadataBytes,
|
||||
public readonly int $maximumDownloadBytes,
|
||||
public readonly int $maximumImageBytes,
|
||||
public readonly int $maximumWidth,
|
||||
public readonly int $maximumHeight,
|
||||
public readonly int $maximumSourcePixels,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromEnvironment(
|
||||
Environment $environment,
|
||||
string $projectRoot,
|
||||
): self {
|
||||
$gateway = rtrim(
|
||||
$environment->string(
|
||||
'IPFS_GATEWAY',
|
||||
'https://gateway.pinata.cloud/ipfs/',
|
||||
),
|
||||
'/',
|
||||
) . '/';
|
||||
if (
|
||||
!str_starts_with($gateway, 'https://')
|
||||
&& !str_starts_with($gateway, 'http://')
|
||||
) {
|
||||
throw new RuntimeException(
|
||||
'IPFS_GATEWAY must start with http:// or https://.',
|
||||
);
|
||||
}
|
||||
|
||||
$localCache = $environment->bool('LOCAL_CACHE', false);
|
||||
if (
|
||||
$localCache
|
||||
&& (!extension_loaded('curl') || !extension_loaded('gd'))
|
||||
) {
|
||||
throw new RuntimeException(
|
||||
'LOCAL_CACHE requires the PHP cURL and GD extensions.',
|
||||
);
|
||||
}
|
||||
|
||||
return new self(
|
||||
$localCache,
|
||||
$gateway,
|
||||
self::path(
|
||||
$environment->string('NFT_CACHE_PATH', 'storage/nft-cache'),
|
||||
$projectRoot,
|
||||
),
|
||||
self::path(
|
||||
$environment->string(
|
||||
'NFT_PLACEHOLDER_IMAGE',
|
||||
'public/images/nft-placeholder.svg',
|
||||
),
|
||||
$projectRoot,
|
||||
),
|
||||
$environment->int('NFT_IPFS_TIMEOUT_SECONDS', 10, 1, 120),
|
||||
$environment->int('NFT_METADATA_MAX_BYTES', 1_048_576, 1_024, 10_485_760),
|
||||
$environment->int('NFT_DOWNLOAD_MAX_BYTES', 25_000_000, 1_024, 100_000_000),
|
||||
$environment->int('NFT_IMAGE_MAX_BYTES', 5_000_000, 1_024, 25_000_000),
|
||||
$environment->int('NFT_IMAGE_MAX_WIDTH', 1_200, 32, 10_000),
|
||||
$environment->int('NFT_IMAGE_MAX_HEIGHT', 1_200, 32, 10_000),
|
||||
$environment->int('NFT_IMAGE_MAX_SOURCE_PIXELS', 40_000_000, 1_024, 100_000_000),
|
||||
);
|
||||
}
|
||||
|
||||
private static function path(string $path, string $projectRoot): string
|
||||
{
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
throw new RuntimeException('NFT cache paths cannot be empty.');
|
||||
}
|
||||
if (!self::isAbsolutePath($path)) {
|
||||
return $projectRoot . DIRECTORY_SEPARATOR
|
||||
. str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
private static function isAbsolutePath(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, '/')
|
||||
|| str_starts_with($path, '\\')
|
||||
|| preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Api\Http;
|
||||
|
||||
final class ImageResponse
|
||||
{
|
||||
public static function send(
|
||||
string $path,
|
||||
string $contentType,
|
||||
bool $cached,
|
||||
string $cid,
|
||||
): never {
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
throw new HttpException(500, 'The NFT placeholder image is unavailable.');
|
||||
}
|
||||
|
||||
$size = filesize($path);
|
||||
if ($size === false) {
|
||||
throw new HttpException(500, 'The NFT image could not be read.');
|
||||
}
|
||||
|
||||
header('Content-Type: ' . $contentType);
|
||||
header('Content-Length: ' . $size);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Contractless-IPFS-CID: ' . $cid);
|
||||
header('X-Contractless-Image-Available: ' . ($cached ? 'true' : 'false'));
|
||||
header($cached
|
||||
? 'Cache-Control: public, max-age=86400, immutable'
|
||||
: 'Cache-Control: public, max-age=300');
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,454 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Api\Nft;
|
||||
|
||||
use Contractless\Api\Config\NftMediaConfig;
|
||||
use Contractless\Api\Http\ImageResponse;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class NftMediaService
|
||||
{
|
||||
public function __construct(private readonly NftMediaConfig $config)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* cid: string,
|
||||
* series: int,
|
||||
* image_available: bool,
|
||||
* image_url: string,
|
||||
* metadata: array<string, mixed>|null
|
||||
* }
|
||||
*/
|
||||
public function describe(string $name, string $cid, int $series): array
|
||||
{
|
||||
$cid = $this->cid($cid);
|
||||
$available = false;
|
||||
$metadata = null;
|
||||
|
||||
if ($this->config->localCache) {
|
||||
try {
|
||||
$record = $this->cachedOrFetch($cid, $series);
|
||||
$available = $record !== null;
|
||||
$metadata = $record['metadata'] ?? null;
|
||||
} catch (Throwable) {
|
||||
$available = false;
|
||||
$metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'cid' => $cid,
|
||||
'series' => $series,
|
||||
'image_available' => $available,
|
||||
'image_url' => '/api/v1/nfts/image?name='
|
||||
. rawurlencode($name)
|
||||
. '&series=' . $series,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
public function sendImage(string $cid, int $series): never
|
||||
{
|
||||
$cid = $this->cid($cid);
|
||||
if ($this->config->localCache) {
|
||||
try {
|
||||
$record = $this->cachedOrFetch($cid, $series);
|
||||
if ($record !== null) {
|
||||
ImageResponse::send(
|
||||
$record['image_path'],
|
||||
$record['image_mime'],
|
||||
true,
|
||||
$cid,
|
||||
);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// The common placeholder is the expected fallback.
|
||||
}
|
||||
}
|
||||
|
||||
ImageResponse::send(
|
||||
$this->config->placeholderPath,
|
||||
$this->placeholderMime(),
|
||||
false,
|
||||
$cid,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* metadata: array<string, mixed>,
|
||||
* image_path: string,
|
||||
* image_mime: string
|
||||
* }|null
|
||||
*/
|
||||
private function cachedOrFetch(string $cid, int $series): ?array
|
||||
{
|
||||
$directory = $this->cacheDirectory($cid, $series);
|
||||
$record = $this->readRecord($directory);
|
||||
if ($record !== null) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
$this->ensureDirectory($directory);
|
||||
$lock = fopen($directory . DIRECTORY_SEPARATOR . 'cache.lock', 'c');
|
||||
if ($lock === false) {
|
||||
throw new RuntimeException('Unable to lock the NFT cache.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($lock, LOCK_EX)) {
|
||||
throw new RuntimeException('Unable to lock the NFT cache.');
|
||||
}
|
||||
$record = $this->readRecord($directory);
|
||||
if ($record !== null) {
|
||||
return $record;
|
||||
}
|
||||
return $this->fetchAndCache($cid, $series, $directory);
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* metadata: array<string, mixed>,
|
||||
* image_path: string,
|
||||
* image_mime: string
|
||||
* }
|
||||
*/
|
||||
private function fetchAndCache(string $cid, int $series, string $directory): array
|
||||
{
|
||||
$metadataPath = $series > 0
|
||||
? $cid . '/metadata/' . $series . '.json'
|
||||
: $cid;
|
||||
$metadataBytes = $this->download(
|
||||
$metadataPath,
|
||||
$this->config->maximumMetadataBytes,
|
||||
);
|
||||
try {
|
||||
$metadata = json_decode(
|
||||
$metadataBytes,
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
} catch (\JsonException) {
|
||||
throw new RuntimeException('IPFS NFT metadata is not valid JSON.');
|
||||
}
|
||||
if (!is_array($metadata)) {
|
||||
throw new RuntimeException('IPFS NFT metadata must be a JSON object.');
|
||||
}
|
||||
|
||||
$imageReference = $metadata['image']
|
||||
?? $metadata['image_url']
|
||||
?? $metadata['animation_url']
|
||||
?? null;
|
||||
if (!is_string($imageReference) || trim($imageReference) === '') {
|
||||
throw new RuntimeException('IPFS NFT metadata does not contain an image.');
|
||||
}
|
||||
$imagePath = $this->imagePath($imageReference, $metadataPath);
|
||||
$imageBytes = $this->download(
|
||||
$imagePath,
|
||||
$this->config->maximumDownloadBytes,
|
||||
);
|
||||
[$processed, $mime] = $this->processImage($imageBytes);
|
||||
|
||||
$imageFile = $directory . DIRECTORY_SEPARATOR . 'image.bin';
|
||||
$recordFile = $directory . DIRECTORY_SEPARATOR . 'record.json';
|
||||
$this->atomicWrite($imageFile, $processed);
|
||||
$this->atomicWrite(
|
||||
$recordFile,
|
||||
json_encode(
|
||||
[
|
||||
'cid' => $cid,
|
||||
'series' => $series,
|
||||
'metadata' => $metadata,
|
||||
'image_mime' => $mime,
|
||||
],
|
||||
JSON_UNESCAPED_SLASHES
|
||||
| JSON_UNESCAPED_UNICODE
|
||||
| JSON_THROW_ON_ERROR,
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
'metadata' => $metadata,
|
||||
'image_path' => $imageFile,
|
||||
'image_mime' => $mime,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* metadata: array<string, mixed>,
|
||||
* image_path: string,
|
||||
* image_mime: string
|
||||
* }|null
|
||||
*/
|
||||
private function readRecord(string $directory): ?array
|
||||
{
|
||||
$recordFile = $directory . DIRECTORY_SEPARATOR . 'record.json';
|
||||
$imageFile = $directory . DIRECTORY_SEPARATOR . 'image.bin';
|
||||
if (!is_file($recordFile) || !is_file($imageFile)) {
|
||||
return null;
|
||||
}
|
||||
$json = file_get_contents($recordFile);
|
||||
if ($json === false) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$record = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!is_array($record)
|
||||
|| !is_array($record['metadata'] ?? null)
|
||||
|| !is_string($record['image_mime'] ?? null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'metadata' => $record['metadata'],
|
||||
'image_path' => $imageFile,
|
||||
'image_mime' => $record['image_mime'],
|
||||
];
|
||||
}
|
||||
|
||||
private function download(string $ipfsPath, int $maximumBytes): string
|
||||
{
|
||||
$url = $this->config->ipfsGateway . $this->encodedPath($ipfsPath);
|
||||
$bytes = '';
|
||||
$tooLarge = false;
|
||||
$handle = curl_init($url);
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Unable to initialize the IPFS request.');
|
||||
}
|
||||
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->config->timeoutSeconds,
|
||||
CURLOPT_TIMEOUT => $this->config->timeoutSeconds,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_USERAGENT => 'Contractless-PHP-API/1.0',
|
||||
CURLOPT_WRITEFUNCTION => static function (
|
||||
mixed $curl,
|
||||
string $chunk,
|
||||
) use (&$bytes, &$tooLarge, $maximumBytes): int {
|
||||
if (strlen($bytes) + strlen($chunk) > $maximumBytes) {
|
||||
$tooLarge = true;
|
||||
return 0;
|
||||
}
|
||||
$bytes .= $chunk;
|
||||
return strlen($chunk);
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
$success = curl_exec($handle);
|
||||
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||
if ($tooLarge) {
|
||||
throw new RuntimeException('The IPFS response exceeded its size limit.');
|
||||
}
|
||||
if ($success === false || $status < 200 || $status >= 300) {
|
||||
throw new RuntimeException('The IPFS gateway did not return the requested data.');
|
||||
}
|
||||
} finally {
|
||||
curl_close($handle);
|
||||
}
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/** @return array{string, string} */
|
||||
private function processImage(string $bytes): array
|
||||
{
|
||||
$details = getimagesizefromstring($bytes);
|
||||
if ($details === false || !isset($details['mime'])) {
|
||||
throw new RuntimeException('The IPFS media is not a supported image.');
|
||||
}
|
||||
$mime = strtolower((string) $details['mime']);
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
|
||||
throw new RuntimeException('The IPFS media uses an unsupported image type.');
|
||||
}
|
||||
$width = (int) $details[0];
|
||||
$height = (int) $details[1];
|
||||
if (
|
||||
$width < 1
|
||||
|| $height < 1
|
||||
|| $width > intdiv($this->config->maximumSourcePixels, $height)
|
||||
) {
|
||||
throw new RuntimeException('The IPFS image dimensions exceed the processing limit.');
|
||||
}
|
||||
if (
|
||||
strlen($bytes) <= $this->config->maximumImageBytes
|
||||
&& $width <= $this->config->maximumWidth
|
||||
&& $height <= $this->config->maximumHeight
|
||||
) {
|
||||
return [$bytes, $mime];
|
||||
}
|
||||
|
||||
$source = imagecreatefromstring($bytes);
|
||||
if ($source === false) {
|
||||
throw new RuntimeException('The IPFS image could not be resized.');
|
||||
}
|
||||
try {
|
||||
$scale = min(
|
||||
1.0,
|
||||
$this->config->maximumWidth / max(1, $width),
|
||||
$this->config->maximumHeight / max(1, $height),
|
||||
);
|
||||
if (strlen($bytes) > $this->config->maximumImageBytes) {
|
||||
$scale = min(
|
||||
$scale,
|
||||
sqrt($this->config->maximumImageBytes / strlen($bytes)) * 0.9,
|
||||
);
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt < 8; $attempt++) {
|
||||
$targetWidth = max(1, (int) floor($width * $scale));
|
||||
$targetHeight = max(1, (int) floor($height * $scale));
|
||||
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
if ($target === false) {
|
||||
throw new RuntimeException('The IPFS image could not be resized.');
|
||||
}
|
||||
if ($mime !== 'image/jpeg') {
|
||||
imagealphablending($target, false);
|
||||
imagesavealpha($target, true);
|
||||
$transparent = imagecolorallocatealpha($target, 0, 0, 0, 127);
|
||||
imagefill($target, 0, 0, $transparent);
|
||||
}
|
||||
imagecopyresampled(
|
||||
$target,
|
||||
$source,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$width,
|
||||
$height,
|
||||
);
|
||||
ob_start();
|
||||
$outputMime = $this->writeImage($target, $mime);
|
||||
$output = ob_get_clean();
|
||||
imagedestroy($target);
|
||||
if (is_string($output) && strlen($output) <= $this->config->maximumImageBytes) {
|
||||
return [$output, $outputMime];
|
||||
}
|
||||
$scale *= 0.75;
|
||||
}
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
throw new RuntimeException('The IPFS image could not be reduced to the cache limit.');
|
||||
}
|
||||
|
||||
private function writeImage(\GdImage $image, string $mime): string
|
||||
{
|
||||
if ($mime === 'image/jpeg') {
|
||||
imagejpeg($image, null, 85);
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if ($mime === 'image/webp' && function_exists('imagewebp')) {
|
||||
imagewebp($image, null, 82);
|
||||
return 'image/webp';
|
||||
}
|
||||
imagepng($image, null, 8);
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
private function imagePath(string $reference, string $metadataPath): string
|
||||
{
|
||||
$path = trim($reference);
|
||||
foreach (['ipfs://', 'ipfs:/', 'ipfs:', '/ipfs/', 'ipfs/'] as $prefix) {
|
||||
if (str_starts_with(strtolower($path), $prefix)) {
|
||||
$path = substr($path, strlen($prefix));
|
||||
break;
|
||||
}
|
||||
}
|
||||
$path = ltrim($path, '/');
|
||||
if (
|
||||
str_starts_with($path, 'http://')
|
||||
|| str_starts_with($path, 'https://')
|
||||
|| str_contains($path, '..')
|
||||
|| str_contains($path, "\0")
|
||||
) {
|
||||
throw new RuntimeException('NFT metadata contains an unsafe image reference.');
|
||||
}
|
||||
if (preg_match('/^[A-Za-z0-9][A-Za-z0-9._~\/-]*$/', $path) !== 1) {
|
||||
throw new RuntimeException('NFT metadata contains an invalid image reference.');
|
||||
}
|
||||
if (preg_match('/^(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]+)(\/|$)/', $path) === 1) {
|
||||
return $path;
|
||||
}
|
||||
$directory = str_contains($metadataPath, '/')
|
||||
? dirname($metadataPath)
|
||||
: '';
|
||||
return ltrim(($directory === '.' ? '' : $directory . '/') . $path, '/');
|
||||
}
|
||||
|
||||
private function cid(string $cid): string
|
||||
{
|
||||
$cid = trim($cid);
|
||||
if (
|
||||
strlen($cid) > 100
|
||||
|| preg_match('/^(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]+)$/', $cid) !== 1
|
||||
) {
|
||||
throw new RuntimeException('The NFT transaction contains an invalid IPFS CID.');
|
||||
}
|
||||
return $cid;
|
||||
}
|
||||
|
||||
private function encodedPath(string $path): string
|
||||
{
|
||||
return implode('/', array_map('rawurlencode', explode('/', $path)));
|
||||
}
|
||||
|
||||
private function cacheDirectory(string $cid, int $series): string
|
||||
{
|
||||
return $this->config->cachePath . DIRECTORY_SEPARATOR
|
||||
. hash('sha256', $cid . ':' . $series);
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $directory): void
|
||||
{
|
||||
if (
|
||||
!is_dir($directory)
|
||||
&& !mkdir($directory, 0750, true)
|
||||
&& !is_dir($directory)
|
||||
) {
|
||||
throw new RuntimeException('Unable to create the NFT cache directory.');
|
||||
}
|
||||
}
|
||||
|
||||
private function atomicWrite(string $path, string $bytes): void
|
||||
{
|
||||
$temporary = $path . '.tmp-' . bin2hex(random_bytes(6));
|
||||
if (file_put_contents($temporary, $bytes, LOCK_EX) === false) {
|
||||
throw new RuntimeException('Unable to write the NFT cache.');
|
||||
}
|
||||
if (!rename($temporary, $path)) {
|
||||
@unlink($temporary);
|
||||
throw new RuntimeException('Unable to finalize the NFT cache.');
|
||||
}
|
||||
}
|
||||
|
||||
private function placeholderMime(): string
|
||||
{
|
||||
return match (strtolower(pathinfo($this->config->placeholderPath, PATHINFO_EXTENSION))) {
|
||||
'png' => 'image/png',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
default => 'image/svg+xml',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Api\Routes;
|
||||
|
||||
use Contractless\Api\Application;
|
||||
use Contractless\Api\Http\JsonResponse;
|
||||
use Contractless\Api\Http\Request;
|
||||
use Contractless\Api\Http\Router;
|
||||
use Contractless\Api\Nft\NftMediaService;
|
||||
|
||||
final class NftMediaRoutes
|
||||
{
|
||||
public static function register(
|
||||
Router $router,
|
||||
Application $application,
|
||||
NftMediaService $media,
|
||||
): void {
|
||||
$router->get('/api/v1/nfts/media', static function () use (
|
||||
$application,
|
||||
$media,
|
||||
): never {
|
||||
[$name, $series, $cid] = self::nftIdentity($application);
|
||||
JsonResponse::success(
|
||||
['name' => $name]
|
||||
+ $media->describe($name, $cid, $series),
|
||||
);
|
||||
});
|
||||
|
||||
$router->get('/api/v1/nfts/image', static function () use (
|
||||
$application,
|
||||
$media,
|
||||
): never {
|
||||
[, $series, $cid] = self::nftIdentity($application);
|
||||
$media->sendImage($cid, $series);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{string, int, string} */
|
||||
private static function nftIdentity(Application $application): array
|
||||
{
|
||||
$name = trim(Request::queryString('name', 15));
|
||||
if (
|
||||
$name === ''
|
||||
|| strlen($name) > 15
|
||||
|| preg_match('/^[\x20-\x7E]+$/', $name) !== 1
|
||||
) {
|
||||
throw new \Contractless\Api\Http\HttpException(
|
||||
422,
|
||||
'Enter a valid NFT name.',
|
||||
);
|
||||
}
|
||||
$series = Request::queryInt('series', 0, 0, 4_294_967_295);
|
||||
$reply = $application->client->nftDetails($name, $series);
|
||||
\Contractless\Api\Rpc\RpcReplyDecoder::notNodeError($reply);
|
||||
if (strlen($reply) < 173) {
|
||||
throw new \RuntimeException('The node returned invalid NFT details.');
|
||||
}
|
||||
$cid = trim(substr($reply, 73, 100), "\0 ");
|
||||
return [$name, $series, $cid];
|
||||
}
|
||||
}
|
||||
|
|
@ -393,7 +393,12 @@ final class RpcReplyDecoder
|
|||
* txid: string,
|
||||
* block_height: int,
|
||||
* transaction_hex: string,
|
||||
* miner_earnings: int
|
||||
* miner_earnings: list<array{
|
||||
* type: string,
|
||||
* asset: string,
|
||||
* nft_series: int,
|
||||
* amount_atomic: string
|
||||
* }>
|
||||
* }>
|
||||
*/
|
||||
public static function addressHistory(string $reply): array
|
||||
|
|
@ -420,7 +425,25 @@ final class RpcReplyDecoder
|
|||
}
|
||||
$transaction = substr($reply, $offset, $transactionLength);
|
||||
$offset += $transactionLength;
|
||||
$minerEarnings = self::u32($reply, $offset);
|
||||
$earningCount = self::u32($reply, $offset);
|
||||
$minerEarnings = [];
|
||||
for ($earningIndex = 0; $earningIndex < $earningCount; $earningIndex++) {
|
||||
if (strlen($reply) - $offset < 28) {
|
||||
throw new RuntimeException('The node returned truncated miner earnings.');
|
||||
}
|
||||
$earningType = ord($reply[$offset]);
|
||||
$offset++;
|
||||
$asset = rtrim(substr($reply, $offset, 15));
|
||||
$offset += 15;
|
||||
$nftSeries = self::u32($reply, $offset);
|
||||
$amount = self::u64($reply, $offset);
|
||||
$minerEarnings[] = [
|
||||
'type' => $earningType === 0 ? 'fee' : 'tip',
|
||||
'asset' => $asset,
|
||||
'nft_series' => $nftSeries,
|
||||
'amount_atomic' => (string) $amount,
|
||||
];
|
||||
}
|
||||
$records[] = [
|
||||
'txid' => $txid,
|
||||
'block_height' => $height,
|
||||
|
|
|
|||
Loading…
Reference in New Issue