mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 15:12:12 +00:00
PdfDriver and ResponseStream existed but nothing implemented them. The factory returned the vendor dompdf wrapper for one driver and a bespoke class for the other, so the two were never held to the same shape. Three things had slipped through that gap. Report PDFs answered 403 for everyone. The five report routes carry no company header, so ScopeBouncer is not in their middleware stack and the ability scope was never set; 'view-financial-reports' is stored scoped to a company, so the check could not pass. They now scope to the company named in the URL. The policy still checks membership, so this grants nothing new. Also firstOrFail() on the hash lookup, so an unknown company is a 404 rather than a 500 on a null. Report downloads were fatal on Gotenberg. GotenbergPdfResponse had no download(), and the report controllers are its only callers. Added, alongside stream() and output(), with the whole set now on the interface. Streamed documents carried an HTTP preamble. GeneratesPdfTrait wrapped $pdf->stream() -- already a Response -- in another response()->make(), which stringified it and prepended "HTTP/1.0 200 OK" plus headers to the file. Readers scan the first kilobyte for %PDF so nobody noticed, but the bytes were malformed. Passing ->output() fixes it, and the render test now asserts the position. Two driver-parity settings, both checked against a real gotenberg:8 rather than inferred: emulateScreenMediaType(), because Chromium defaults to print media while config/dompdf.php renders as screen, so a @media print rule applied on one driver and not the other; and printBackground(), which turns out to affect only the root background, since Chromium paints element backgrounds either way. No stock template sets a body background, so that one changes nothing today and is here to keep custom templates consistent across drivers. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
202 lines
7.3 KiB
PHP
202 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace App\Traits;
|
|
|
|
use App\Models\Address;
|
|
use App\Models\CompanySetting;
|
|
use App\Models\FileDisk;
|
|
use App\Models\Setting;
|
|
use App\Services\FontService;
|
|
use App\Support\Pdf\PdfHtmlSanitizer;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\App;
|
|
|
|
trait GeneratesPdfTrait
|
|
{
|
|
public function getGeneratedPDFOrStream($collection_name)
|
|
{
|
|
$pdf = $this->getGeneratedPDF($collection_name);
|
|
if ($pdf && file_exists($pdf['path'])) {
|
|
return response()->make(file_get_contents($pdf['path']), 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => 'inline; filename="'.$pdf['file_name'].'"',
|
|
]);
|
|
}
|
|
|
|
$locale = CompanySetting::getSetting('language', $this->company_id);
|
|
|
|
App::setLocale($locale);
|
|
app(FontService::class)->ensureFontsForLocale($locale);
|
|
|
|
$pdf = $this->getPDFData();
|
|
|
|
// ->output(), not ->stream(): stream() already returns a Response, and
|
|
// nesting one inside response()->make() stringifies it, prepending the
|
|
// whole "HTTP/1.0 200 OK" preamble to the file. Readers scan the first
|
|
// kilobyte for %PDF so it looked fine, but the bytes were malformed and
|
|
// anything that validates them (PDF/A, extraction tooling) would balk.
|
|
return response()->make($pdf->output(), 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => 'inline; filename="'.$this[$collection_name.'_number'].'.pdf"',
|
|
]);
|
|
}
|
|
|
|
public function getGeneratedPDF($collection_name)
|
|
{
|
|
try {
|
|
$media = $this->getMedia($collection_name)->first();
|
|
|
|
if ($media) {
|
|
$file_disk = FileDisk::find($media->custom_properties['file_disk_id']);
|
|
|
|
if (! $file_disk) {
|
|
return false;
|
|
}
|
|
|
|
$file_disk->setConfig();
|
|
|
|
$path = null;
|
|
|
|
if ($file_disk->driver == 'local') {
|
|
$path = $media->getPath();
|
|
} else {
|
|
$path = $media->getTemporaryUrl(Carbon::now()->addMinutes(5));
|
|
}
|
|
|
|
return collect([
|
|
'path' => $path,
|
|
'file_name' => $media->file_name,
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function generatePDF($collection_name, $file_name, $deleteExistingFile = false)
|
|
{
|
|
$save_pdf_to_disk = Setting::getSetting('save_pdf_to_disk') ?? 'NO';
|
|
|
|
if ($save_pdf_to_disk == 'NO') {
|
|
return 0;
|
|
}
|
|
|
|
$locale = CompanySetting::getSetting('language', $this->company_id);
|
|
|
|
App::setLocale($locale);
|
|
app(FontService::class)->ensureFontsForLocale($locale);
|
|
|
|
$pdf = $this->getPDFData();
|
|
|
|
\Storage::disk('local')->put('temp/'.$collection_name.'/'.$this->id.'/temp.pdf', $pdf->output());
|
|
|
|
if ($deleteExistingFile) {
|
|
$this->clearMediaCollection($this->id);
|
|
}
|
|
|
|
$file_disk = FileDisk::whereSetAsDefault(true)->first();
|
|
|
|
if ($file_disk) {
|
|
$file_disk->setConfig();
|
|
}
|
|
|
|
$media = \Storage::disk('local')->path('temp/'.$collection_name.'/'.$this->id.'/temp.pdf');
|
|
|
|
try {
|
|
$this->addMedia($media)
|
|
->withCustomProperties(['file_disk_id' => $file_disk->id])
|
|
->usingFileName($file_name.'.pdf')
|
|
->toMediaCollection($collection_name, config('filesystems.default'));
|
|
|
|
\Storage::disk('local')->deleteDirectory('temp/'.$collection_name.'/'.$this->id);
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
return $e->getMessage();
|
|
}
|
|
}
|
|
|
|
public function getFieldsArray()
|
|
{
|
|
$customer = $this->customer;
|
|
$shippingAddress = $customer->shippingAddress ?? new Address;
|
|
$billingAddress = $customer->billingAddress ?? new Address;
|
|
$companyAddress = $this->company->address ?? new Address;
|
|
|
|
$fields = [
|
|
'{SHIPPING_ADDRESS_NAME}' => $shippingAddress->name,
|
|
'{SHIPPING_COUNTRY}' => $shippingAddress->country_name,
|
|
'{SHIPPING_STATE}' => $shippingAddress->state,
|
|
'{SHIPPING_CITY}' => $shippingAddress->city,
|
|
'{SHIPPING_ADDRESS_STREET_1}' => $shippingAddress->address_street_1,
|
|
'{SHIPPING_ADDRESS_STREET_2}' => $shippingAddress->address_street_2,
|
|
'{SHIPPING_PHONE}' => $shippingAddress->phone,
|
|
'{SHIPPING_ZIP_CODE}' => $shippingAddress->zip,
|
|
'{BILLING_ADDRESS_NAME}' => $billingAddress->name,
|
|
'{BILLING_COUNTRY}' => $billingAddress->country_name,
|
|
'{BILLING_STATE}' => $billingAddress->state,
|
|
'{BILLING_CITY}' => $billingAddress->city,
|
|
'{BILLING_ADDRESS_STREET_1}' => $billingAddress->address_street_1,
|
|
'{BILLING_ADDRESS_STREET_2}' => $billingAddress->address_street_2,
|
|
'{BILLING_PHONE}' => $billingAddress->phone,
|
|
'{BILLING_ZIP_CODE}' => $billingAddress->zip,
|
|
'{COMPANY_NAME}' => $this->company->name,
|
|
'{COMPANY_COUNTRY}' => $companyAddress->country_name,
|
|
'{COMPANY_STATE}' => $companyAddress->state,
|
|
'{COMPANY_CITY}' => $companyAddress->city,
|
|
'{COMPANY_ADDRESS_STREET_1}' => $companyAddress->address_street_1,
|
|
'{COMPANY_ADDRESS_STREET_2}' => $companyAddress->address_street_2,
|
|
'{COMPANY_PHONE}' => $companyAddress->phone,
|
|
'{COMPANY_ZIP_CODE}' => $companyAddress->zip,
|
|
'{COMPANY_VAT}' => $this->company->vat_id,
|
|
'{COMPANY_TAX}' => $this->company->tax_id,
|
|
'{CONTACT_DISPLAY_NAME}' => $customer->name,
|
|
'{PRIMARY_CONTACT_NAME}' => $customer->contact_name,
|
|
'{CONTACT_EMAIL}' => $customer->email,
|
|
'{CONTACT_PHONE}' => $customer->phone,
|
|
'{CONTACT_WEBSITE}' => $customer->website,
|
|
'{CONTACT_TAX_ID}' => __('pdf_tax_id').': '.$customer->tax_id,
|
|
];
|
|
|
|
$customFields = $this->fields;
|
|
$customerCustomFields = $this->customer->fields;
|
|
|
|
foreach ($customFields as $customField) {
|
|
$fields['{'.$customField->customField->slug.'}'] = $customField->defaultAnswer;
|
|
}
|
|
|
|
foreach ($customerCustomFields as $customField) {
|
|
$fields['{'.$customField->customField->slug.'}'] = $customField->defaultAnswer;
|
|
}
|
|
|
|
foreach ($fields as $key => $field) {
|
|
$fields[$key] = htmlspecialchars($field, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
return $fields;
|
|
}
|
|
|
|
public function getFormattedString($format)
|
|
{
|
|
$values = array_merge($this->getFieldsArray(), $this->getExtraFields());
|
|
|
|
$str = nl2br(strtr($format, $values));
|
|
|
|
$str = preg_replace('/{(.*?)}/', '', $str);
|
|
|
|
$str = preg_replace("/<[^\/>]*>([\s]?)*<\/[^>]*>/", '', $str);
|
|
|
|
$str = str_replace('<p>', '', $str);
|
|
|
|
$str = str_replace('</p>', '<br />', $str);
|
|
|
|
// Sanitize the assembled HTML to strip any SSRF vectors that may have
|
|
// entered through user-supplied address fields, customer names, or
|
|
// custom field values. Notes also pass through this method, so they
|
|
// get the same treatment without needing a separate wrapper.
|
|
return PdfHtmlSanitizer::sanitize($str);
|
|
}
|
|
}
|