153 lines
5.7 KiB
PHP
153 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\ExportTarget;
|
|
use App\Models\Invoice;
|
|
use App\Services\Secrets\SecretCipher;
|
|
use Illuminate\Filesystem\FilesystemAdapter;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* A copy of an invoice on one destination.
|
|
*
|
|
* Two kinds, and the difference is where the protocol lives.
|
|
*
|
|
* 'local' is a directory this machine can already write to — a NAS mounted over
|
|
* NFS 4.1, a bind mount, a disk. The application knows nothing about the
|
|
* protocol and does not want to: the mount is the operator's business, the
|
|
* target can change without a line of code changing with it, and the whole
|
|
* thing stays testable against a temporary directory.
|
|
*
|
|
* 'sftp' is for something reachable over the network with credentials — a
|
|
* Hetzner Storage Box being the obvious one. The password is encrypted on the
|
|
* row with the same cipher the credential vault uses: a credential in a
|
|
* settings table in the clear is a credential in every database dump.
|
|
*
|
|
* Neither is allowed to create its own root. See ensureRoot().
|
|
*/
|
|
final class InvoiceArchive
|
|
{
|
|
public function __construct(
|
|
private readonly InvoiceRenderer $renderer,
|
|
private readonly SecretCipher $cipher,
|
|
) {}
|
|
|
|
/**
|
|
* Write one invoice to one destination.
|
|
*
|
|
* Throws rather than returning false: the caller is a queued job, and a
|
|
* throw is what makes the queue retry. A silent false would leave the
|
|
* invoice unarchived and the job marked done — the failure nobody notices.
|
|
*/
|
|
public function store(Invoice $invoice, ExportTarget $target): string
|
|
{
|
|
// BEFORE the disk is built, and that order is the point: Laravel's
|
|
// local adapter creates its root when it is constructed, so a check
|
|
// afterwards always finds the directory it just made — which is exactly
|
|
// the silent-success this guard exists to prevent.
|
|
$this->ensureRoot($target);
|
|
|
|
$disk = $this->disk($target);
|
|
|
|
// Grouped by year, because seven years of invoices in one directory is
|
|
// a directory nobody opens twice.
|
|
$year = $invoice->issued_on?->format('Y') ?? 'unbekannt';
|
|
$relative = $year.'/'.$invoice->number.'.pdf';
|
|
|
|
$pdf = $this->renderer->forInvoice($invoice);
|
|
|
|
// Written beside the final name and moved into place. A move within one
|
|
// filesystem is atomic, so whatever watches that folder never sees a
|
|
// truncated PDF and takes it for a finished invoice.
|
|
$temporary = $relative.'.part';
|
|
|
|
if (! $disk->put($temporary, $pdf)) {
|
|
throw new RuntimeException("Could not write to {$target->name}: {$temporary}");
|
|
}
|
|
|
|
$disk->delete($relative);
|
|
|
|
if (! $disk->move($temporary, $relative)) {
|
|
$disk->delete($temporary);
|
|
|
|
throw new RuntimeException("Could not move the finished file into place on {$target->name}: {$relative}");
|
|
}
|
|
|
|
// Read the size back rather than trusting the write. Over a network
|
|
// mount a short write can report success, and an archive of truncated
|
|
// files looks exactly like one that worked.
|
|
$written = $disk->size($relative);
|
|
|
|
if ($written < 1000) {
|
|
throw new RuntimeException("The archived file on {$target->name} is too small to be an invoice: {$relative}");
|
|
}
|
|
|
|
return $relative;
|
|
}
|
|
|
|
/**
|
|
* The root is used, never created.
|
|
*
|
|
* This is the whole difference between an archive and a hole in the ground.
|
|
* mkdir -p on a path whose mount is not there SUCCEEDS: it creates the empty
|
|
* directory beneath the mountpoint, writes the invoice onto the container's
|
|
* own disk and reports success — so an unmounted NAS would quietly collect
|
|
* invoices locally while every check said it worked. A missing root means a
|
|
* missing mount, and that is a failure to report, not a directory to make.
|
|
*
|
|
* Only for 'local'. On sftp the root is a remote directory that the server
|
|
* genuinely may need to create, and there is no mountpoint to be confused
|
|
* with.
|
|
*/
|
|
private function ensureRoot(ExportTarget $target): void
|
|
{
|
|
if ($target->driver !== ExportTarget::LOCAL) {
|
|
return;
|
|
}
|
|
|
|
if (! is_dir($target->path)) {
|
|
throw new RuntimeException(
|
|
"The path for {$target->name} does not exist — is the mount there? {$target->path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
private function disk(ExportTarget $target): FilesystemAdapter
|
|
{
|
|
if ($target->driver === ExportTarget::SFTP) {
|
|
$stored = trim((string) $target->secret_key);
|
|
|
|
if ($stored === '') {
|
|
throw new RuntimeException("No credential stored for {$target->name}.");
|
|
}
|
|
|
|
$password = $this->cipher->decrypt($stored);
|
|
|
|
if ($password === '') {
|
|
// A rotated SECRETS_KEY makes every stored credential
|
|
// unreadable. Saying so beats an authentication failure against
|
|
// a host that is perfectly fine.
|
|
throw new RuntimeException("The credential for {$target->name} cannot be decrypted — has SECRETS_KEY changed?");
|
|
}
|
|
|
|
return Storage::build([
|
|
'driver' => 'sftp',
|
|
'host' => (string) $target->host,
|
|
'port' => (int) ($target->port ?: 22),
|
|
'username' => (string) $target->username,
|
|
'password' => $password,
|
|
'root' => $target->path,
|
|
'timeout' => 30,
|
|
]);
|
|
}
|
|
|
|
return Storage::build([
|
|
'driver' => 'local',
|
|
'root' => $target->path,
|
|
'throw' => false,
|
|
]);
|
|
}
|
|
}
|