Commit 6b1c1297 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(uploads): one 150 MB ceiling, agreed on by every layer that can refuse

A file crossing this system passes four size checks and they disagreed:
the component's rules said 5 MB, Livewire's undeclared default said 12,
PHP said 20, nginx said 25. Whichever was smallest won, with a message
written by whoever owned that layer — and nginx's refusal is a 413 error
page, not something a receptionist can act on.

config/uploads.php holds the number now, and the four layers are set from
it in the right order: nginx (160M) is the most generous so it never
refuses first, then post_max_size (160M) above upload_max_filesize (150M)
so a file at the limit is rejected as a file rather than as a malformed
request, then Livewire's temporary-upload rule, then the component. The
size named in each Arabic error message is interpolated from the same
config instead of retyped, because the old messages said "5 ميجابايت"
while the rule said something else.

Time limits went with it: max_input_time is what cuts off a body still
arriving, and 150 MB over Egyptian mobile data is minutes, so it and
nginx's client_body_timeout go to 300s and Livewire's max_upload_time to
30 minutes. Pictures keep their own small ceilings — a logo is carried on
every page load.

And the settlement worklist stops treating the running month as a problem.
Late now means a month that has ENDED and was not collected — read from
the month the invoice names, not from its due date — because a club
collects all month and does not consider a player a problem on the 9th.
A card being paid on an agreed plan whose next instalment has not come due
is not an anomaly either. Together: a player who owes only this month and
whose bundled product is bought or paid up to date does not appear at all,
which is the whole point of the screen. An unpaid bundled product goes
back to standing on its own, since that is exactly what it exists to find.

Verified on the restored tenant: 24 settlement cases including the new
month rule (last month unpaid flags, this month never does, whatever the
due date), full suite 334 tests on both connections.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent d396f784
......@@ -99,7 +99,9 @@ class SystemSettings extends Component
'medical_certificate_expiry_required' => ['type' => 'boolean', 'label' => 'إلزام تاريخ انتهاء الشهادة عند الموافقة'],
'medical_certificate_max_age_months' => ['type' => 'number', 'label' => 'أقصى مدة صلاحية الشهادة الطبية (شهور)', 'step' => '1', 'min' => 1, 'max' => 60],
'block_attendance_without_medical' => ['type' => 'boolean', 'label' => 'منع الحضور بدون شهادة طبية سارية'],
'max_document_size_mb' => ['type' => 'number', 'label' => 'الحد الأقصى لحجم الملف (ميجابايت)', 'step' => '1', 'min' => 1, 'max' => 50],
// 150 = the platform ceiling in config/uploads.php (max_kb / 1024).
// A literal because a property initialiser cannot call config().
'max_document_size_mb' => ['type' => 'number', 'label' => 'الحد الأقصى لحجم الملف (ميجابايت)', 'step' => '1', 'min' => 1, 'max' => 150],
],
];
......
......@@ -45,7 +45,7 @@ public function rules(): array
'receipt_reference' => 'nullable|string|max:100',
'expense_date' => 'required|date',
'notes' => 'nullable|string',
'attachment' => 'nullable|file|max:5120|mimes:jpg,jpeg,png,pdf,webp',
'attachment' => 'nullable|file|max:' . config('uploads.max_kb') . '|mimes:jpg,jpeg,png,pdf,webp',
];
}
......@@ -59,7 +59,7 @@ public function messages(): array
'description.max' => 'الوصف يجب ألا يتجاوز 500 حرف',
'payment_method.required' => 'اختر طريقة الدفع',
'expense_date.required' => 'تاريخ المصروف مطلوب',
'attachment.max' => 'حجم الملف لا يتجاوز 5 ميجابايت',
'attachment.max' => __('حجم الملف لا يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'attachment.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp) أو PDF',
];
}
......
......@@ -59,7 +59,7 @@ private function expense(): Expense
public function rules(): array
{
return [
'receipt' => 'required|file|max:5120|mimes:jpg,jpeg,png,pdf,webp',
'receipt' => 'required|file|max:' . config('uploads.max_kb') . '|mimes:jpg,jpeg,png,pdf,webp',
];
}
......@@ -67,7 +67,7 @@ public function messages(): array
{
return [
'receipt.required' => 'اختر ملف الإيصال أولاً',
'receipt.max' => 'حجم الملف لا يتجاوز 5 ميجابايت',
'receipt.max' => __('حجم الملف لا يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'receipt.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp) أو PDF',
];
}
......
......@@ -47,7 +47,7 @@ protected function rules(): array
// A medical certificate without an expiry is a certificate nobody
// can act on: the nightly job has nothing to compare against.
'expiresAt' => ['nullable', 'date', 'after:today'],
'file' => ['required', 'file', 'max:6144', 'mimes:png,jpg,jpeg,webp,pdf'],
'file' => ['required', 'file', 'max:' . config('uploads.max_kb'), 'mimes:png,jpg,jpeg,webp,pdf'],
];
}
......@@ -56,7 +56,7 @@ protected function messages(): array
return [
'file.required' => __('اختر الملف أولاً'),
'file.mimes' => __('الملف يجب أن يكون صورة أو PDF'),
'file.max' => __('حجم الملف يتجاوز ٦ ميجابايت'),
'file.max' => __('حجم الملف يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'expiresAt.after' => __('تاريخ الانتهاء يجب أن يكون في المستقبل'),
];
}
......
......@@ -63,7 +63,7 @@ protected function rules(): array
'transferredAt' => ['required', 'date', 'before_or_equal:today'],
// Images and PDFs only, and small enough that a phone can send it
// on a bad connection.
'proof' => ['required', 'file', 'max:4096', 'mimes:png,jpg,jpeg,webp,pdf'],
'proof' => ['required', 'file', 'max:' . config('uploads.max_kb'), 'mimes:png,jpg,jpeg,webp,pdf'],
];
}
......@@ -79,7 +79,7 @@ protected function messages(): array
'transferredAt.before_or_equal' => __('لا يمكن أن يكون تاريخ التحويل في المستقبل'),
'proof.required' => __('صورة إثبات التحويل مطلوبة'),
'proof.mimes' => __('الملف يجب أن يكون صورة أو PDF'),
'proof.max' => __('حجم الملف يتجاوز ٤ ميجابايت'),
'proof.max' => __('حجم الملف يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
];
}
......
......@@ -74,7 +74,7 @@ protected function rules(): array
->where('is_active', true)
->whereNull('deleted_at'),
],
'attachment' => ['nullable', 'file', 'max:4096', 'mimes:png,jpg,jpeg,webp,pdf'],
'attachment' => ['nullable', 'file', 'max:' . config('uploads.max_kb'), 'mimes:png,jpg,jpeg,webp,pdf'],
];
}
......@@ -85,7 +85,7 @@ protected function messages(): array
'reason.required' => __('اكتب سبب الطلب'),
'reason.min' => __('السبب قصير جداً'),
'attachment.mimes' => __('المرفق يجب أن يكون صورة أو PDF'),
'attachment.max' => __('حجم المرفق يتجاوز ٤ ميجابايت'),
'attachment.max' => __('حجم المرفق يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'branchId.exists' => __('الفرع المختار غير متاح'),
];
}
......
This diff is collapsed.
<?php
return [
/*
|--------------------------------------------------------------------------
| The ceiling on any single uploaded file
|--------------------------------------------------------------------------
|
| One number, in kilobytes, that the validation rules, Livewire's temporary
| upload check and the container's own limits all agree on. Before this the
| three disagreed — a receipt could pass `max:5120` in the rules, then be
| refused by Livewire's undeclared 12 MB default, or accepted by both and
| cut off by nginx at 25 MB with a 413 the user could not read.
|
| Raising it means raising all three: docker/php/php.ini
| (upload_max_filesize, post_max_size, max_input_time),
| docker/nginx/default.conf (client_max_body_size, client_body_timeout),
| and this file. The value here must stay BELOW post_max_size, so the file
| is rejected as a file with a message on the field rather than as an
| oversized request with an error page.
|
*/
'max_kb' => (int) env('UPLOAD_MAX_KB', 153600), // 150 MB
/*
|--------------------------------------------------------------------------
| Ceilings for things that have no business being large
|--------------------------------------------------------------------------
|
| A logo is not a video. Letting a 150 MB PNG through would be technically
| fine and practically a bug — every page load carries it.
|
*/
'image_max_kb' => (int) env('UPLOAD_IMAGE_MAX_KB', 5120), // 5 MB
'icon_max_kb' => (int) env('UPLOAD_ICON_MAX_KB', 1024), // 1 MB
];
......@@ -20,7 +20,14 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
# Max upload size
client_max_body_size 25M;
# Matches post_max_size in php.ini: nginx refuses first, and a 413 from
# nginx is an error page rather than a validation message, so it must be the
# looser of the two.
client_max_body_size 160M;
# A large upload over mobile data is slow, not stalled.
client_body_timeout 300s;
send_timeout 300s;
# Application-served files that LOOK static but are generated by PHP.
#
......@@ -89,7 +96,7 @@ server {
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
fastcgi_read_timeout 60s;
fastcgi_read_timeout 300s;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
}
......
[PHP]
; Memory & execution
;
; A 150 MB upload from a phone on Egyptian mobile data takes minutes, and the
; clock that matters is max_input_time — the time PHP allows for receiving the
; request body, not for running the script. Sixty seconds cut those uploads off
; mid-transfer with nothing in the log to explain it.
memory_limit = 256M
max_execution_time = 60
max_input_time = 60
max_execution_time = 300
max_input_time = 300
max_input_vars = 5000
; Upload
upload_max_filesize = 20M
post_max_size = 25M
;
; 150 MB is the platform ceiling. post_max_size sits above it because the POST
; carries the file plus Livewire's snapshot and the form's own fields; if the
; two were equal, a file at exactly the limit would be rejected as an oversized
; request instead of an oversized file, which is a far more confusing error.
upload_max_filesize = 150M
post_max_size = 160M
; Error handling (production)
display_errors = Off
......
......@@ -6,6 +6,7 @@
<h1 class="text-xl sm:text-2xl font-bold text-gray-900">{{ __('حالات تحتاج تسوية') }}</h1>
<p class="mt-1 text-sm text-gray-500">
{{ __('حسابات لا تتفق أرقامها مع الواقع: مال حُصِّل ولم يُسجَّل، شهور بلا فواتير، منتجات بيعت خارج السيستم.') }}
<span class="block mt-0.5">{{ __('الشهر الجاري لا يُحتسب هنا — تحصيله من شاشة «تحصيل دفعة». وكذلك الأقساط المنتظمة حتى الشهر الجاري.') }}</span>
</p>
</div>
<button type="button" wire:click="export"
......
......@@ -596,11 +596,67 @@ public function test_this_months_renewal_is_not_an_anomaly(): void
$after = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];
$this->assertSame($before['overdue'], $after['overdue'], 'A renewal that is not due yet is not late.');
$this->assertSame($before['overdue'], $after['overdue'], 'A month that is still running is never late.');
$this->assertSame($before['unpaid'] + 1, $after['unpaid'], 'It is still shown as outstanding.');
$this->assertGreaterThan(0, $invoice->id);
}
public function test_the_month_that_has_ended_is_what_makes_an_account_late(): void
{
// Same invoice, last month instead of this one. Due date is deliberately
// in the future to prove the rule is the MONTH, not the due date: a club
// collects all month, so nothing inside the current month counts, and
// everything from a month that has closed does.
$participant = Participant::withoutGlobalScopes()
->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
->firstOrFail();
$before = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];
Invoice::withoutGlobalScopes()->create([
'academy_id' => $participant->academy_id,
'branch_id' => $participant->branch_id,
'number' => 'INV-TEST-' . uniqid(),
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'subtotal_amount' => 90000,
'total_amount' => 90000,
'paid_amount' => 0,
'due_amount' => 90000,
'issue_date' => now()->subMonth()->startOfMonth()->toDateString(),
'due_date' => now()->addWeeks(2)->toDateString(),
'status' => InvoiceStatus::Sent,
'currency' => 'EGP',
'metadata' => ['month' => now()->subMonth()->format('Y-m')],
]);
$after = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];
$this->assertSame($before['overdue'] + 1, $after['overdue'], 'Last month going uncollected is the anomaly.');
}
public function test_a_card_being_paid_on_schedule_keeps_a_player_off_the_list(): void
{
// The rule the operator asked for: someone who owes only the month we
// are in, and whose bundled product is bought or paid up to date, must
// not appear at all.
$rows = app(AccountAnomalyScanner::class)->scan(limit: 500);
foreach ($rows as $row) {
foreach ($row['detail']['bundle_status'] ?? [] as $bundle) {
if ($bundle['status'] === 'paid' || $bundle['on_schedule']) {
$this->assertNotContains(
'partial_bundle',
$row['cases'],
'A card paid, or being paid on an agreed plan, is not an anomaly.'
);
}
}
}
$this->addToAssertionCount(1);
}
public function test_a_card_paid_in_a_combined_invoice_reads_as_part_paid_not_missing(): void
{
// Production participant 219: 2,500 toward the federation card, typed
......
<?php
namespace Tests\Feature;
use Tests\TestCase;
/**
* One upload ceiling, agreed on by everything in the path.
*
* A file crossing this system passes four separate size checks — nginx,
* PHP, Livewire's own temporary-upload rule, and the component's validation —
* and before this they disagreed: the rules said 5 MB, Livewire's undeclared
* default said 12 MB, nginx said 25 MB. Whichever was smallest won, with a
* message written by whoever happened to own that layer, and a 413 from nginx
* is an error page rather than something a receptionist can act on.
*
* These assertions are about the ordering that keeps the error readable:
* nginx must be the most generous, then PHP, then the application. Get it
* backwards and a file at the limit is refused as a malformed request instead
* of an oversized file.
*/
class UploadLimitsTest extends TestCase
{
private function iniBytes(string $value): int
{
$value = trim($value);
$unit = strtolower(substr($value, -1));
$number = (int) $value;
return match ($unit) {
'g' => $number * 1024 * 1024 * 1024,
'm' => $number * 1024 * 1024,
'k' => $number * 1024,
default => $number,
};
}
private function phpIni(): string
{
return file_get_contents(base_path('docker/php/php.ini'));
}
private function iniValue(string $key): string
{
preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(\S+)/m', $this->phpIni(), $m);
$this->assertNotEmpty($m, "{$key} is not set in docker/php/php.ini");
return $m[1];
}
public function test_the_application_ceiling_is_the_one_the_client_asked_for(): void
{
$this->assertSame(153600, (int) config('uploads.max_kb'), '150 MB, in kilobytes.');
}
public function test_php_accepts_a_file_at_the_ceiling(): void
{
$ceiling = ((int) config('uploads.max_kb')) * 1024;
$this->assertGreaterThanOrEqual(
$ceiling,
$this->iniBytes($this->iniValue('upload_max_filesize')),
'upload_max_filesize must not be the thing that refuses a file the app allows.'
);
}
public function test_the_whole_request_is_allowed_to_be_bigger_than_the_file(): void
{
// The POST carries the file plus Livewire's snapshot and the form
// fields. Equal limits mean a file at exactly the ceiling is rejected
// as an oversized request — a different, and far less useful, error.
$this->assertGreaterThan(
$this->iniBytes($this->iniValue('upload_max_filesize')),
$this->iniBytes($this->iniValue('post_max_size')),
'post_max_size must exceed upload_max_filesize.'
);
}
public function test_nginx_is_the_most_generous_of_the_three(): void
{
$conf = file_get_contents(base_path('docker/nginx/default.conf'));
preg_match('/client_max_body_size\s+(\S+);/', $conf, $m);
$this->assertNotEmpty($m, 'client_max_body_size is not set.');
$this->assertGreaterThanOrEqual(
$this->iniBytes($this->iniValue('post_max_size')),
$this->iniBytes(rtrim($m[1], ';')),
'nginx refuses before PHP does, and its refusal is an error page — so it must be the looser limit.'
);
}
public function test_livewire_does_not_refuse_before_the_application_does(): void
{
// Livewire validates the temporary upload before any component sees
// it. Its undeclared default is 12 MB, which silently capped every
// upload in the system regardless of the rules the component declared.
$rules = config('livewire.temporary_file_upload.rules');
$this->assertIsArray($rules, 'The temporary upload rules must be declared, not left to the package default.');
$this->assertContains(
'max:' . config('uploads.max_kb'),
$rules,
'Livewire must allow what the application allows.'
);
}
public function test_a_slow_upload_is_given_time_to_arrive(): void
{
// 150 MB over Egyptian mobile data is minutes, not seconds. The clock
// that cuts it off is max_input_time — the body being received — not
// max_execution_time.
$this->assertGreaterThanOrEqual(300, (int) $this->iniValue('max_input_time'));
$this->assertGreaterThanOrEqual(
10,
(int) config('livewire.temporary_file_upload.max_upload_time'),
'Livewire invalidates an upload still in flight after this many minutes.'
);
}
public function test_pictures_keep_a_sane_ceiling_of_their_own(): void
{
// A logo is not a video: it is carried on every page load.
$this->assertLessThan((int) config('uploads.max_kb'), (int) config('uploads.image_max_kb'));
$this->assertLessThan((int) config('uploads.image_max_kb'), (int) config('uploads.icon_max_kb'));
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment