CluPilotCloud/app/Services/Billing/InvoiceArchive.php

166 lines
6.5 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 that is not
* mounted. 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);
// Per year for an archive somebody keeps, per day for a handover point
// somebody collects from — the destination says which, because the two
// do different jobs. Either way it is grouped: seven years of invoices
// in one directory is a directory nobody opens twice.
$relative = $target->folderFor($invoice->issued_on).'/'.$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,
// Explicit, because the default follows the process umask and a
// restrictive one leaves the folder 0700. That is not cosmetic:
// whoever COLLECTS from a handover directory is a different account
// — an rsync over ssh, a backup agent — and a directory it cannot
// enter looks to it exactly like an empty one. Reported as "the
// folder arrives empty", with the invoice sitting in it the whole
// time.
'visibility' => 'public',
'permissions' => [
'file' => ['public' => 0o644, 'private' => 0o600],
'dir' => ['public' => 0o755, 'private' => 0o700],
],
]);
}
}