Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a609138
CONV-330: Create FileRetentionPolicy service
menvil Jun 3, 2026
d9b0c65
Merge feature/CONV-330-create-file-retention-policy-service
menvil Jun 3, 2026
b0867c0
CONV-331: Test upload file expiration by plan
menvil Jun 3, 2026
b742184
Merge feature/CONV-331-test-upload-file-expiration-by-plan
menvil Jun 3, 2026
a5ef100
CONV-332: Apply expiration to uploaded files
menvil Jun 3, 2026
ed0d708
Merge feature/CONV-332-apply-expiration-to-uploaded-files
menvil Jun 3, 2026
8077a59
CONV-333: Test result file expiration by plan
menvil Jun 3, 2026
ff9adbb
Merge feature/CONV-333-test-result-file-expiration-by-plan
menvil Jun 3, 2026
0e32432
CONV-334: Apply expiration to conversion result files
menvil Jun 3, 2026
ae1fd79
Merge feature/CONV-334-apply-expiration-to-conversion-result-files
menvil Jun 3, 2026
52346c9
CONV-335: Add expired file status handling
menvil Jun 3, 2026
355f280
Merge feature/CONV-335-add-expired-file-status-handling
menvil Jun 3, 2026
16f085a
CONV-336: Create CleanupExpiredFilesJob skeleton
menvil Jun 3, 2026
e966b7c
Merge feature/CONV-336-create-cleanup-expired-files-job-skeleton
menvil Jun 3, 2026
dd30db5
CONV-337: Test cleanup deletes expired physical files
menvil Jun 3, 2026
bef7f65
Merge feature/CONV-337-test-cleanup-deletes-expired-physical-files
menvil Jun 3, 2026
c266318
CONV-338: Implement expired physical file deletion
menvil Jun 3, 2026
fade7d7
Merge feature/CONV-338-implement-expired-physical-file-deletion
menvil Jun 3, 2026
7121ea8
CONV-339: Test cleanup marks file records expired
menvil Jun 3, 2026
77ffa0b
Merge feature/CONV-339-test-cleanup-marks-file-records-expired
menvil Jun 3, 2026
a786632
CONV-340: Implement expired file record marking
menvil Jun 3, 2026
fdeb463
Merge feature/CONV-340-implement-expired-file-record-marking
menvil Jun 3, 2026
cc1366c
CONV-341: Add manual cleanup command
menvil Jun 3, 2026
2ffb7d3
Merge feature/CONV-341-add-manual-cleanup-command
menvil Jun 3, 2026
59d071a
CONV-342: Schedule expired files cleanup
menvil Jun 3, 2026
b31d1d5
Merge feature/CONV-342-schedule-expired-files-cleanup
menvil Jun 3, 2026
f95d3f2
CONV-343: Block expired web downloads
menvil Jun 3, 2026
8d11a3f
Merge feature/CONV-343-block-expired-web-downloads
menvil Jun 3, 2026
bea4aaf
CONV-344: Block expired API downloads
menvil Jun 3, 2026
4cf8592
Merge feature/CONV-344-block-expired-api-downloads
menvil Jun 3, 2026
5340e58
CONV-345: Show retention and expiration info in UI
menvil Jun 4, 2026
9e5a67e
Merge feature/CONV-345-show-retention-and-expiration-info-in-ui
menvil Jun 4, 2026
6048196
CONV-346: Add cleanup and retention final smoke tests
menvil Jun 4, 2026
b495fd5
Merge feature/CONV-346-add-cleanup-and-retention-final-smoke-tests
menvil Jun 4, 2026
6297662
Fix cleanup disk hardcoded to local and Carbon test time leak
menvil Jun 4, 2026
5bc372f
Fix review findings: command messaging, job deletion guard, retention…
menvil Jun 4, 2026
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
28 changes: 28 additions & 0 deletions app/Console/Commands/CleanupExpiredFilesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\CleanupExpiredFilesJob;
use Illuminate\Console\Command;

final class CleanupExpiredFilesCommand extends Command
{
protected $signature = 'files:cleanup-expired {--sync : Run cleanup synchronously instead of dispatching a queue job}';

protected $description = 'Delete expired physical files and mark their records as expired';

public function handle(): int
{
if ($this->option('sync')) {
app(CleanupExpiredFilesJob::class)->handle();
$this->info('Expired files cleanup completed.');
} else {
CleanupExpiredFilesJob::dispatch();
$this->info('Expired files cleanup queued.');
}

return self::SUCCESS;
}
}
15 changes: 15 additions & 0 deletions app/Exceptions/Files/InvalidRetentionPolicyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Files;

use RuntimeException;

final class InvalidRetentionPolicyException extends RuntimeException
{
public static function forInvalidDays(int|string|null $raw): self
{
return new self('Retention days must be a positive integer, got ['.var_export($raw, true).'].');
}
}
18 changes: 15 additions & 3 deletions app/Http/Controllers/Api/V1/ConversionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public function download(Request $request, int $conversionId): mixed

$resultFile = $conversion->resultFile;

if ($resultFile === null || ! Storage::disk('local')->exists($resultFile->stored_path)) {
if ($resultFile === null) {
return response()->json([
'error' => [
'code' => 'result_not_found',
Expand All @@ -98,12 +98,24 @@ public function download(Request $request, int $conversionId): mixed
return response()->json([
'error' => [
'code' => 'result_expired',
'message' => 'Conversion result has expired.',
'details' => [],
'message' => 'This conversion result has expired and can no longer be downloaded.',
'details' => [
'expired_at' => $resultFile->expires_at?->toIso8601String(),
],
],
], 410);
}

if (! Storage::disk('local')->exists($resultFile->stored_path)) {
return response()->json([
'error' => [
'code' => 'result_not_found',
'message' => 'Conversion result file not found.',
'details' => [],
],
], 404);
}

return Storage::disk('local')->download(
$resultFile->stored_path,
$resultFile->original_name,
Expand Down
47 changes: 47 additions & 0 deletions app/Jobs/CleanupExpiredFilesJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Enums\FileStatus;
use App\Models\FileRecord;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

final class CleanupExpiredFilesJob implements ShouldQueue
{
use Queueable;

public function handle(): void
{
FileRecord::query()
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->whereNotIn('status', [FileStatus::Expired, FileStatus::Deleted])
->chunkById(100, function ($files): void {
foreach ($files as $file) {
if ($file->stored_path) {
$exists = Storage::disk('local')->exists($file->stored_path);

if ($exists) {
$deleted = Storage::disk('local')->delete($file->stored_path);

if (! $deleted) {
Log::warning('CleanupExpiredFilesJob: failed to delete file', [
'file_id' => $file->id,
'path' => $file->stored_path,
]);

continue;
}
}
}

$file->forceFill(['status' => FileStatus::Expired])->save();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
});
}
}
23 changes: 22 additions & 1 deletion app/Livewire/RecentConversionsTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,28 @@ public function convertAgain(int $conversionJobId): void

public function canDownload(ConversionJob $job): bool
{
return $job->isCompleted() && $job->result_file_id !== null;
return $job->isCompleted()
&& $job->result_file_id !== null
&& $job->resultFile !== null
&& ! $job->resultFile->isExpired();
}

/**
* @return array{label: string, class: string}|null
*/
public function expirationMetaFor(ConversionJob $job): ?array
{
$file = $job->resultFile;

if ($file === null || $file->expires_at === null) {
return null;
}

if ($file->isExpired()) {
return ['label' => 'Expired', 'class' => 'text-red-500'];
}

return ['label' => 'Expires '.$file->expires_at->diffForHumans(), 'class' => 'text-[var(--ca-muted)]'];
}

public function canConvertAgain(ConversionJob $job): bool
Expand Down
3 changes: 2 additions & 1 deletion app/Models/FileRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ protected function casts(): array

public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
return $this->status === FileStatus::Expired
|| ($this->expires_at !== null && $this->expires_at->isPast());
}

public function user(): BelongsTo
Expand Down
31 changes: 31 additions & 0 deletions app/Services/Files/FileRetentionPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Services\Files;

use App\Exceptions\Files\InvalidRetentionPolicyException;
use App\Models\User;
use App\Services\FeatureAccess\FeatureAccessService;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Date;

final class FileRetentionPolicy
{
public function __construct(
private readonly FeatureAccessService $features,
) {}

public function expiresAtFor(User $user, ?CarbonInterface $from = null): CarbonInterface
{
$raw = $this->features->limit($user, 'retention_days');

$validated = filter_var($raw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);

if ($validated === false) {
throw InvalidRetentionPolicyException::forInvalidDays($raw);
}

return ($from ?? Date::now())->copy()->addDays($validated);
}
}
12 changes: 12 additions & 0 deletions resources/views/errors/410.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>File Expired</title>
</head>
<body>
<h1>File Expired</h1>
<p>This file has expired and was deleted. Upload the source file again to convert it.</p>
</body>
</html>
6 changes: 6 additions & 0 deletions resources/views/livewire/recent-conversions-table.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white p
</span>
</td>
<td class="px-4 py-3">
@php $expirationMeta = $this->expirationMetaFor($job); @endphp
@if ($expirationMeta !== null)
<div class="mb-1 text-xs {{ $expirationMeta['class'] }}">
{{ $expirationMeta['label'] }}
</div>
@endif
<div class="flex items-center gap-2">
@if ($this->canDownload($job))
<a
Expand Down
3 changes: 3 additions & 0 deletions routes/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::command('files:cleanup-expired')->hourly()->withoutOverlapping();
48 changes: 48 additions & 0 deletions tests/Feature/Api/V1/ConversionDownloadEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use App\Enums\FileStatus;
use App\Enums\Plan;
use App\Models\ConversionJob;
use App\Models\FileRecord;
Expand Down Expand Up @@ -45,6 +46,53 @@
->assertJsonPath('error.code', 'result_not_ready');
});

// CONV-344: Block expired API downloads

it('returns result_expired error for expired api download', function () {
Storage::fake('local');

$user = User::factory()->create(['plan' => Plan::Pro]);
$token = app(ApiKeyGenerator::class)->create($user, 'API')->plainToken;

$resultFile = FileRecord::factory()->for($user)->create([
'stored_path' => 'results/expired.jpg',
'expires_at' => now()->subDay(),
]);

$job = ConversionJob::factory()->for($user)->completed()->create([
'result_file_id' => $resultFile->id,
]);

$this->withToken($token)
->getJson("/api/v1/conversions/{$job->id}/download")
->assertStatus(410)
->assertJsonPath('error.code', 'result_expired')
->assertJsonPath('error.details.expired_at', $resultFile->expires_at->toIso8601String());
});

it('returns result_expired error for file with expired status', function () {
Storage::fake('local');

$user = User::factory()->create(['plan' => Plan::Pro]);
$token = app(ApiKeyGenerator::class)->create($user, 'API')->plainToken;

$resultFile = FileRecord::factory()->for($user)->create([
'stored_path' => 'results/expired.jpg',
'status' => FileStatus::Expired,
'expires_at' => now()->addDay(),
]);

$job = ConversionJob::factory()->for($user)->completed()->create([
'result_file_id' => $resultFile->id,
]);

$this->withToken($token)
->getJson("/api/v1/conversions/{$job->id}/download")
->assertStatus(410)
->assertJsonPath('error.code', 'result_expired')
->assertJsonStructure(['error' => ['details' => ['expired_at']]]);
});

it('does not allow another user to download conversion result', function () {
$owner = User::factory()->create(['plan' => Plan::Pro]);
$other = User::factory()->create(['plan' => Plan::Pro]);
Expand Down
26 changes: 26 additions & 0 deletions tests/Feature/Console/CleanupExpiredFilesCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

use App\Jobs\CleanupExpiredFilesJob;
use Illuminate\Support\Facades\Bus;

it('dispatches the cleanup job when run without --sync', function () {
Bus::fake();

$this->artisan('files:cleanup-expired')
->expectsOutputToContain('Expired files cleanup queued')
->assertExitCode(0);

Bus::assertDispatched(CleanupExpiredFilesJob::class);
});

it('runs cleanup synchronously with --sync flag and does not dispatch', function () {
Bus::fake();

$this->artisan('files:cleanup-expired --sync')
->expectsOutputToContain('Expired files cleanup completed')
->assertExitCode(0);

Bus::assertNotDispatched(CleanupExpiredFilesJob::class);
});
24 changes: 24 additions & 0 deletions tests/Feature/ConversionDownloadTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use App\Enums\FileStatus;
use App\Models\ConversionJob;
use App\Models\FileRecord;
use App\Models\User;
Expand Down Expand Up @@ -71,6 +72,8 @@
->assertNotFound();
});

// CONV-343: Block expired web downloads

it('does not allow downloading an expired result', function () {
Storage::fake('local');

Expand All @@ -87,6 +90,27 @@
'result_file_id' => $resultFile->id,
]);

$this->actingAs($user)
->get(route('conversions.download', $job))
->assertStatus(410)
->assertSee('expired');
});

it('does not allow downloading a result with expired status', function () {
Storage::fake('local');

$user = User::factory()->create();

$resultFile = FileRecord::factory()->for($user)->create([
'stored_path' => 'conversions/result.jpg',
'status' => FileStatus::Expired,
'expires_at' => now()->addDay(),
]);

$job = ConversionJob::factory()->for($user)->completed()->create([
'result_file_id' => $resultFile->id,
]);

$this->actingAs($user)
->get(route('conversions.download', $job))
->assertStatus(410);
Expand Down
Loading
Loading