docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Filesystem\FilePath;
use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Status;
final readonly class DeleteImageController
{
public function __construct(
private ImageRepository $imageRepository
) {
}
#[Route(path: '/api/images/{ulid}', method: Method::DELETE)]
public function __invoke(HttpRequest $request): JsonResponse
{
$ulid = $request->routeParameters->getString('ulid');
// Find image by ULID
$image = $this->imageRepository->findByUlid($ulid);
if (!$image) {
throw NotFound::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image with ULID {$ulid} not found"
)->withData(['ulid' => $ulid]);
}
// Delete physical files
$this->deletePhysicalFiles($image->path);
// Delete from database
$this->imageRepository->delete($image);
return new JsonResponse([
'success' => true,
'message' => 'Image deleted successfully',
'ulid' => $ulid,
], Status::OK);
}
/**
* Delete physical image files (original and variants)
*/
private function deletePhysicalFiles(FilePath $imagePath): void
{
$storageDirectory = FilePath::cwd()->join('storage/media');
// Delete original image
$originalPath = $storageDirectory->join($imagePath->toString());
if ($originalPath->exists() && $originalPath->isFile()) {
unlink($originalPath->toString());
}
// Delete thumbnail if exists
$thumbnailPath = $storageDirectory->join('thumbnails')->join($imagePath->toString());
if ($thumbnailPath->exists() && $thumbnailPath->isFile()) {
unlink($thumbnailPath->toString());
}
// Delete other variants (WebP, AVIF, etc.)
$this->deleteImageVariants($storageDirectory, $imagePath);
}
/**
* Delete all image variants (WebP, AVIF, different sizes)
*/
private function deleteImageVariants(FilePath $storageDirectory, FilePath $imagePath): void
{
$baseFilename = $imagePath->getBasename();
$directory = $imagePath->getDirectory();
// Common variant patterns
$variantPatterns = [
'webp/' . $directory . '/' . $baseFilename . '.webp',
'avif/' . $directory . '/' . $baseFilename . '.avif',
'variants/' . $directory . '/' . $baseFilename . '_small.jpg',
'variants/' . $directory . '/' . $baseFilename . '_medium.jpg',
'variants/' . $directory . '/' . $baseFilename . '_large.jpg',
];
foreach ($variantPatterns as $pattern) {
$variantPath = $storageDirectory->join($pattern);
if ($variantPath->exists() && $variantPath->isFile()) {
unlink($variantPath->toString());
}
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Responses\JsonResponse;
final readonly class GetImageController
{
public function __construct(
private ImageRepository $imageRepository
) {
}
#[Route(path: '/api/images/{ulid}', method: Method::GET)]
public function __invoke(HttpRequest $request): JsonResponse
{
$ulid = $request->routeParameters->getString('ulid');
// Find image by ULID
$image = $this->imageRepository->findByUlid($ulid);
if (!$image) {
throw NotFound::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image with ULID {$ulid} not found"
)->withData(['ulid' => $ulid]);
}
return new JsonResponse([
'ulid' => $image->getUlidString(),
'filename' => $image->filename,
'original_filename' => $image->originalFilename,
'url' => '/media/images/' . $image->path->toString(),
'thumbnail_url' => '/media/images/thumbnails/' . $image->path->toString(),
'alt_text' => $image->altText,
'dimensions' => [
'width' => $image->width,
'height' => $image->height,
'aspect_ratio' => $image->getAspectRatio(),
'orientation' => $image->getDimensions()->getOrientation()->value,
],
'mime_type' => $image->mimeType->value,
'file_size' => [
'bytes' => $image->fileSize->toBytes(),
'human_readable' => $image->getHumanReadableFileSize(),
],
'hash' => $image->hash->toString(),
'is_image' => $image->isImageFile(),
'created_at' => $image->ulid->getDateTime()->format('c'),
'variants' => array_map(fn ($variant) => [
'type' => $variant->variantType,
'width' => $variant->width,
'height' => $variant->height,
'path' => $variant->path,
'url' => '/media/images/' . $variant->path,
], $image->variants ?? []),
]);
}
}

View File

@@ -4,18 +4,35 @@ declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Application\Security\Services\FileUploadSecurityService;
use App\Domain\Media\Image;
use App\Domain\Media\ImageProcessor;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageVariantRepository;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\DateTime\Clock;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Status;
use App\Framework\Http\UploadedFile;
use App\Framework\Router\Result\FileResult;
use App\Framework\Ulid\UlidGenerator;
final readonly class ImageApiController
{
public function __construct(
private ImageRepository $imageRepository
private ImageRepository $imageRepository,
private FileUploadSecurityService $uploadSecurityService,
private ImageProcessor $imageProcessor,
private ImageVariantRepository $imageVariantRepository,
private UlidGenerator $ulidGenerator,
private PathProvider $pathProvider,
private Clock $clock
) {
}
@@ -30,19 +47,24 @@ final readonly class ImageApiController
$total = $this->imageRepository->count($search);
return new JsonResponse([
'images' => array_map(fn ($image) => [
'data' => array_map(fn ($image) => [
'ulid' => $image->ulid,
'filename' => $image->filename,
'original_filename' => $image->originalFilename,
'url' => '/media/images/' . $image->path,
'thumbnail_url' => '/media/images/thumbnails/' . $image->path,
'url' => '/images/' . $image->filename,
'thumbnail_url' => '/images/' . str_replace('_original.', '_thumbnail.', $image->filename),
'alt_text' => $image->altText,
'width' => $image->width,
'height' => $image->height,
'dimensions' => [
'width' => $image->width,
'height' => $image->height,
],
'file_size' => [
'bytes' => $image->fileSize->toBytes(),
'human_readable' => $image->fileSize->toHumanReadable(),
],
'mime_type' => $image->mimeType,
'file_size' => $image->fileSize,
], $images),
'pagination' => [
'meta' => [
'total' => $total,
'limit' => $limit,
'offset' => $offset,
@@ -67,19 +89,24 @@ final readonly class ImageApiController
'ulid' => $image->ulid,
'filename' => $image->filename,
'original_filename' => $image->originalFilename,
'url' => '/media/images/' . $image->path,
'url' => '/images/' . $image->filename,
'alt_text' => $image->altText,
'width' => $image->width,
'height' => $image->height,
'dimensions' => [
'width' => $image->width,
'height' => $image->height,
],
'file_size' => [
'bytes' => $image->fileSize->toBytes(),
'human_readable' => $image->fileSize->toHumanReadable(),
],
'mime_type' => $image->mimeType,
'file_size' => $image->fileSize,
'hash' => $image->hash,
'variants' => array_map(fn ($variant) => [
'type' => $variant->variantType,
'width' => $variant->width,
'height' => $variant->height,
'path' => $variant->path,
'url' => '/media/images/' . $variant->path,
'url' => '/storage/' . $variant->path,
], $image->variants ?? []),
]);
}
@@ -128,14 +155,59 @@ final readonly class ImageApiController
'results' => array_map(fn ($image) => [
'ulid' => $image->ulid,
'filename' => $image->filename,
'url' => '/media/images/' . $image->path,
'thumbnail_url' => '/media/images/thumbnails/' . $image->path,
'url' => '/images/' . $image->filename,
'thumbnail_url' => '/images/' . str_replace('_original.', '_thumbnail.', $image->filename),
'alt_text' => $image->altText,
'width' => $image->width,
'height' => $image->height,
'dimensions' => [
'width' => $image->width,
'height' => $image->height,
],
'file_size' => [
'bytes' => $image->fileSize->toBytes(),
'human_readable' => $image->fileSize->toHumanReadable(),
],
'mime_type' => $image->mimeType,
], $images),
'count' => count($images),
]);
}
#[Route(path: '/images/{filename}', method: Method::GET, name: 'show_image')]
public function showImageFile(string $filename): FileResult
{
$image = $this->imageRepository->findByFilename($filename);
if ($image === null) {
$image = $this->imageVariantRepository->findByFilename($filename);
}
if ($image === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image not found: {$filename}"
)->withData(['filename' => $filename]);
}
// The path already contains the full file path
$file = $image->path->toString();
if (!file_exists($file)) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image file not found on filesystem: {$file}"
)->withData(['filename' => $filename, 'file_path' => $file]);
}
// Determine MIME type based on file extension
$mimeType = match (strtolower(pathinfo($file, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
default => 'image/jpeg'
};
return new FileResult($file, 'original', $mimeType);
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Domain\Media\Image;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Status;
use App\Framework\Pagination\PaginationService;
use App\Framework\Pagination\ValueObjects\Direction;
final readonly class ListImagesController
{
public function __construct(
private PaginationService $paginationService
) {
}
#[Route(path: '/api/images', method: Method::GET)]
public function __invoke(HttpRequest $request): JsonResponse
{
// Parse query parameters
$limit = $this->parseLimit($request);
$page = $this->parsePage($request);
$sortField = $this->parseSortField($request);
$direction = $this->parseDirection($request);
$cursor = $this->parseCursor($request);
// Create pagination request
if ($cursor !== null) {
// Cursor-based pagination
$paginationRequest = $this->paginationService->cursorRequest(
limit: $limit,
cursorValue: $cursor,
sortField: $sortField,
direction: $direction
);
} else {
// Offset-based pagination
$offset = ($page - 1) * $limit;
$paginationRequest = $this->paginationService->offsetRequest(
limit: $limit,
offset: $offset,
sortField: $sortField,
direction: $direction
);
}
// Get paginated results
$paginator = $this->paginationService->forEntity(Image::class);
$paginationResponse = $paginator->paginate($paginationRequest);
// Transform images to API format
$transformedData = array_map([$this, 'transformImage'], $paginationResponse->data);
// Create response with transformed data
$responseData = [
'data' => $transformedData,
'meta' => $paginationResponse->meta->toArray(),
];
return new JsonResponse($responseData, Status::OK);
}
/**
* Parse limit parameter with validation
*/
private function parseLimit(HttpRequest $request): int
{
$limit = $request->query->getInt('limit', 20);
// Validate limit bounds
if ($limit < 1) {
$limit = 1;
} elseif ($limit > 100) {
$limit = 100;
}
return $limit;
}
/**
* Parse page parameter with validation
*/
private function parsePage(HttpRequest $request): int
{
$page = $request->query->getInt('page', 1);
return max(1, $page);
}
/**
* Parse sort field parameter
*/
private function parseSortField(HttpRequest $request): ?string
{
$sortField = $request->query->getString('sort');
// Allow only specific fields for security
$allowedFields = ['ulid', 'filename', 'width', 'height', 'fileSize'];
if ($sortField && in_array($sortField, $allowedFields)) {
return $sortField;
}
// Default sort by creation time (via ULID)
return 'ulid';
}
/**
* Parse direction parameter
*/
private function parseDirection(HttpRequest $request): string
{
$direction = $request->query->getString('direction', 'desc');
return in_array($direction, ['asc', 'desc']) ? $direction : 'desc';
}
/**
* Parse cursor parameter
*/
private function parseCursor(HttpRequest $request): ?string
{
return $request->query->getString('cursor');
}
/**
* Transform Image entity to API representation
*/
private function transformImage(Image $image): array
{
return [
'ulid' => $image->getUlidString(),
'filename' => $image->filename,
'original_filename' => $image->originalFilename,
'url' => '/images/' . $image->filename,
'thumbnail_url' => '/images/' . str_replace('_original.', '_thumbnail.', $image->filename),
'alt_text' => $image->altText,
'dimensions' => [
'width' => $image->width,
'height' => $image->height,
'aspect_ratio' => $image->getAspectRatio(),
'orientation' => $image->getDimensions()->getOrientation()->value,
],
'mime_type' => $image->mimeType->value,
'file_size' => [
'bytes' => $image->fileSize->toBytes(),
'human_readable' => $image->getHumanReadableFileSize(),
],
'hash' => $image->hash->toString(),
'is_image' => $image->isImageFile(),
'created_at' => $image->ulid->getDateTime()->format('c'),
'variants' => array_map(fn ($variant) => [
'type' => $variant->variantType,
'width' => $variant->width,
'height' => $variant->height,
'path' => $variant->path,
'url' => '/images/' . $variant->filename,
], $image->variants ?? []),
];
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Status;
final readonly class UpdateImageController
{
public function __construct(
private ImageRepository $imageRepository
) {
}
#[Route(path: '/api/images/{ulid}', method: Method::PUT)]
public function __invoke(HttpRequest $request): JsonResponse
{
$ulid = $request->routeParameters->getString('ulid');
// Find image by ULID
$image = $this->imageRepository->findByUlid($ulid);
if (!$image) {
throw NotFound::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image with ULID {$ulid} not found"
)->withData(['ulid' => $ulid]);
}
// Parse request body
$updateData = $this->parseUpdateData($request);
// Update only allowed fields
$updatedImage = $image;
if (isset($updateData['alt_text'])) {
$updatedImage = $updatedImage->withAltText($updateData['alt_text']);
}
if (isset($updateData['filename'])) {
$this->validateFilename($updateData['filename']);
$updatedImage = $updatedImage->withFilename($updateData['filename']);
}
// Save updated image
$this->imageRepository->save($updatedImage);
// Return updated image data
return new JsonResponse([
'ulid' => $updatedImage->getUlidString(),
'filename' => $updatedImage->filename,
'original_filename' => $updatedImage->originalFilename,
'url' => '/media/images/' . $updatedImage->path->toString(),
'thumbnail_url' => '/media/images/thumbnails/' . $updatedImage->path->toString(),
'alt_text' => $updatedImage->altText,
'dimensions' => [
'width' => $updatedImage->width,
'height' => $updatedImage->height,
'aspect_ratio' => $updatedImage->getAspectRatio(),
'orientation' => $updatedImage->getDimensions()->getOrientation()->value,
],
'mime_type' => $updatedImage->mimeType->value,
'file_size' => [
'bytes' => $updatedImage->fileSize->toBytes(),
'human_readable' => $updatedImage->getHumanReadableFileSize(),
],
'hash' => $updatedImage->hash->toString(),
'is_image' => $updatedImage->isImageFile(),
'created_at' => $updatedImage->ulid->getDateTime()->format('c'),
'variants' => array_map(fn ($variant) => [
'type' => $variant->variantType,
'width' => $variant->width,
'height' => $variant->height,
'path' => $variant->path,
'url' => '/media/images/' . $variant->path,
], $updatedImage->variants ?? []),
], Status::OK);
}
/**
* Parse update data from request body
*/
private function parseUpdateData(HttpRequest $request): array
{
$body = $request->parsedBody->toArray();
// Only allow specific fields to be updated
$allowedFields = ['alt_text', 'filename'];
$updateData = [];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $body)) {
$updateData[$field] = $body[$field];
}
}
return $updateData;
}
/**
* Validate filename
*/
private function validateFilename(string $filename): void
{
if (empty(trim($filename))) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Filename cannot be empty'
)->withData(['field' => 'filename']);
}
// Check for invalid characters
if (preg_match('/[\/\\\\:*?"<>|]/', $filename)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Filename contains invalid characters'
)->withData([
'field' => 'filename',
'value' => $filename,
'invalid_chars' => ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
]);
}
// Check length
if (strlen($filename) > 255) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Filename is too long (maximum 255 characters)'
)->withData([
'field' => 'filename',
'length' => strlen($filename),
'max_length' => 255
]);
}
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Application\Security\Services\FileUploadSecurityService;
use App\Domain\Media\Image;
use App\Domain\Media\ImageProcessor;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\DateTime\Clock;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Filesystem\FilePath;
use App\Framework\Http\Request;
use App\Framework\Http\Method;
use App\Framework\Http\MimeType;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Status;
use App\Framework\Http\UploadedFile;
use App\Framework\Ulid\Ulid;
final readonly class UploadImageController
{
public function __construct(
private ImageRepository $imageRepository,
private FileUploadSecurityService $uploadSecurityService,
private ImageProcessor $imageProcessor,
private Clock $clock
) {
}
#[Route(path: '/api/images', method: Method::POST)]
public function __invoke(Request $request): JsonResponse
{
// Validate uploaded file
$uploadedFiles = $request->files;
if ($uploadedFiles->isEmpty() || !$uploadedFiles->has('image')) {
throw FrameworkException::create(
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
'No image file uploaded'
)->withData([
'field' => 'image',
'files_empty' => $uploadedFiles->isEmpty(),
'available_fields' => $uploadedFiles->keys()
]);
}
$uploadedFile = $uploadedFiles->get('image');
if (!($uploadedFile instanceof UploadedFile)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Invalid uploaded file'
);
}
// Security validation
try {
$validationResult = $this->uploadSecurityService->validateUpload($uploadedFile);
if (!$validationResult) {
throw FrameworkException::create(
ErrorCode::SEC_FILE_UPLOAD_REJECTED,
'File upload security validation failed'
);
}
} catch (\Exception $e) {
throw $e;
}
// Validate MIME type
$detectedMimeType = MimeType::fromFilePath($uploadedFile->name);
if (!$detectedMimeType || !$detectedMimeType->isImage()) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Uploaded file is not a valid image'
)->withData(['detected_mime_type' => $detectedMimeType?->value]);
}
// Generate ULID for image
$ulid = new Ulid($this->clock);
// Calculate file hash first (needed for filename)
if (!is_file($uploadedFile->tmpName)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Temporary file is not a valid file'
)->withData([
'tmp_name' => $uploadedFile->tmpName,
'is_file' => is_file($uploadedFile->tmpName),
'is_dir' => is_dir($uploadedFile->tmpName),
'exists' => file_exists($uploadedFile->tmpName)
]);
}
$fileHash = Hash::fromFile($uploadedFile->tmpName);
// Get original filename and create structured storage path
$originalFilename = $uploadedFile->name;
$extension = $uploadedFile->getExtension() ?: 'jpg';
// Create structured path: uploads/YYYY/MM/DD/XXX/YYY/ZZZ/
$uploadDirectory = sprintf('uploads/%s/%s/%s', date('Y'), date('m'), date('d'));
$ulidString = $ulid->__toString();
$id = substr($ulidString, 10); // Remove timestamp part
$idStr = str_pad((string)$id, 9, '0', STR_PAD_LEFT);
$filePathPattern = sprintf(
'%s/%s/%s',
substr($idStr, 0, 3),
substr($idStr, 3, 3),
substr($idStr, 6, 3),
);
// Create full storage path
$storagePath = $uploadDirectory . '/' . $filePathPattern;
$filename = $idStr . '_' . $fileHash->toString() . '_original.' . $extension;
// Get image dimensions from temporary file
$imageInfo = getimagesize($uploadedFile->tmpName);
if ($imageInfo === false) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Could not read image dimensions'
)->withData(['tmp_file' => $uploadedFile->tmpName]);
}
$width = $imageInfo[0];
$height = $imageInfo[1];
// Get alt text from request (optional)
$altText = $request->parsedBody->get('alt_text', '');
// Create full storage directory path
$storageBasePath = FilePath::cwd()->join('storage');
$fullStoragePath = $storageBasePath->join($storagePath);
// Create Image entity (needed for SaveImageFile service)
// Note: SaveImageFile expects directory path, not full file path
$image = new Image(
ulid: $ulid,
filename: $filename,
originalFilename: $originalFilename,
mimeType: $detectedMimeType,
fileSize: FileSize::fromBytes($uploadedFile->size), // Use uploaded file size
width: $width,
height: $height,
hash: $fileHash, // Use already calculated hash
path: $fullStoragePath, // Directory path as FilePath object
altText: $altText
);
// Save to database and move file (ImageRepository handles both)
$this->imageRepository->save($image, $uploadedFile->tmpName);
// Update file information after successful save
$fullFilePath = $fullStoragePath->join($filename);
$actualFileSize = FileSize::fromFile($fullFilePath->toString());
$actualHash = Hash::fromFile($fullFilePath->toString());
// Update image with actual file information
$image = new Image(
ulid: $ulid,
filename: $filename,
originalFilename: $originalFilename,
mimeType: $detectedMimeType,
fileSize: $actualFileSize,
width: $width,
height: $height,
hash: $actualHash,
path: $fullStoragePath,
altText: $altText
);
// Process image variants (thumbnails, WebP, AVIF, etc.)
$this->imageProcessor->createAllVariants($image);
// Return created image data
return new JsonResponse([
'ulid' => $image->getUlidString(),
'filename' => $image->filename,
'original_filename' => $image->originalFilename,
'url' => '/images/' . $filename,
'thumbnail_url' => '/images/' . str_replace('_original.', '_thumbnail.', $filename),
'alt_text' => $image->altText,
'dimensions' => [
'width' => $image->width,
'height' => $image->height,
'aspect_ratio' => $image->getAspectRatio(),
'orientation' => $image->getDimensions()->getOrientation()->value,
],
'mime_type' => $image->mimeType->value,
'file_size' => [
'bytes' => $image->fileSize->toBytes(),
'human_readable' => $image->getHumanReadableFileSize(),
],
'hash' => $image->hash->toString(),
'is_image' => $image->isImageFile(),
'created_at' => $image->ulid->getDateTime()->format('c'),
], Status::CREATED);
}
}