Compare commits

..

3 Commits

Author SHA1 Message Date
Nyan Lin Paing 4f0f20659d Return EvCompany logo as a full URL in the API resource
PHP Tests / php-tests (push) Has been cancelled
EvCompanyResource returned the raw disk-relative path stored by
Filament's FileUpload (e.g. "logos/xxx.png"), not something API
consumers can render directly.

- EvCompany::logoUrl() builds an absolute URL from the configured
  filesystem disk, guarding against a disk (e.g. s3) that already
  returns an absolute URL so it isn't double-prefixed.
- EvCompanyResource now exposes that as 'logo' instead of the raw path.
2026-08-23 23:59:13 +07:00
Nyan Lin Paing 98dacef556 Fix asset URLs generated as http:// behind staging's reverse proxy
PHP Tests / php-tests (push) Has been cancelled
Staging terminates SSL at a reverse proxy in front of the app, but
Laravel had no trustProxies() configured, so it never saw the request
as HTTPS and generated http:// asset URLs on the https:// page.
Browsers block that as mixed content, which silently broke every
JS-enhanced Filament field (FileUpload, Textarea, etc.) — e.g. the
Ev Company logo field falling back to a bare native file input.

- bootstrap/app.php: trust the proxy via X-Forwarded-* headers.
- AppServiceProvider: force the https scheme when APP_URL is https,
  as a fallback in case the forwarded header is ever missing.
2026-08-23 23:19:44 +07:00
Nyan Lin Paing da6d51b7b2 chmod storage and bootstrap/cache to 775
PHP Tests / php-tests (push) Has been cancelled
2026-08-23 23:02:41 +07:00
16 changed files with 72 additions and 1 deletions
@@ -23,7 +23,7 @@ class EvCompanyResource extends JsonResource
'mm_description' => $this->mm_description, 'mm_description' => $this->mm_description,
'contact' => $this->contact, 'contact' => $this->contact,
'address' => $this->address, 'address' => $this->address,
'logo' => $this->logo, 'logo' => $this->logo_url,
]; ];
} }
} }
@@ -2,8 +2,10 @@
namespace Modules\Catalog\Models; namespace Modules\Catalog\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Modules\Catalog\Database\Factories\EvCompanyFactory; use Modules\Catalog\Database\Factories\EvCompanyFactory;
use Spatie\Activitylog\Models\Concerns\LogsActivity; use Spatie\Activitylog\Models\Concerns\LogsActivity;
@@ -72,4 +74,26 @@ class EvCompany extends Model
'is_active' => 'boolean', 'is_active' => 'boolean',
]; ];
} }
/**
* `logo` is stored as the disk-relative path Filament's FileUpload
* writes (e.g. "logos/xxx.png"), not a URL API consumers need a full
* absolute URL to render it directly. Guards against the disk itself
* already returning an absolute URL (e.g. an s3 disk), so this stays
* correct if the storage disk ever changes from local.
*/
public function logoUrl(): Attribute
{
return Attribute::make(
get: function (): ?string {
if (blank($this->logo)) {
return null;
}
$url = Storage::disk(config('filesystems.default'))->url($this->logo);
return str($url)->startsWith(['http://', 'https://']) ? $url : url($url);
},
);
}
} }
@@ -1,6 +1,7 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Modules\Catalog\Models\Destination; use Modules\Catalog\Models\Destination;
use Modules\Catalog\Models\EvCompany; use Modules\Catalog\Models\EvCompany;
@@ -19,6 +20,24 @@ test('lists active ev companies', function () {
->assertJsonFragment(['id' => $active->id]); ->assertJsonFragment(['id' => $active->id]);
}); });
test('returns the company logo as a full absolute url', function () {
$company = EvCompany::factory()->create(['is_active' => true, 'logo' => 'logos/example.png']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/companies')
->assertSuccessful()
->assertJsonFragment(['logo' => url(Storage::disk(config('filesystems.default'))->url($company->logo))]);
});
test('returns a null logo when the company has none', function () {
EvCompany::factory()->create(['is_active' => true, 'logo' => null]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/companies')
->assertSuccessful()
->assertJsonFragment(['logo' => null]);
});
test('lists active destinations', function () { test('lists active destinations', function () {
$active = Destination::factory()->create(['is_active' => true]); $active = Destination::factory()->create(['is_active' => true]);
Destination::factory()->create(['is_active' => false]); Destination::factory()->create(['is_active' => false]);
+11
View File
@@ -5,6 +5,7 @@ namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -23,6 +24,16 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
$this->configureRateLimiting(); $this->configureRateLimiting();
// Belt-and-suspenders alongside bootstrap/app.php's trustProxies():
// that already makes url()/asset() respect the proxy's
// X-Forwarded-Proto, but if that header is ever missing or a proxy
// is misconfigured, this still forces https:// asset/route URLs on
// any environment whose APP_URL is itself https — so a plain-http
// request never causes a mixed-content-blocked asset again.
if (str(config('app.url'))->startsWith('https://')) {
URL::forceScheme('https');
}
} }
/** /**
+17
View File
@@ -11,6 +11,7 @@ use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Modules\Identity\Http\Middleware\AuthenticateSanctumOrFastApiJwt; use Modules\Identity\Http\Middleware\AuthenticateSanctumOrFastApiJwt;
use Modules\Identity\Http\Middleware\EnsureFastApiAgent; use Modules\Identity\Http\Middleware\EnsureFastApiAgent;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -24,6 +25,22 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// Staging/production sit behind a reverse proxy/load balancer that
// terminates SSL — without this, Laravel never sees the original
// request as HTTPS, so it generates http:// asset URLs, which
// browsers then block as mixed content on the https:// page (e.g.
// Filament's file-upload.js failing to load, breaking that field's
// JS-enhanced dropzone). Trusting '*' is the standard Laravel
// pattern when the proxy's IP isn't fixed/known in advance.
$middleware->trustProxies(
at: '*',
headers: SymfonyRequest::HEADER_X_FORWARDED_FOR
| SymfonyRequest::HEADER_X_FORWARDED_HOST
| SymfonyRequest::HEADER_X_FORWARDED_PORT
| SymfonyRequest::HEADER_X_FORWARDED_PROTO
| SymfonyRequest::HEADER_X_FORWARDED_AWS_ELB,
);
$middleware->alias([ $middleware->alias([
'fastapi.agent' => EnsureFastApiAgent::class, 'fastapi.agent' => EnsureFastApiAgent::class,
'api.auth' => AuthenticateSanctumOrFastApiJwt::class, 'api.auth' => AuthenticateSanctumOrFastApiJwt::class,
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
Regular → Executable
View File