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
3 changes: 3 additions & 0 deletions .env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
# Copy to .env next to docker-compose.yml and adjust.

APP_NAME="b10cks CMS"
# The scheme here decides the scheme of every generated URL (assets, redirects,
# mail links). Use https:// as soon as a TLS reverse proxy sits in front, even
# though the container itself keeps speaking plain HTTP.
APP_URL=http://localhost:8000
APP_PORT=8000
# Leave empty: the entrypoint generates a key on first boot and persists it on
Expand Down
5 changes: 4 additions & 1 deletion app/Http/Middleware/EnsureRevision.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ public function handle(Request $request, Closure $next)
$params['rv'] = $space->content_updated_at?->timestamp ?? $space->updated_at->timestamp;
$url = $request->path() . '?' . http_build_query($params);

// No $secure argument: it is an absolute override, not a hint, so
// passing one here pins the redirect to a scheme the instance may
// not serve. Leaving it out follows APP_URL (see AppServiceProvider).
return redirect($url, 301, [
'cache-control' => 'public, max-age=60, s-maxage=10',
'x-b10cks-version' => config('app.version'),
], app()->environment('production'));
]);
}
return $next($request);
}
Expand Down
16 changes: 12 additions & 4 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* APP_URL, not APP_ENV, decides the scheme generated URLs get. The
* self-hosted Docker stack runs APP_ENV=production over plain HTTP on
* localhost, so keying this on the environment pointed every asset URL at
* an https port nothing listens on. Behind a TLS terminating proxy the
* operator sets an https APP_URL and the forcing still applies, which also
* covers an unset TRUSTED_PROXIES, where X-Forwarded-Proto is not believed.
*
* Proxy trust itself is configured in config/trustedproxy.php and applied
* by the TrustProxies middleware; setting it here has no effect, since
* that middleware resets it on every request.
*/
public function register(): void
{
if (app()->environment('production')) {
// Proxy trust is configured in config/trustedproxy.php and applied
// by the TrustProxies middleware; setting it here has no effect,
// since that middleware resets it on every request.
if (str_starts_with(strtolower((string) config('app.url')), 'https://')) {
\URL::forceScheme('https');
}
}
Expand Down
2 changes: 2 additions & 0 deletions docs/self-hosting/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ APP_ENV=production
APP_DEBUG=false
```

The scheme in `APP_URL` decides the scheme every generated URL gets — assets, redirects, signed links, mail. Put `https://` there as soon as anything terminates TLS in front of the app, even when the app itself is reached over plain HTTP inside the network. Leave it `http://` for a local stack that speaks plain HTTP end to end, which is what the Docker default does.

## Sign-in

```bash
Expand Down
65 changes: 65 additions & 0 deletions tests/Unit/Http/Middleware/EnsureRevisionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Tests\Unit\Http\Middleware;

use App\Http\Middleware\EnsureRevision;
use App\Models\Management\Space;
use App\Providers\AppServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

/**
* The revision redirect used to pin its scheme to APP_ENV, so a plain HTTP
* self-host sent every delivery request to an https port nothing serves.
*/
#[CoversClass(EnsureRevision::class)]
class EnsureRevisionTest extends TestCase
{
private EnsureRevision $middleware;

protected function setUp(): void
{
parent::setUp();

$this->middleware = new EnsureRevision;

app()->instance('currentSpace', (new Space)->forceFill([
'content_updated_at' => now(),
'updated_at' => now(),
]));
}

private function redirectLocation(string $env, string $appUrl): string
{
$this->app['env'] = $env;
config(['app.url' => $appUrl]);

URL::forceScheme(null);
URL::setRequest(Request::create('http://localhost:8000/'));
$this->app->register(AppServiceProvider::class, force: true);

$response = $this->middleware->handle(
Request::create('http://localhost:8000/contents/home'),
fn () => response('unreachable'),
);

$this->assertSame(301, $response->getStatusCode());

return $response->getTargetUrl();
}

#[Test]
public function it_redirects_over_http_when_app_url_is_http(): void
{
$this->assertStringStartsWith('http://', $this->redirectLocation('production', 'http://localhost:8000'));
}

#[Test]
public function it_redirects_over_https_when_app_url_is_https(): void
{
$this->assertStringStartsWith('https://', $this->redirectLocation('local', 'https://cms.example.com'));
}
}
45 changes: 45 additions & 0 deletions tests/Unit/Providers/UrlSchemeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace Tests\Unit\Providers;

use App\Providers\AppServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;

/**
* The scheme URLs are generated with follows APP_URL, not APP_ENV. The
* self-hosted Docker stack runs APP_ENV=production over plain HTTP, and used
* to get https asset URLs pointing at a port nothing listens on.
*
* Both cases arrive over plain HTTP, so the assertions turn on APP_URL alone.
*/
class UrlSchemeTest extends TestCase
{
private function bootWith(string $env, string $appUrl): void
{
$this->app['env'] = $env;
config(['app.url' => $appUrl]);

URL::forceScheme(null);
URL::setRequest(Request::create('http://localhost:8000/'));

$this->app->register(AppServiceProvider::class, force: true);
}

public function test_http_app_url_keeps_http_in_production(): void
{
$this->bootWith('production', 'http://localhost:8000');

$this->assertStringStartsWith('http://', asset('build/app.js'));
$this->assertStringStartsWith('http://', url('/login'));
}

public function test_https_app_url_forces_https_behind_a_terminating_proxy(): void
{
$this->bootWith('local', 'https://cms.example.com');

$this->assertStringStartsWith('https://', asset('build/app.js'));
$this->assertStringStartsWith('https://', url('/login'));
}
}