mirror of https://github.com/pixelfed/pixelfed
Track media storage quota via an explicit lifecycle enum
Charge the raw upload size immediately (enforce on raw, never under-count), correct down to the optimized size in the finalize job, and refund on delete. Each transition is guarded by media.quota_status so retries can't double-apply.fix/media-storage-optimized-charge
parent
be65cce84a
commit
252dea7016
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Lifecycle of a media row's contribution to its owner's storage quota
|
||||
* (users.storage_used).
|
||||
*
|
||||
* The amount reflected in the quota changes over the media's life:
|
||||
*
|
||||
* Pending Uploaded and enforced against the quota on its raw size, but
|
||||
* nothing has been added to storage_used yet.
|
||||
* OriginalSize The raw upload size has been added to storage_used. This is
|
||||
* charged synchronously at upload so the quota never
|
||||
* under-counts while optimization is still queued.
|
||||
* OptimizedSize The async finalize job has optimized the file and corrected
|
||||
* the quota down by (original_size - size), so storage_used now
|
||||
* reflects the optimized on-disk footprint.
|
||||
* Subtracted The media was deleted and whatever it still reflected was
|
||||
* refunded to storage_used.
|
||||
*
|
||||
* Each transition is guarded by the current status so retries and overlapping
|
||||
* jobs cannot double-apply a delta.
|
||||
*/
|
||||
enum MediaQuotaStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
|
||||
case OriginalSize = 'original_size';
|
||||
|
||||
case OptimizedSize = 'optimized_size';
|
||||
|
||||
case Subtracted = 'subtracted';
|
||||
|
||||
/**
|
||||
* Whether this status means bytes are currently reflected in storage_used
|
||||
* (and therefore a delete must refund them).
|
||||
*/
|
||||
public function isCharged(): bool
|
||||
{
|
||||
return $this === self::OriginalSize || $this === self::OptimizedSize;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\MediaQuotaStatus;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Storage-accounting columns for the media quota lifecycle.
|
||||
*
|
||||
* `original_size` records the raw uploaded byte count. Upload-time quota
|
||||
* enforcement is based on the raw size (a user cannot upload an original
|
||||
* larger than their remaining quota).
|
||||
*
|
||||
* `quota_status` tracks how much of the media is currently reflected in the
|
||||
* owner's users.storage_used counter (see App\Enums\MediaQuotaStatus). The
|
||||
* quota is charged the raw size at upload, corrected down to the optimized
|
||||
* size by the async finalize job, and refunded on delete. Each transition
|
||||
* is guarded by this status so retries cannot double-apply a delta.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// New uploads begin their quota lifecycle at pending.
|
||||
Schema::table('media', function (Blueprint $table) {
|
||||
$table->unsignedInteger('original_size')->nullable()->after('size');
|
||||
$table->string('quota_status', 20)
|
||||
->default(MediaQuotaStatus::Pending->value)
|
||||
->index()
|
||||
->after('original_size');
|
||||
});
|
||||
|
||||
// Existing rows were already counted at their (optimized) `size` under
|
||||
// the previous scheme, so mark them optimized_size to keep the delete
|
||||
// refund correct. Skipped for empty tables (fresh installs).
|
||||
DB::table('media')->update(['quota_status' => MediaQuotaStatus::OptimizedSize->value]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('media', function (Blueprint $table) {
|
||||
$table->dropColumn(['original_size', 'quota_status']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\MediaQuotaStatus;
|
||||
use App\Models\Media;
|
||||
use App\Models\User;
|
||||
use App\Services\UserStorageService;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Media storage accounting: enforce on raw, charge raw, correct to optimized
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Upload-time quota enforcement uses the RAW upload size (a user cannot upload
|
||||
| an original larger than their remaining quota). The raw size is charged to
|
||||
| storage_used immediately at upload (quota_status = OriginalSize) so the quota
|
||||
| never under-counts while optimization is queued, then the async finalize job
|
||||
| corrects it down to the optimized media.size (quota_status = OptimizedSize).
|
||||
|
|
||||
*/
|
||||
|
||||
it('rejects an upload when the raw size exceeds the remaining quota', function () {
|
||||
Storage::fake(config('filesystems.default'));
|
||||
config([
|
||||
'pixelfed.enforce_account_limit' => true,
|
||||
'pixelfed.max_account_size' => 1000, // 1000 KB cap
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
|
||||
// Already near the cap.
|
||||
$user->storage_used = 990;
|
||||
$user->storage_used_updated_at = now();
|
||||
$user->save();
|
||||
|
||||
// A ~1.4 MB raw image pushes the raw-size projection over the cap.
|
||||
$file = UploadedFile::fake()->image('big.jpg', 4000, 4000)->size(1400);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/api/compose/v0/media/upload', ['file' => $file])
|
||||
->assertStatus(403);
|
||||
|
||||
// Nothing charged: the counter is unchanged and no media row persisted.
|
||||
$user->refresh();
|
||||
expect((int) $user->storage_used)->toBe(990);
|
||||
expect(Media::where('user_id', $user->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('charges the raw size at upload and marks the media OriginalSize', function () {
|
||||
Storage::fake(config('filesystems.default'));
|
||||
config([
|
||||
'pixelfed.enforce_account_limit' => true,
|
||||
'pixelfed.max_account_size' => 1000000,
|
||||
]);
|
||||
|
||||
// Don't run the finalize jobs; assert the upload (controller) half only.
|
||||
Bus::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
|
||||
$user->storage_used = 100;
|
||||
$user->storage_used_updated_at = now();
|
||||
$user->save();
|
||||
|
||||
$file = UploadedFile::fake()->image('ok.jpg', 1080, 1080);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/api/compose/v0/media/upload', ['file' => $file])
|
||||
->assertOk();
|
||||
|
||||
$media = Media::where('user_id', $user->id)->first();
|
||||
|
||||
expect($media)->not->toBeNull()
|
||||
->and((int) $media->original_size)->toBeGreaterThan(0)
|
||||
->and($media->quota_status)->toBe(MediaQuotaStatus::OriginalSize);
|
||||
|
||||
// storage_used grew by the raw upload size immediately (never under-counts).
|
||||
$expected = 100 + (int) ceil($media->original_size / 1000);
|
||||
$user->refresh();
|
||||
expect((int) $user->storage_used)->toBe($expected);
|
||||
});
|
||||
|
||||
it('corrects the quota down to the optimized size when the finalize job runs', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
|
||||
$user->storage_used = 0;
|
||||
$user->storage_used_updated_at = now();
|
||||
$user->save();
|
||||
|
||||
// Uploaded (raw 900 KB) and charged at upload.
|
||||
$media = Media::create([
|
||||
'status_id' => null,
|
||||
'profile_id' => $user->profile->id,
|
||||
'user_id' => $user->id,
|
||||
'media_path' => 'public/m/_v2/1/final.jpeg',
|
||||
'mime' => 'image/jpeg',
|
||||
'size' => 900000,
|
||||
'original_size' => 900000,
|
||||
'quota_status' => MediaQuotaStatus::Pending,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
UserStorageService::chargeOriginal($media);
|
||||
$user->refresh();
|
||||
expect((int) $user->storage_used)->toBe(900);
|
||||
|
||||
// Finalize job optimizes to 320 KB then corrects the quota.
|
||||
$media->size = 320000;
|
||||
$media->save();
|
||||
UserStorageService::chargeOptimized($media->fresh());
|
||||
|
||||
$user->refresh();
|
||||
expect((int) $user->storage_used)->toBe(320)
|
||||
->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::OptimizedSize);
|
||||
});
|
||||
Loading…
Reference in New Issue