Store the agreement where the web process can actually read it

Every link on the agreement page answered 404 — the download visibly, the
two inline ones just as dead — and nothing anywhere said why. The files
were there, whole and correct.

An artisan run inside a container is root; PHP-FPM is www-data. Laravel's
local disk creates a PRIVATE directory, which is 0700, so `dpa/` came out
as root-only. The web process could not enter it, `Storage::exists()`
answered false, and the routes did exactly what they were told: abort 404.
No exception, no log line, a page full of links to nothing.

Both writers — the command and the console's upload form — now store with
"public" visibility, which is the file MODE and nothing to do with the web:
0755/0644 instead of 0700/0600. The disk's root is outside the document
root either way, and the two routes still check who is asking. The command
also warns if a file it just wrote is not readable, because the alternative
is finding out from a customer.

The download itself now looks like one: the `download` attribute on both
anchors, and in the console a bordered control rather than a third link in
a row of two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 17:19:39 +02:00
parent 3b0c6b19b9
commit 52aacddc56
5 changed files with 67 additions and 9 deletions

View File

@ -41,8 +41,19 @@ class PublishProcessingAgreement extends Command
$agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf'; $agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf';
$measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf'; $measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf';
Storage::disk('local')->put($agreementPath, $renderer->agreement($version)); // Visibility "public" is about the FILE MODE, not about the web: this
Storage::disk('local')->put($measuresPath, $renderer->measures($version)); // disk's root is outside the document root and nothing here becomes
// reachable by URL. What it changes is 0755/0644 instead of 0700/0600 —
// and that is the difference between a document the web process can read
// and one it cannot.
//
// It matters because this command is normally run as root (an artisan
// call in a container is root; PHP-FPM is www-data). The first run left
// a directory only root could enter, the route's exists() check answered
// false, and every link on the page 404'd with nothing in any log to say
// why.
Storage::disk('local')->put($agreementPath, $renderer->agreement($version), 'public');
Storage::disk('local')->put($measuresPath, $renderer->measures($version), 'public');
$row = DpaVersion::query()->create([ $row = DpaVersion::query()->create([
'version' => $version, 'version' => $version,
@ -55,6 +66,12 @@ class PublishProcessingAgreement extends Command
'published_at' => $this->option('draft') ? null : now(), 'published_at' => $this->option('draft') ? null : now(),
]); ]);
foreach ([$agreementPath, $measuresPath] as $path) {
if (Storage::disk('local')->getVisibility($path) !== 'public') {
$this->warn("{$path} is not readable by the web process. Run this as the user PHP runs as, or fix the mode — the page will 404 otherwise.");
}
}
$this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.'); $this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.');
$this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)'); $this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)');
$this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)'); $this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)');

View File

@ -62,13 +62,21 @@ class ProcessingAgreements extends Component
'measures' => 'nullable|file|mimes:pdf|max:10240', 'measures' => 'nullable|file|mimes:pdf|max:10240',
]); ]);
// The private disk. An agreement is not a public asset, and a guessable // The private DISK. An agreement is not a public asset, and a guessable
// URL to one would be a list of who our customers are. // URL to one would be a list of who our customers are — the root of this
// disk is outside the document root and nothing here is reachable except
// through the two routes that check who is asking.
//
// The visibility below is the file MODE, not the web: 0755/0644 rather
// than 0700/0600, so a document written by one process (an artisan run
// as root) stays readable by the other (PHP-FPM as www-data). Getting
// that wrong produced a directory nothing could enter and a page whose
// every link 404'd silently.
$version = DpaVersion::query()->create([ $version = DpaVersion::query()->create([
'version' => $data['version'], 'version' => $data['version'],
'note' => $data['note'] ?: null, 'note' => $data['note'] ?: null,
'agreement_path' => $this->agreement->store('dpa', 'local'), 'agreement_path' => $this->agreement->store('dpa', 'local', 'public'),
'measures_path' => $this->measures?->store('dpa', 'local'), 'measures_path' => $this->measures?->store('dpa', 'local', 'public'),
'created_by' => Auth::guard('operator')->id(), 'created_by' => Auth::guard('operator')->id(),
]); ]);

View File

@ -129,9 +129,13 @@
{{ __('dpa_admin.file_measures') }} {{ __('dpa_admin.file_measures') }}
</a> </a>
@endif @endif
{{-- A download, so it says so and looks like
a control rather than a third link in
a row of two. --}}
<a href="{{ route('admin.dpa.file', ['uuid' => $v->uuid, 'which' => 'agreement', 'download' => 1]) }}" <a href="{{ route('admin.dpa.file', ['uuid' => $v->uuid, 'which' => 'agreement', 'download' => 1]) }}"
class="text-xs font-semibold text-muted underline-offset-4 hover:underline"> download
{{ __('dpa_admin.download') }} class="inline-flex items-center gap-1 rounded border border-line-strong bg-surface px-2 py-0.5 text-xs font-semibold text-body hover:bg-surface-hover">
<x-ui.icon name="download" class="size-3.5" />{{ __('dpa_admin.download') }}
</a> </a>
</div> </div>
</td> </td>

View File

@ -379,7 +379,7 @@
{{-- Keep, not just read: the file carries the version in {{-- Keep, not just read: the file carries the version in
its name, so "which fassung did I agree to" is its name, so "which fassung did I agree to" is
answerable from a downloads folder months later. --}} answerable from a downloads folder months later. --}}
<x-ui.button :href="route('dpa.file', ['which' => 'agreement', 'download' => 1])" variant="ghost" size="sm"> <x-ui.button :href="route('dpa.file', ['which' => 'agreement', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }} <x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }}
</x-ui.button> </x-ui.button>
<span class="font-mono text-xs text-muted">{{ __('dpa.version', ['version' => $dpa->version]) }}</span> <span class="font-mono text-xs text-muted">{{ __('dpa.version', ['version' => $dpa->version]) }}</span>

View File

@ -287,3 +287,32 @@ it('hands the file over under a name carrying the version', function () {
->assertOk() ->assertOk()
->assertDownload('CluPilot-TOM-2.5.pdf'); ->assertDownload('CluPilot-TOM-2.5.pdf');
}); });
it('leaves the documents readable by the process that has to serve them', function () {
// The failure this prevents had no error message anywhere: an artisan run in
// a container is root, PHP-FPM is www-data, and Laravel's local disk creates
// a private directory as 0700. The web process could not enter it, the
// route's exists() check answered false, and every link on the page 404'd
// while the file sat there perfectly intact.
//
// "public" here is the file MODE and nothing to do with the web: this disk's
// root is outside the document root either way.
Storage::fake('local');
$this->artisan('clupilot:publish-dpa', ['version' => '6.1'])->assertSuccessful();
$version = DpaVersion::query()->where('version', '6.1')->sole();
expect(Storage::disk('local')->getVisibility($version->agreement_path))->toBe('public')
->and(Storage::disk('local')->getVisibility($version->measures_path))->toBe('public');
});
it('serves the console download too, not only the portal one', function () {
publishedDpa('3.3');
$version = DpaVersion::query()->where('version', '3.3')->sole();
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.dpa.file', ['uuid' => $version->uuid, 'which' => 'agreement', 'download' => 1]))
->assertOk()
->assertDownload('CluPilot-AV-Vertrag-3.3.pdf');
});