Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions app/Actions/Files/StoreUploadedFileAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace App\Actions\Files;

use App\Enums\FileStatus;
use App\Exceptions\Files\FileStorageException;
use App\Models\FileRecord;
use App\Models\User;
use App\Support\Files\FileExpirationPolicy;
use App\Support\Files\FileFormatDetector;
use App\Support\Files\FileRecordCreator;
use App\Support\Files\ImageMetadataExtractor;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Throwable;

final class StoreUploadedFileAction
{
public function __construct(
private readonly FileFormatDetector $formatDetector,
private readonly ImageMetadataExtractor $metadataExtractor,
private readonly FileExpirationPolicy $expirationPolicy,
private readonly FileRecordCreator $recordCreator,
) {}

public function handle(User $user, UploadedFile $file): FileRecord
{
$format = $this->formatDetector->detect($file);
$metadata = $this->metadataExtractor->extract($file, $format);

$disk = Storage::disk('local');
$path = $file->store("uploads/{$user->id}", 'local');

if (! is_string($path) || $path === '') {
throw FileStorageException::cannotStore($file->getClientOriginalName());
}

try {
$size = $disk->size($path);
$absolutePath = $disk->path($path);
$checksum = is_string($absolutePath) && $absolutePath !== ''
? hash_file('sha256', $absolutePath)
: false;

if ($size === false || $checksum === false) {
throw FileStorageException::cannotStore($file->getClientOriginalName());
}

return $this->recordCreator->create([
'user_id' => $user->id,
'original_name' => $file->getClientOriginalName(),
'stored_path' => $path,
'mime_type' => $file->getMimeType(),
'extension' => $format,
'size_bytes' => $size,
'checksum' => $checksum,
'metadata_json' => $metadata,
'status' => FileStatus::Analyzed,
'expires_at' => $this->expirationPolicy->forUploadedFile($user),
]);
} catch (Throwable $e) {
$disk->delete($path);

if ($e instanceof FileStorageException) {
throw $e;
}

throw FileStorageException::cannotCreateRecord($file->getClientOriginalName(), previous: $e);
}
}
}
12 changes: 12 additions & 0 deletions app/Enums/FileStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace App\Enums;

enum FileStatus: string
{
case Uploaded = 'uploaded';
case Analyzed = 'analyzed';
case Failed = 'failed';
case Expired = 'expired';
case Deleted = 'deleted';
}
13 changes: 13 additions & 0 deletions app/Exceptions/Files/FileMetadataException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\Exceptions\Files;

use RuntimeException;

final class FileMetadataException extends RuntimeException
{
public static function cannotReadImage(string $filename): self
{
return new self("Cannot read image metadata for file: {$filename}");
}
}
19 changes: 19 additions & 0 deletions app/Exceptions/Files/FileStorageException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Exceptions\Files;

use RuntimeException;
use Throwable;

final class FileStorageException extends RuntimeException
{
public static function cannotStore(string $filename): self
{
return new self("Cannot store uploaded file: {$filename}");
}

public static function cannotCreateRecord(string $filename, ?Throwable $previous = null): self
{
return new self("Cannot persist file record for uploaded file: {$filename}", previous: $previous);
}
}
15 changes: 15 additions & 0 deletions app/Exceptions/Files/UnsupportedFileFormatException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\Exceptions\Files;

use DomainException;

final class UnsupportedFileFormatException extends DomainException
{
public static function forFile(string $filename, ?string $mime = null): self
{
$hint = $mime !== null && $mime !== '' ? " ({$mime})" : '';

return new self("Unsupported uploaded file format: {$filename}{$hint}");
}
}
51 changes: 51 additions & 0 deletions app/Models/FileRecord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace App\Models;

use App\Enums\FileStatus;
use Database\Factories\FileRecordFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable([
'user_id',
'original_name',
'stored_path',
'mime_type',
'extension',
'size_bytes',
'checksum',
'metadata_json',
'status',
'expires_at',
])]
class FileRecord extends Model
{
/** @use HasFactory<FileRecordFactory> */
use HasFactory;

protected $table = 'files';

protected function casts(): array
{
return [
'metadata_json' => 'array',
'status' => FileStatus::class,
'expires_at' => 'datetime',
'size_bytes' => 'integer',
];
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

public function scopeForUser(Builder $query, User $user): Builder
{
return $query->where('user_id', $user->id);
}
}
25 changes: 25 additions & 0 deletions app/Support/Files/FileExpirationPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Support\Files;

use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Date;

final class FileExpirationPolicy
{
public function forUploadedFile(User $user): CarbonInterface
{
return $this->calculateExpiration($user);
}

public function forResultFile(User $user): CarbonInterface
{
return $this->calculateExpiration($user);
}

private function calculateExpiration(User $user): CarbonInterface
{
return Date::now()->addDay();
}
}
41 changes: 41 additions & 0 deletions app/Support/Files/FileFormatDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace App\Support\Files;

use App\Exceptions\Files\UnsupportedFileFormatException;
use Illuminate\Http\UploadedFile;

final class FileFormatDetector
{
private const MIME_TO_FORMAT = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];

private const EXTENSION_TO_FORMAT = [
'png' => 'png',
'jpg' => 'jpg',
'jpeg' => 'jpg',
'webp' => 'webp',
'pdf' => 'pdf',
];

public function detect(UploadedFile $file): string
{
$mime = $file->getMimeType();

if ($mime !== null && isset(self::MIME_TO_FORMAT[$mime])) {
return self::MIME_TO_FORMAT[$mime];
}

$extension = strtolower((string) $file->getClientOriginalExtension());

if ($extension !== '' && isset(self::EXTENSION_TO_FORMAT[$extension])) {
return self::EXTENSION_TO_FORMAT[$extension];
}

throw UnsupportedFileFormatException::forFile($file->getClientOriginalName(), $mime);
}
}
16 changes: 16 additions & 0 deletions app/Support/Files/FileRecordCreator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace App\Support\Files;

use App\Models\FileRecord;

class FileRecordCreator
{
/**
* @param array<string, mixed> $attributes
*/
public function create(array $attributes): FileRecord
{
return FileRecord::query()->create($attributes);
}
}
42 changes: 42 additions & 0 deletions app/Support/Files/ImageMetadataExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Support\Files;

use App\Exceptions\Files\FileMetadataException;
use Illuminate\Http\UploadedFile;

final class ImageMetadataExtractor
{
private const IMAGE_FORMATS = ['png', 'jpg', 'webp'];

public function extract(UploadedFile $file, string $format): array
{
if (! in_array($format, self::IMAGE_FORMATS, true)) {
return [];
}

$path = $file->getRealPath();

if ($path === false) {
throw FileMetadataException::cannotReadImage($file->getClientOriginalName());
}

$size = @getimagesize($path);

if ($size === false) {
throw FileMetadataException::cannotReadImage($file->getClientOriginalName());
}

return [
'width' => $size[0],
'height' => $size[1],
'supports_transparency' => $this->formatSupportsTransparency($format),
'orientation' => null,
];
}

private function formatSupportsTransparency(string $format): bool
{
return $format !== 'jpg';
}
}
21 changes: 21 additions & 0 deletions app/Support/Files/UploadedFileRules.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Support\Files;

final class UploadedFileRules
{
public const MAX_FILE_KILOBYTES = 10240;

/**
* @return array<int, string>
*/
public static function rules(): array
{
return [
'required',
'file',
'max:'.self::MAX_FILE_KILOBYTES,
'mimes:png,jpg,jpeg,webp,pdf',
];
}
}
40 changes: 40 additions & 0 deletions database/factories/FileRecordFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Database\Factories;

use App\Enums\FileStatus;
use App\Models\FileRecord;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<FileRecord>
*/
class FileRecordFactory extends Factory
{
protected $model = FileRecord::class;

public function definition(): array
{
$extension = fake()->randomElement(['png', 'jpg', 'webp', 'pdf']);
$mime = match ($extension) {
'png' => 'image/png',
'jpg' => 'image/jpeg',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
};

return [
'user_id' => User::factory(),
'original_name' => fake()->slug().'.'.$extension,
'stored_path' => 'uploads/'.fake()->uuid().'.'.$extension,
'mime_type' => $mime,
'extension' => $extension,
'size_bytes' => fake()->numberBetween(1000, 5_000_000),
'checksum' => hash('sha256', fake()->uuid()),
'metadata_json' => [],
'status' => FileStatus::Uploaded,
'expires_at' => now()->addDay(),
];
}
}
Loading
Loading