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
parent
3b0c6b19b9
commit
52aacddc56
|
|
@ -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)');
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue