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], ], ]); } }