Allow custom payment gateway images (#4519)

* Add custom payment gateway images

Allow image uploads and URL imports with AVIF and PNG browser fallback. Preserve sanitized SVG artwork and keep custom images outside packaged assets.

* Fix gateway image CI checks and upgrade coverage

* Limit gateway raster images to four million pixels
This commit is contained in:
Adam Daley authored and GitHub committed 2026-10-12 18:23:10 +01:00
1 parent dfb57671d1
commit 5489acb148
19 files changed
+973 -13

No files matched your search

+1
View File
@@ -12,6 +12,7 @@
!/src/data/log/index.html
!/src/data/uploads/index.html
/src/public/assets
/src/public/gateways/custom/
/src/vendor/*
build
+4 -1
View File
@@ -105,7 +105,10 @@
"Bakker",
"Novakova",
"nosniff",
"pjpeg"
"pjpeg",
"imageavif",
"webp",
"webmoney"
],
"ignoreWords": [
+1 -1
View File
@@ -416,7 +416,7 @@ const API = {
const formMethod = (formElement.getAttribute('method') || 'post').toLowerCase();
const data = formMethod !== 'get'
? Tools.serializeFormDataToJSON(formData)
? (formElement.enctype === 'multipart/form-data' ? formData : Tools.serializeFormDataToJSON(formData))
: Tools.serializeFormData(formData);
const buttons = formElement.querySelectorAll('button:not([disabled])');
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
const testGlobal = globalThis as any;
const forms: any[] = [];
const NativeFormData = FormData;
testGlobal.window = { FOSSBilling: { ready() {} }, location: { origin: 'https://billing.example' } };
testGlobal.document = { cookie: '', querySelectorAll: () => forms };
testGlobal.FormData = class extends NativeFormData {
constructor(form: any) {
super();
for (const [name, value] of form.fields) this.append(name, value);
}
};
await import('../api.ts');
test('API form binding preserves uploaded files and nested fields in multipart requests', async () => {
let submit: (event: any) => void;
const image = new File(['image bytes'], 'cards.png', { type: 'image/png' });
forms.push({
dataset: {},
enctype: 'multipart/form-data',
fields: [['title', 'Cards'], ['config[key]', 'value'], ['logo_file', image]],
addEventListener(_event: string, handler: any) { submit = handler; },
getAttribute(name: string) { return name === 'method' ? 'post' : '/api/admin/invoice/gateway_update'; },
querySelectorAll() { return []; },
});
let body: FormData;
const api = testGlobal.window.FOSSBilling.api;
api.makeRequest = (method: string, url: string, params: FormData) => {
assert.equal(method, 'post');
assert.equal(url, 'https://billing.example/api/admin/invoice/gateway_update');
body = params;
};
api._apiForm();
submit!({ preventDefault() {} });
assert.ok(body! instanceof NativeFormData);
assert.equal(body!.get('title'), 'Cards');
assert.equal(body!.get('config[key]'), 'value');
assert.equal(await (body!.get('logo_file') as File).text(), 'image bytes');
});
test('ordinary API forms still submit JSON', () => {
let submit: (event: any) => void;
forms.length = 0;
forms.push({
dataset: {},
enctype: 'application/x-www-form-urlencoded',
fields: [['title', 'Cards'], ['config[key]', 'value']],
addEventListener(_event: string, handler: any) { submit = handler; },
getAttribute(name: string) { return name === 'method' ? 'post' : '/api/admin/invoice/gateway_update'; },
querySelectorAll() { return []; },
});
const api = testGlobal.window.FOSSBilling.api;
api.makeRequest = (_method: string, _url: string, params: string) => {
assert.deepEqual(JSON.parse(params), { title: 'Cards', config: { key: 'value' } });
};
api._apiForm();
submit!({ preventDefault() {} });
});
+1 -1
View File
@@ -304,7 +304,7 @@ LOCK TABLES `setting` WRITE;
INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
VALUES
(1,'last_patch','128',0,NULL,NULL,NOW(),NOW()),
(1,'last_patch','129',0,NULL,NULL,NOW(),NOW()),
(2,'company_name','Company Name',0,NULL,NULL,NOW(),NOW()),
(3,'company_email','support@yourcompany.com',0,NULL,NULL,NOW(),NOW()),
(4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,NOW(),NOW()),
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace FOSSBilling\Sanitizer;
/** Rebuild static SVG artwork with a restricted set of elements and attributes. */
final class GatewaySvgSanitizer
{
private const string SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
private const array ELEMENTS = [
'svg', 'g', 'defs', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
'text', 'tspan', 'title', 'desc', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern',
];
private const array ATTRIBUTES = [
'id', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'width', 'height',
'viewBox', 'preserveAspectRatio', 'd', 'points', 'transform', 'opacity',
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity',
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset',
'clip-path', 'clip-rule', 'mask', 'maskUnits', 'maskContentUnits',
'gradientUnits', 'gradientTransform', 'spreadMethod', 'offset', 'stop-color', 'stop-opacity',
'patternUnits', 'patternContentUnits', 'patternTransform',
'font-family', 'font-size', 'font-weight', 'font-style', 'text-anchor', 'dominant-baseline', 'dx', 'dy', 'color',
];
private const array STYLE_PROPERTIES = [
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity',
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset',
'opacity', 'stop-color', 'stop-opacity', 'font-family', 'font-size', 'font-weight', 'font-style', 'text-anchor', 'color',
];
public static function sanitize(string $bytes): string
{
$source = new \DOMDocument();
if (!@$source->loadXML($bytes, LIBXML_NONET) || $source->doctype !== null
|| $source->documentElement?->localName !== 'svg'
|| !in_array($source->documentElement->namespaceURI, [null, self::SVG_NAMESPACE], true)) {
throw new \FOSSBilling\Exception('Use a valid PNG, JPEG, WebP, AVIF, or SVG image.');
}
$clean = new \DOMDocument('1.0', 'UTF-8');
$clean->appendChild(self::copyElement($source->documentElement, $clean, 0));
return $clean->saveXML($clean->documentElement);
}
private static function copyElement(\DOMElement $source, \DOMDocument $document, int $depth): \DOMElement
{
if ($depth > 64) {
throw new \FOSSBilling\Exception('SVG image is too deeply nested.');
}
$element = $document->createElementNS(self::SVG_NAMESPACE, $source->localName);
foreach ($source->attributes as $attribute) {
if ($attribute->namespaceURI !== null) {
continue;
}
if (in_array($attribute->name, self::ATTRIBUTES, true) && self::isSafeValue($attribute->value)) {
$element->setAttribute($attribute->name, $attribute->value);
} elseif ($attribute->name === 'style') {
$declarations = [];
foreach (explode(';', $attribute->value) as $declaration) {
$parts = explode(':', $declaration, 2);
if (count($parts) === 2 && in_array(trim($parts[0]), self::STYLE_PROPERTIES, true) && self::isSafeValue(trim($parts[1]))) {
$declarations[] = trim($parts[0]) . ':' . trim($parts[1]);
}
}
$element->setAttribute('style', implode(';', $declarations));
}
}
foreach ($source->childNodes as $child) {
if ($child instanceof \DOMElement && in_array($child->localName, self::ELEMENTS, true)
&& in_array($child->namespaceURI, [null, self::SVG_NAMESPACE], true)) {
$element->appendChild(self::copyElement($child, $document, $depth + 1));
} elseif ($child instanceof \DOMText && in_array($source->localName, ['text', 'tspan', 'title', 'desc'], true)) {
$element->appendChild($document->createTextNode($child->textContent));
}
}
return $element;
}
private static function isSafeValue(string $value): bool
{
// Only local paint-server references are permitted. CSS escapes,
// at-rules, external URLs, and arbitrary functions cannot survive.
$value = preg_replace('/url\(\s*#[a-zA-Z_][a-zA-Z0-9_.-]*\s*\)/i', '', $value);
$value = preg_replace('/(?:matrix|translate|scale|rotate|skewX|skewY|rgb|rgba|hsl|hsla)\([0-9\s.,%+\/-]*\)/i', '', $value);
return preg_match('/\A[a-zA-Z0-9\s#.,%+\-]*\z/', $value) === 1;
}
}
@@ -1492,6 +1492,7 @@ class UpdatePatcher implements InjectionAwareInterface
126 => 'patch126',
127 => 'patch127',
128 => 'patch128',
129 => 'patch129',
];
ksort($patches, SORT_NATURAL);
@@ -4787,6 +4788,13 @@ class UpdatePatcher implements InjectionAwareInterface
}
}
private function patch129(): void
{
if (!$this->tableHasColumn('pay_gateway', 'custom_logo')) {
$this->executeSql('ALTER TABLE `pay_gateway` ADD COLUMN `custom_logo` VARCHAR(255) DEFAULT NULL');
}
}
/**
* Find a database-collision-free slug derived from $base for reconciliation.
*
+10 -1
View File
@@ -25,6 +25,7 @@ use Box\Mod\Order\Entity\Order;
use FOSSBilling\InformationException;
use FOSSBilling\PaginationOptions;
use FOSSBilling\Validation\Api\RequiredParams;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Response;
class Admin extends \FOSSBilling\Api\AbstractApi
@@ -934,6 +935,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi
* Change gateway settings.
*
* @optional string $title - gateway title
* @optional string $logo_url - public HTTP(S) image URL to download and store locally; blank keeps the current image
* @optional file $logo_file - custom image uploaded in a multipart request; takes precedence over logo_url
* @optional bool $remove_custom_logo - restore the adapter's default image
* @optional array $config - gateway config array
* @optional array $accepted_currencies - list of currencies this gateway supports
* @optional bool $enabled - flag to enable or disable gateway
@@ -956,7 +960,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
}
$gatewayService = $this->getDi()['mod_service']('Invoice', 'PayGateway');
return $gatewayService->update($model, $data);
$file = $this->getDi()['request']->files->get('logo_file');
if ($file !== null && !$file instanceof UploadedFile) {
throw new \FOSSBilling\Exception('Payment gateway image upload failed.');
}
return $gatewayService->update($model, $data, $file);
}
/**
+15
View File
@@ -48,6 +48,9 @@ class PayGateway
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $config = null;
#[ORM\Column(name: 'custom_logo', type: Types::STRING, length: 255, nullable: true)]
private ?string $customLogo = null;
public function getId(): ?int
{
return $this->id;
@@ -148,4 +151,16 @@ class PayGateway
return $this;
}
public function getCustomLogo(): ?string
{
return $this->customLogo;
}
public function setCustomLogo(?string $customLogo): self
{
$this->customLogo = $customLogo;
return $this;
}
}
+183 -3
View File
@@ -17,13 +17,22 @@ use Box\Mod\Invoice\Entity\Subscription;
use Box\Mod\Invoice\Entity\Transaction;
use Box\Mod\Invoice\Repository\PayGatewayRepository;
use FOSSBilling\InjectionAwareInterface;
use FOSSBilling\Sanitizer\GatewaySvgSanitizer;
use FOSSBilling\Tools;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
class ServicePayGateway implements InjectionAwareInterface
{
private const int MAX_LOGO_BYTES = 2 * 1024 * 1024;
private const int MAX_LOGO_PIXELS = 4_000_000;
/**
* Sent by the admin UI in place of a secret config field's value to mean
* "keep the currently stored value". Blank input means the same thing;
@@ -175,6 +184,8 @@ class ServicePayGateway implements InjectionAwareInterface
$result['enabled'] = $model->isEnabled();
$result['test_mode'] = $model->isTestMode();
$result['callback'] = $this->getCallbackUrl($model);
$result['custom_logo_url'] = $this->getCustomLogoUrl($model);
$result['custom_logo_avif_url'] = $this->getCustomLogoUrl($model, true);
}
return $result;
@@ -223,6 +234,7 @@ class ServicePayGateway implements InjectionAwareInterface
$new->setAcceptedCurrencies($model->getAcceptedCurrencies());
$new->setTestMode($model->isTestMode());
$new->setConfig($model->getConfig());
$new->setCustomLogo($model->getCustomLogo());
$this->di['em']->persist($new);
$this->di['em']->flush();
$newId = (int) $new->getId();
@@ -231,10 +243,8 @@ class ServicePayGateway implements InjectionAwareInterface
return $newId;
}
public function update(PayGateway $model, array $data): bool
public function update(PayGateway $model, array $data, ?UploadedFile $file = null): bool
{
$model->setName($data['title'] ?? $model->getName());
$newEnabled = isset($data['enabled']) ? (bool) $data['enabled'] : $model->isEnabled();
$newTestMode = isset($data['test_mode']) ? (bool) $data['test_mode'] : $model->isTestMode();
$existingConfig = json_decode($model->getConfig() ?? '', true) ?? [];
@@ -252,6 +262,18 @@ class ServicePayGateway implements InjectionAwareInterface
$this->validateGatewayConfig($model, $mergedConfig, $newTestMode);
}
$customLogo = $model->getCustomLogo();
if (Tools::normalizeBoolean($data['remove_custom_logo'] ?? false)) {
$customLogo = null;
} elseif ($file !== null) {
$customLogo = $this->storeUploadedLogo($file);
} elseif (array_key_exists('logo_url', $data) && $data['logo_url'] !== '') {
$customLogo = $this->importGatewayLogo($data['logo_url']);
}
$model->setName($data['title'] ?? $model->getName());
$model->setCustomLogo($customLogo);
if (isset($data['config']) && is_array($data['config'])) {
$model->setConfig(json_encode($mergedConfig));
}
@@ -373,6 +395,10 @@ class ServicePayGateway implements InjectionAwareInterface
];
}
if (($customLogoUrl = $this->getCustomLogoUrl($gtw)) !== null) {
$gateway['logo'] = ['logo' => $customLogoUrl, 'avif' => $this->getCustomLogoUrl($gtw, true), 'height' => '50px', 'width' => '150px'];
}
$result[] = $gateway;
}
}
@@ -380,6 +406,160 @@ class ServicePayGateway implements InjectionAwareInterface
return $result;
}
private function getCustomLogoUrl(PayGateway $model, bool $avif = false): ?string
{
$filename = $model->getCustomLogo();
if ($avif) {
if ($filename === null || !str_ends_with($filename, '.png')) {
return null;
}
$filename = substr($filename, 0, -4) . '.avif';
}
if ($filename === null || !preg_match('/\A[a-f0-9]{64}\.(?:png|jpg|webp|avif|svg)\z/', $filename)
|| !$this->filesystem->exists(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', $filename))) {
return null;
}
return $this->di['tools']->url('/public/gateways/custom/' . $filename);
}
/** Import once; client pages always use the stored local image. */
private function importGatewayLogo(mixed $url): string
{
if (!is_string($url) || strlen($url) > 2048 || !filter_var($url, FILTER_VALIDATE_URL)
|| !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)
|| parse_url($url, PHP_URL_USER) !== null || parse_url($url, PHP_URL_PASS) !== null) {
throw new \FOSSBilling\Exception('Enter a valid HTTP or HTTPS image URL without credentials.');
}
$client = new NoPrivateNetworkHttpClient($this->di['http_client']);
$response = null;
try {
$response = $client->request('GET', $url, [
'max_redirects' => 3,
'timeout' => 5,
'max_duration' => 10,
'on_progress' => static function (int $downloaded, int $size): void {
if ($downloaded > self::MAX_LOGO_BYTES || $size > self::MAX_LOGO_BYTES) {
throw new TransportException('Payment gateway image is too large.');
}
},
]);
if ($response->getStatusCode() !== 200) {
throw new \FOSSBilling\Exception('Could not download the payment gateway image.');
}
$bytes = '';
foreach ($client->stream($response) as $chunk) {
$bytes .= $chunk->getContent();
if (strlen($bytes) > self::MAX_LOGO_BYTES) {
throw new \FOSSBilling\Exception('Payment gateway images must be no larger than 2 MB.');
}
}
} catch (ExceptionInterface) {
throw new \FOSSBilling\Exception('Could not download the payment gateway image. Use a publicly accessible image URL, no larger than 2 MB.');
} finally {
$response?->cancel();
}
return $this->storeGatewayLogo($bytes);
}
private function storeUploadedLogo(UploadedFile $file): string
{
if (!$file->isValid()) {
throw new \FOSSBilling\Exception('Payment gateway image upload failed.');
}
if ($file->getSize() > self::MAX_LOGO_BYTES) {
throw new \FOSSBilling\Exception('Payment gateway images must be no larger than 2 MB.');
}
return $this->storeGatewayLogo($this->filesystem->readFile($file->getPathname()));
}
private function storeGatewayLogo(string $bytes): string
{
if (strlen($bytes) > self::MAX_LOGO_BYTES) {
throw new \FOSSBilling\Exception('Payment gateway images must be no larger than 2 MB.');
}
$avif = null;
$image = @getimagesizefromstring($bytes);
$extension = $image === false ? null : match ($image['mime']) {
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'image/avif' => 'avif',
default => null,
};
if ($image === false) {
$bytes = GatewaySvgSanitizer::sanitize($bytes);
$extension = 'svg';
} elseif ($extension === null || $image[0] <= 0 || $image[1] <= 0 || $image[0] * $image[1] > self::MAX_LOGO_PIXELS) {
throw new \FOSSBilling\Exception('Use a PNG, JPEG, WebP, AVIF, or SVG image with no more than 4 million pixels.');
} else {
[$bytes, $extension, $avif] = $this->normalizeRasterLogo($bytes, $extension);
}
if (strlen($bytes) > self::MAX_LOGO_BYTES || ($avif !== null && strlen($avif) > self::MAX_LOGO_BYTES)) {
throw new \FOSSBilling\Exception('The processed payment gateway image exceeds 2 MB. Use a smaller image.');
}
// Gateway copies share stored images.
$basename = hash('sha256', $bytes);
$filename = $basename . '.' . $extension;
$this->filesystem->dumpFile(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', $filename), $bytes);
if ($avif !== null) {
$this->filesystem->dumpFile(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', $basename . '.avif'), $avif);
}
return $filename;
}
/** @return array{string, string, ?string} PNG fallback (or unsupported original), extension, optional AVIF. */
private function normalizeRasterLogo(string $bytes, string $extension): array
{
if (!function_exists('imagecreatefromstring') || !function_exists('imagepng')) {
return [$bytes, $extension, null];
}
$type = match ($extension) {
'png' => IMG_PNG,
'jpg' => IMG_JPG,
'webp' => IMG_WEBP,
'avif' => IMG_AVIF,
default => throw new \LogicException('Unsupported raster image format.'),
};
if (!(imagetypes() & $type) || !(imagetypes() & IMG_PNG)) {
return [$bytes, $extension, null];
}
$image = @imagecreatefromstring($bytes);
if ($image === false) {
throw new \FOSSBilling\Exception('Could not decode the payment gateway image.');
}
imagepalettetotruecolor($image);
imagesavealpha($image, true);
$png = $this->encodeRasterLogo($image, 'imagepng', 6);
$avif = function_exists('imageavif') && (imagetypes() & IMG_AVIF)
? $this->encodeRasterLogo($image, 'imageavif', 80)
: null;
return [$png, 'png', $avif];
}
private function encodeRasterLogo(\GdImage $image, string $encoder, int $quality): string
{
ob_start();
try {
if (!@$encoder($image, null, $quality) || ob_get_length() === 0) {
throw new \FOSSBilling\Exception('Could not process the payment gateway image.');
}
return (string) ob_get_contents();
} finally {
ob_end_clean();
}
}
public function resolveGatewayLogo(array $logoConfig): string
{
$filename = $logoConfig['logo'] ?? 'default.png';
@@ -36,7 +36,7 @@
</div>
{% endif %}
<form method="post" action="{{ 'invoice/gateway_update'|api_url }}" {{ fb_api_form({message: 'Gateway Updated'|trans}) }}>
<form method="post" action="{{ 'invoice/gateway_update'|api_url }}" enctype="multipart/form-data" {{ fb_api_form({message: 'Gateway Updated'|trans, reload: true}) }}>
{% embed 'partial_admin_form_card.html.twig' with {
title: 'Gateway Basics'|trans,
subtitle: 'Define the display title and choose which currencies can use this gateway.'|trans,
@@ -49,6 +49,26 @@
<label class="form-label" for="gateway_title">{{ 'Payment Gateway Title'|trans }}</label>
<input class="form-control" id="gateway_title" type="text" name="title" value="{{ gateway.title }}">
</div>
<div class="col-12">
<label class="form-label" for="gateway_logo_file">{{ 'Custom Image'|trans }}</label>
<input class="form-control" id="gateway_logo_file" type="file" name="logo_file" accept=".png,.jpg,.jpeg,.webp,.avif,.svg">
<div class="form-text">{{ 'PNG, JPEG, WebP, AVIF, or SVG, up to 2 MB. Leave empty to keep the current image. SVG sanitization may remove unsupported elements or styles.'|trans }}</div>
<label class="form-label mt-2" for="gateway_logo_url">{{ 'Or Import from URL'|trans }}</label>
<input class="form-control" id="gateway_logo_url" type="url" name="logo_url" maxlength="2048" placeholder="https://example.com/payment-methods.png">
<div class="form-text">{{ 'Use a publicly accessible image URL. Images are saved locally and preserved during updates.'|trans }}</div>
{% if gateway.custom_logo_url %}
<picture>
{% if gateway.custom_logo_avif_url %}
<source srcset="{{ gateway.custom_logo_avif_url }}" type="image/avif">
{% endif %}
<img class="mt-2" src="{{ gateway.custom_logo_url }}" alt="{{ 'Custom payment gateway image'|trans }}" style="max-width: 150px; max-height: 50px;">
</picture>
{% endif %}
<label class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="remove_custom_logo" value="1">
<span class="form-check-label">{{ 'Restore Default Image'|trans }}</span>
</label>
</div>
<div class="col-12">
<label class="form-label" for="gateway_accepted_currencies">{{ 'Accepted Currencies'|trans }}</label>
<div class="form-text mb-2">{{ 'Select which currencies can accept this payment gateway. Leave all unselected to allow all currencies.'|trans }}</div>
@@ -200,6 +220,18 @@
{% block js %}
<script>
(function () {
const removeLogo = document.querySelector('input[name="remove_custom_logo"]');
const logoUrl = document.getElementById('gateway_logo_url');
const logoFile = document.getElementById('gateway_logo_file');
if (removeLogo && logoUrl && logoFile) {
const updateImageInputs = () => {
logoUrl.disabled = removeLogo.checked || logoFile.files.length > 0;
logoFile.disabled = removeLogo.checked;
};
removeLogo.addEventListener('change', updateImageInputs);
logoFile.addEventListener('change', updateImageInputs);
updateImageInputs();
}
const enabledCheckbox = document.querySelector('input[type="checkbox"][name="enabled"]');
const testModeCheckbox = document.querySelector('input[type="checkbox"][name="test_mode"]');
@@ -49,9 +49,16 @@
<div class="invoice-gateway rounded-2 p-2">
<input type="radio" class="btn-check border-0" name="gateway_id" gateway_id="{{ gtw.id }}" id="{{ gtw.id }}" autocomplete="off">
<label class="btn" for="{{ gtw.id }}"
style="background: transparent url('{{gtw.logo.logo}}') no-repeat center center; background-size: contain; height:{{gtw.logo.height}}; width:{{gtw.logo.width}};"
style="height:{{ gtw.logo.height }}; width:{{ gtw.logo.width }}; padding: 0;"
onclick="paymentPrompt('{{ gtw.id }}', {{ gtw.allow_recurrent ? 'true' : 'false' }}, {{ gtw.allow_single ? 'true' : 'false' }}, `{{ gtw.title }}`)"
data-bs-toggle="tooltip" data-bs-title="{{ gtw.title }}"></label>
data-bs-toggle="tooltip" data-bs-title="{{ gtw.title }}">
<picture class="d-block h-100">
{% if gtw.logo.avif|default(null) %}
<source srcset="{{ gtw.logo.avif }}" type="image/avif">
{% endif %}
<img src="{{ gtw.logo.logo }}" alt="{{ gtw.title }}" style="width: 100%; height: 100%; object-fit: contain;">
</picture>
</label>
</div>
{% endif %}
{% endfor %}
@@ -1285,6 +1285,37 @@ test('updates a gateway', function (): void {
expect($result)->toBeBool()->toBeTrue();
});
test('uploads a gateway image from the multipart request', function (): void {
$api = apiEndpoint(new Admin());
$model = createEntity(PayGateway::class, ['id' => 1]);
$file = Mockery::mock(Symfony\Component\HttpFoundation\File\UploadedFile::class);
$service = Mockery::mock(ServicePayGateway::class);
$service->shouldReceive('update')->once()->with($model, ['id' => 1, 'title' => 'Cards'], $file)->andReturn(true);
$em = Mockery::mock(EntityManagerInterface::class);
$repo = Mockery::mock(PayGatewayRepository::class);
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn($repo);
$repo->shouldReceive('find')->with(1)->andReturn($model);
$di = container();
$di['em'] = $em;
$di['request']->files->set('logo_file', $file);
$di['mod_service'] = $di->protect(moduleService(['invoice:paygateway' => $service]));
$api->setDi($di);
expect($api->gateway_update(['id' => 1, 'title' => 'Cards']))->toBeTrue();
});
test('gateway image upload requires gateway management permission before processing the file', function (): void {
$api = apiEndpoint(new Admin());
$staff = Mockery::mock(Box\Mod\Staff\Service::class);
$staff->shouldReceive('checkPermissionsAndThrowException')->once()->with('invoice', 'manage_gateways', null, null)
->andThrow(new FOSSBilling\Exception('Denied'));
$di = container();
$di['mod_service'] = $di->protect(moduleService(['staff' => $staff]));
$api->setDi($di);
expect(fn () => $api->gateway_update(['id' => 1]))->toThrow(FOSSBilling\Exception::class, 'Denied');
});
test('deletes a gateway', function (): void {
$api = apiEndpoint(new Admin());
$data = [
@@ -0,0 +1,458 @@
<?php
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
declare(strict_types=1);
use Box\Mod\Invoice\Entity\PayGateway;
use Box\Mod\Invoice\Repository\PayGatewayRepository;
use Box\Mod\Invoice\ServicePayGateway;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use function Tests\Helpers\container;
use function Tests\Helpers\createEntity;
function gatewayLogoPng(): string
{
return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAYAAA4AAbGa6gYAAAAASUVORK5CYII=');
}
function gatewayLogoAvif(): string
{
return base64_decode('AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADrbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAAOcGl0bQAAAAAAAQAAAB5pbG9jAAAAAEQAAAEAAQAAAAEAAAETAAAAIgAAAChpaW5mAAAAAAABAAAAGmluZmUCAAAAAAEAAGF2MDFDb2xvcgAAAABqaXBycAAAAEtpcGNvAAAAFGlzcGUAAAAAAAAAAgAAAAIAAAAQcGl4aQAAAAADCAgIAAAADGF2MUOBAAwAAAAAE2NvbHJuY2x4AAEADQABgAAAABdpcG1hAAAAAAAAAAEAAQQBAoMEAAAAKm1kYXQSAAoIGAA2iAhoDCAyFBeHh4YhhJJJJlAAAABIVDZhrGGr');
}
function gatewayLogoCanNormalize(string $extension): bool
{
if (!function_exists('imagecreatefromstring') || !function_exists('imagepng')) {
return false;
}
$support = gd_info();
$format = match ($extension) {
'png' => 'PNG Support',
'jpg' => 'JPEG Support',
'webp' => 'WebP Support',
'avif' => 'AVIF Support',
default => '',
};
return ($support['PNG Support'] ?? false) && ($support[$format] ?? false);
}
function gatewayLogoService(MockHttpClient $client, Filesystem $filesystem): ServicePayGateway
{
$di = container();
$repo = Mockery::mock(PayGatewayRepository::class);
$em = Mockery::mock(EntityManagerInterface::class);
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn($repo);
$em->shouldReceive('flush')->byDefault();
$di['em'] = $em;
$di['http_client'] = $client;
$di['filesystem'] = $filesystem;
$di['url'] = Mockery::mock(FOSSBilling\Url::class);
$di['url']->shouldReceive('link')->andReturn('https://billing.example/');
$service = new ServicePayGateway();
$service->setDi($di);
return $service;
}
function captureGatewayLogo(Filesystem $filesystem): object
{
$stored = new stdClass();
$stored->files = [];
$filesystem->shouldReceive('dumpFile')->atLeast()->once()->andReturnUsing(static function (string $path, string $bytes) use ($stored): void {
$stored->files[basename($path)] = $bytes;
if (!isset($stored->bytes)) {
$stored->bytes = $bytes;
$stored->filename = basename($path);
}
expect($path)->toBe(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', basename($path)));
});
return $stored;
}
test('gateway image import stores detected image bytes locally and does not retain the source URL', function (): void {
$bytes = gatewayLogoPng();
$filesystem = Mockery::mock(Filesystem::class);
$stored = captureGatewayLogo($filesystem);
$client = new MockHttpClient(new MockResponse($bytes));
$service = gatewayLogoService($client, $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
expect($service->update($gateway, ['logo_url' => 'https://8.8.8.8/image.php']))->toBeTrue();
expect($gateway->getCustomLogo())->toBe(hash('sha256', $stored->bytes) . '.png')
->and($gateway->getConfig())->toBeNull();
});
test('gateway image is preserved on ordinary updates and can be reset without fetching', function (): void {
$client = new MockHttpClient(static fn () => throw new LogicException('Must not fetch'));
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('dumpFile')->never();
$service = gatewayLogoService($client, $filesystem);
$filename = str_repeat('a', 64) . '.png';
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'customLogo' => $filename]);
$service->update($gateway, ['title' => 'Cards']);
$service->update($gateway, ['logo_url' => '']);
expect($gateway->getCustomLogo())->toBe($filename);
$service->update($gateway, ['remove_custom_logo' => '0']);
expect($gateway->getCustomLogo())->toBe($filename);
$service->update($gateway, ['remove_custom_logo' => '1', 'logo_url' => 'https://8.8.8.8/logo.png']);
expect($gateway->getCustomLogo())->toBeNull();
});
test('gateway image rejects invalid or private URLs without making a request or changing settings', function (mixed $url): void {
$client = new MockHttpClient(static fn () => throw new LogicException('Must not fetch'));
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('dumpFile')->never();
$service = gatewayLogoService($client, $filesystem);
$service->getDi()['em']->shouldReceive('flush')->never();
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'name' => 'Original', 'customLogo' => 'original.png']);
expect(fn () => $service->update($gateway, ['title' => 'Changed', 'logo_url' => $url]))->toThrow(FOSSBilling\Exception::class);
expect($gateway->getName())->toBe('Original')->and($gateway->getCustomLogo())->toBe('original.png');
})->with([
'file:///etc/passwd', 'ftp://8.8.8.8/logo.png', 'https://',
'https://user:pass@8.8.8.8/logo.png', 'http://127.0.0.1/logo.png',
'http://169.254.169.254/logo.png', 'http://192.168.1.1/logo.png',
'http://[::1]/logo.png', 'http://localhost/logo.png',
[null], [['https://8.8.8.8/logo.png']],
]);
test('gateway image rejects unsuccessful or unsafe downloads', function (string $bytes, int $status): void {
$client = new MockHttpClient(new MockResponse($bytes, ['http_code' => $status]));
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('dumpFile')->never();
$service = gatewayLogoService($client, $filesystem);
$service->getDi()['em']->shouldReceive('flush')->never();
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'customLogo' => 'original.png']);
expect(fn () => $service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']))->toThrow(FOSSBilling\Exception::class);
expect($gateway->getCustomLogo())->toBe('original.png');
})->with([
'HTML' => ['<html>not an image</html>', 200],
'invalid SVG' => ['<svg><path></svg>', 200],
// cspell:disable-next-line
'GIF' => [base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'), 200],
'oversized' => [str_repeat('x', 2 * 1024 * 1024 + 1), 200],
'not found' => ['', 404],
]);
test('gateway image rejects redirects to private networks', function (): void {
$client = new MockHttpClient(new MockResponse('', [
'http_code' => 302,
'response_headers' => ['location: http://127.0.0.1/logo.png'],
'redirect_url' => 'http://127.0.0.1/logo.png',
]));
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('dumpFile')->never();
$service = gatewayLogoService($client, $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
expect(fn () => $service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']))->toThrow(FOSSBilling\Exception::class);
});
test('active gateways use the imported local image and fall back if it is missing', function (bool $exists): void {
$filename = str_repeat('a', 64) . '.png';
$gateway = createEntity(PayGateway::class, ['gateway' => 'Custom', 'customLogo' => $filename, 'acceptedCurrencies' => '["USD"]']);
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('exists')->with(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', $filename))->andReturn($exists);
$filesystem->shouldReceive('exists')->with(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', str_repeat('a', 64) . '.avif'))->andReturn(false);
$service = gatewayLogoService(new MockHttpClient(), $filesystem);
$service->getPayGatewayRepository()->shouldReceive('findEnabledOrderedByIdDesc')->andReturn([$gateway]);
$filesystem->shouldReceive('exists')->with(Path::join(PATH_LIBRARY, 'Payment', 'Adapter', 'default.png'))->andReturn(false);
$filesystem->shouldReceive('exists')->with(Path::join(PATH_ROOT, 'public', 'gateways', 'default.png'))->andReturn(true);
$filesystem->shouldReceive('exists')->with(Path::join(PATH_LIBRARY, 'Payment', 'Adapter', 'custom.png'))->andReturn(false);
$filesystem->shouldReceive('exists')->with(Path::join(PATH_ROOT, 'public', 'gateways', 'custom.png'))->andReturn(false);
$logo = $service->getActive([])[0]['logo'];
expect($logo['logo'])->toBe(SYSTEM_URL . ($exists ? 'public/gateways/custom/' . $filename : 'public/gateways/default.png'));
})->with([true, false]);
test('gateway image imports through a public redirect', function (): void {
$bytes = gatewayLogoPng();
$filesystem = Mockery::mock(Filesystem::class);
$stored = captureGatewayLogo($filesystem);
$client = new MockHttpClient([
new MockResponse('', ['http_code' => 302, 'response_headers' => ['location: https://1.1.1.1/logo.png'], 'redirect_url' => 'https://1.1.1.1/logo.png']),
new MockResponse($bytes),
]);
$service = gatewayLogoService($client, $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
$service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']);
expect($gateway->getCustomLogo())->toBe(hash('sha256', $stored->bytes) . '.png');
});
test('gateway image rejects a changed connection address and excessive advertised size', function (string $ip, int $size): void {
$client = new MockHttpClient(static function (string $method, string $url, array $options) use ($ip, $size): MockResponse {
$options['on_progress'](0, $size, ['primary_ip' => $ip, 'url' => $url]);
return new MockResponse(gatewayLogoPng());
});
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('dumpFile')->never();
$service = gatewayLogoService($client, $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
expect(fn () => $service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']))->toThrow(FOSSBilling\Exception::class);
})->with([['127.0.0.1', 0], ['8.8.8.8', 2 * 1024 * 1024 + 1]]);
test('gateway settings render image import and default restoration controls', function (?string $imageUrl): void {
$renderer = new Tests\Support\StrictTemplateRenderer();
$html = $renderer->renderTemplate(PATH_MODS . '/Invoice/templates/admin/mod_invoice_gateway.html.twig', [
'gateway' => [
'id' => 1, 'title' => 'Cards', 'description' => null, 'form' => [], 'custom_logo_url' => $imageUrl, 'custom_logo_avif_url' => $imageUrl === null ? null : str_replace('.png', '.avif', $imageUrl),
'accepted_currencies' => ['USD'], 'supports_one_time_payments' => true, 'supports_subscriptions' => false,
'enabled' => true, 'test_mode' => false, 'allow_single' => true, 'callback' => 'https://billing.example/ipn.php',
],
]);
expect($html)->toContain('name="logo_url"')->toContain('name="remove_custom_logo"')
->toContain('name="logo_file"')->toContain('enctype="multipart/form-data"');
if ($imageUrl !== null) {
expect($html)->toContain('src="' . $imageUrl . '"')->toContain('type="image/avif"')
->toContain('srcset="' . str_replace('.png', '.avif', $imageUrl) . '"');
}
})->with([null, 'https://billing.example/public/gateways/custom/' . str_repeat('a', 64) . '.png']);
test('gateway image upload detects the format from bytes rather than the filename', function (string $bytes, string $extension): void {
$path = tempnam(sys_get_temp_dir(), 'gateway-logo-');
$realFilesystem = new Filesystem();
$realFilesystem->dumpFile($path, $bytes);
try {
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('readFile')->with($path)->andReturn($bytes);
$stored = captureGatewayLogo($filesystem);
$service = gatewayLogoService(new MockHttpClient(static fn () => throw new LogicException('Must not fetch')), $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'customLogo' => 'old.png']);
$file = new UploadedFile($path, 'misleading.gif', 'image/gif', UPLOAD_ERR_OK, true);
expect($service->update($gateway, ['title' => 'Cards', 'logo_url' => 'ignored because a file is selected'], $file))->toBeTrue();
expect($gateway->getName())->toBe('Cards');
$outputExtension = gatewayLogoCanNormalize($extension) ? 'png' : $extension;
expect($gateway->getCustomLogo())->toBe(hash('sha256', $stored->bytes) . '.' . $outputExtension);
if ($extension === 'svg') {
expect($stored->bytes)->not->toContain('<script');
expect($stored->files)->toHaveCount(1);
} else {
$original = getimagesizefromstring($bytes);
$processed = getimagesizefromstring($stored->bytes);
expect($processed[0])->toBe($original[0])->and($processed[1])->toBe($original[1])->and($processed['mime'])->toBe(gatewayLogoCanNormalize($extension) ? 'image/png' : $original['mime']);
}
} finally {
$realFilesystem->remove($path);
}
})->with([
'PNG' => [gatewayLogoPng(), 'png'],
'AVIF' => [gatewayLogoAvif(), 'avif'],
'legacy file with misleading JPEG extension' => [(new Filesystem())->readFile(PATH_ROOT . '/public/gateways/webmoney.jpg'), 'png'],
'WebP' => [base64_decode('UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoCAAIAAUAmJaQAA3AA/v02aAA='), 'webp'],
'SVG' => ['<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><path d="M0 0L1 1"/></svg>', 'svg'],
]);
test('gateway image upload rejects errors, GIFs, and oversized files without changing the image', function (string $bytes, int $error): void {
$path = tempnam(sys_get_temp_dir(), 'gateway-logo-');
$realFilesystem = new Filesystem();
$realFilesystem->dumpFile($path, $bytes);
try {
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('readFile')->with($path)->andReturn($bytes);
$filesystem->shouldReceive('dumpFile')->never();
$service = gatewayLogoService(new MockHttpClient(), $filesystem);
$service->getDi()['em']->shouldReceive('flush')->never();
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'customLogo' => 'original.png', 'name' => 'Original']);
$file = new UploadedFile($path, 'logo.png', 'image/png', $error, true);
expect(fn () => $service->update($gateway, ['title' => 'Changed'], $file))->toThrow(FOSSBilling\Exception::class);
expect($gateway->getCustomLogo())->toBe('original.png')->and($gateway->getName())->toBe('Original');
} finally {
$realFilesystem->remove($path);
}
})->with([
// cspell:disable-next-line
'GIF' => [base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'), UPLOAD_ERR_OK],
'too large' => [str_repeat('x', 2 * 1024 * 1024 + 1), UPLOAD_ERR_OK],
'failed upload' => [gatewayLogoPng(), UPLOAD_ERR_PARTIAL],
]);
test('gateway URL import supports AVIF and sanitizes SVG before storing it', function (string $bytes, string $extension): void {
$filesystem = Mockery::mock(Filesystem::class);
$stored = captureGatewayLogo($filesystem);
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
$service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo']);
$outputExtension = gatewayLogoCanNormalize($extension) ? 'png' : $extension;
expect($gateway->getCustomLogo())->toBe(hash('sha256', $stored->bytes) . '.' . $outputExtension);
if ($extension === 'svg') {
expect($stored->bytes)->not->toContain('<script')->toContain('<path');
} else {
expect(getimagesizefromstring($stored->bytes)['mime'])->toBe(gatewayLogoCanNormalize('avif') ? 'image/png' : 'image/avif');
}
})->with([
'AVIF' => [gatewayLogoAvif(), 'avif'],
'SVG' => ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><script>alert(1)</script><path d="M0 0L10 10"/></svg>', 'svg'],
]);
test('gateway SVG sanitizer preserves artwork while removing active and external content', function (): void {
$svg = '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)" viewBox="0 0 10 10">'
. '<defs><linearGradient id="g"><stop offset="0" stop-color="red"/></linearGradient></defs>'
. '<style>@import "https://evil.example/style.css";</style><script>alert(1)</script>'
. '<foreignObject><iframe src="https://evil.example"/></foreignObject>'
. '<image href="https://evil.example/logo.png"/><use href="#recursive"/>'
. '<path transform="translate(1, 2)" d="M0 0L10 10" fill="url(#g)" style="stroke:red;fill:url(https://evil.example/a);opacity:0.5"/>'
. '<rect fill="&#117;rl(https://evil.example/a)"/>'
. '</svg>';
$clean = FOSSBilling\Sanitizer\GatewaySvgSanitizer::sanitize($svg);
expect($clean)->toContain('linearGradient')->toContain('fill="url(#g)"')->toContain('transform="translate(1, 2)"')
->toContain('style="stroke:red;opacity:0.5"')->not->toContain('evil.example')
->not->toContain('<script')->not->toContain('onload')->not->toContain('foreignObject')->not->toContain('<use');
});
test('gateway SVG rejects entities and non SVG documents', function (string $bytes): void {
expect(fn () => FOSSBilling\Sanitizer\GatewaySvgSanitizer::sanitize($bytes))->toThrow(FOSSBilling\Exception::class);
})->with([
'<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg xmlns="http://www.w3.org/2000/svg">&x;</svg>',
'<html><body>not svg</body></html>',
'<svg xmlns="http://www.w3.org/1999/xhtml"/>',
]);
test('raster normalization strips trailing data and preserves dimensions and transparency', function (): void {
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAcAABIAAW/6Y7cAAAAASUVORK5CYII=');
$bytes = $png . 'private-trailing-data';
$filesystem = Mockery::mock(Filesystem::class);
$stored = captureGatewayLogo($filesystem);
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
$service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']);
if (!function_exists('imagecreatefromstring')) {
expect($stored->bytes)->toBe($bytes);
return;
}
expect($stored->bytes)->not->toContain('private-trailing-data');
$image = imagecreatefromstring($stored->bytes);
expect(imagesx($image))->toBe(2)->and(imagesy($image))->toBe(2)
->and(imagecolorsforindex($image, imagecolorat($image, 0, 0))['alpha'])->toBe(127);
});
test('raster normalization rejects a PNG with valid headers but corrupt image data', function (): void {
if (!function_exists('imagecreatefromstring')) {
$this->markTestSkipped('GD decoding is unavailable.');
}
$bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII=');
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('dumpFile')->never();
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
expect(fn () => $service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']))
->toThrow(FOSSBilling\Exception::class, 'Could not decode the payment gateway image.');
});
test('raster images store PNG and at most one AVIF generated from the same decoded image', function (): void {
$bytes = gatewayLogoPng();
$filesystem = Mockery::mock(Filesystem::class);
$stored = captureGatewayLogo($filesystem);
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
$service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']);
$hasAvif = function_exists('imagecreatefromstring') && function_exists('imageavif') && (imagetypes() & IMG_AVIF);
expect($stored->files)->toHaveCount($hasAvif ? 2 : 1);
expect(getimagesizefromstring($stored->bytes)['mime'])->toBe('image/png');
if ($hasAvif) {
$avif = $stored->files[substr($gateway->getCustomLogo(), 0, -4) . '.avif'];
$size = getimagesizefromstring($avif);
expect($size['mime'])->toBe('image/avif')->and($size[0])->toBe(2)->and($size[1])->toBe(2);
}
});
test('active gateways offer AVIF only when its stored companion exists', function (bool $hasAvif): void {
$filename = str_repeat('a', 64) . '.png';
$gateway = createEntity(PayGateway::class, ['gateway' => 'Custom', 'customLogo' => $filename, 'acceptedCurrencies' => '["USD"]']);
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('exists')->andReturnUsing(static fn (string $path): bool => str_ends_with($path, '/custom/' . $filename) || ($hasAvif && str_ends_with($path, '/custom/' . str_repeat('a', 64) . '.avif')));
$service = gatewayLogoService(new MockHttpClient(), $filesystem);
$service->getPayGatewayRepository()->shouldReceive('findEnabledOrderedByIdDesc')->andReturn([$gateway]);
$logo = $service->getActive([])[0]['logo'];
expect($logo['logo'])->toBe(SYSTEM_URL . 'public/gateways/custom/' . $filename);
expect($logo['avif'])->toBe($hasAvif ? SYSTEM_URL . 'public/gateways/custom/' . str_repeat('a', 64) . '.avif' : null);
})->with([true, false]);
test('payment selectors render AVIF with one PNG fallback and support images without variants', function (string $template, bool $hasAvif): void {
$png = 'https://billing.example/logo.png';
$avif = 'https://billing.example/logo.avif';
$gateway = [
'id' => 1, 'title' => 'Cards', 'accepted_currencies' => ['USD'],
'allow_single' => true, 'allow_recurrent' => false,
'logo' => ['logo' => $png, 'height' => '50px', 'width' => '150px'],
];
if ($hasAvif) {
$gateway['logo']['avif'] = $avif;
}
$guest = new Tests\Support\PermissiveStub([
'invoice_gateways' => [$gateway],
'cart_get' => new Tests\Support\PermissiveStub([
'items' => [new Tests\Support\PermissiveStub(['id' => 1, 'title' => 'Service', 'quantity' => 1, 'total' => 10, 'discount_price' => 0, 'setup_price' => 0, 'discount_setup' => 0, 'form_id' => false, 'period' => false])],
'currency' => ['code' => 'USD', 'conversion_rate' => 1], 'total' => 10, 'discount' => 0, 'subtotal' => 10,
]),
]);
$html = (new Tests\Support\StrictTemplateRenderer())->renderTemplate(PATH_MODS . '/' . $template, [
'app_area' => 'client', 'client' => null, 'guest' => $guest,
'invoice' => new Tests\Support\PermissiveStub([
'status' => 'unpaid', 'currency' => 'USD', 'items' => [],
'credit_note_for_invoice_id' => false, 'debit_note_for_invoice_id' => false,
'refunded_by_invoice_ids' => [], 'debited_by_invoice_ids' => [],
'paid_at' => '', 'created_at' => '2026-10-12', 'tax' => 0, 'subtotal' => 10, 'total' => 10,
'buyer' => new Tests\Support\PermissiveStub(['country' => 'US']),
]),
'request' => new Tests\Support\PermissiveStub(['checkout' => true, 'show_custom_form_values' => false]),
]);
expect($html)->toContain('<picture')->toContain('src="' . $png . '"');
if ($hasAvif) {
expect($html)->toContain('srcset="' . $avif . '" type="image/avif"');
} else {
expect($html)->not->toContain('type="image/avif"');
}
})->with([
['Invoice/templates/client/mod_invoice_invoice.html.twig', true],
['Invoice/templates/client/mod_invoice_invoice.html.twig', false],
['Orderbutton/templates/client/mod_orderbutton_checkout.html.twig', true],
['Orderbutton/templates/client/mod_orderbutton_checkout.html.twig', false],
]);
test('gateway images above four million pixels are rejected before decoding or changing settings', function (): void {
$bytes = gatewayLogoPng();
// Change the PNG header dimensions without allocating a large decoded image.
$header = substr($bytes, 12, 4) . pack('NN', 2000, 2001) . substr($bytes, 24, 5);
$bytes = substr($bytes, 0, 12) . $header . hash('crc32b', $header, true) . substr($bytes, 33);
expect(getimagesizefromstring($bytes)[0])->toBe(2000);
expect(getimagesizefromstring($bytes)[1])->toBe(2001);
$filesystem = Mockery::mock(Filesystem::class);
$filesystem->shouldReceive('dumpFile')->never();
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
$service->getDi()['em']->shouldReceive('flush')->never();
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'name' => 'Original', 'customLogo' => 'original.png']);
expect(fn () => $service->update($gateway, ['title' => 'Changed', 'logo_url' => 'https://8.8.8.8/logo.png']))
->toThrow(FOSSBilling\Exception::class, 'no more than 4 million pixels');
expect($gateway->getName())->toBe('Original')->and($gateway->getCustomLogo())->toBe('original.png');
});
@@ -14,6 +14,8 @@ use Box\Mod\Invoice\Entity\PayGateway;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMSetup;
use Doctrine\ORM\Tools\SchemaTool;
use FOSSBilling\Doctrine\SchemaSynchronizer;
use Symfony\Component\Filesystem\Path;
function payGatewayEntityManager(): EntityManager
@@ -25,6 +27,32 @@ function payGatewayEntityManager(): EntityManager
return new EntityManager(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]), $config);
}
test('custom gateway image persists independently of adapter credentials', function (): void {
$em = payGatewayEntityManager();
(new SchemaTool($em))->createSchema([$em->getClassMetadata(PayGateway::class)]);
$gateway = (new PayGateway())->setGateway('Custom')->setConfig('{"api_key":"secret"}')->setCustomLogo(str_repeat('a', 64) . '.png');
$em->persist($gateway);
$em->flush();
$id = $gateway->getId();
$em->clear();
$stored = $em->find(PayGateway::class, $id);
expect($stored->getCustomLogo())->toBe(str_repeat('a', 64) . '.png')
->and($stored->getConfig())->toBe('{"api_key":"secret"}');
});
test('schema synchronization adds custom images to existing gateways', function (): void {
$em = payGatewayEntityManager();
(new SchemaTool($em))->createSchema([$em->getClassMetadata(PayGateway::class)]);
$em->getConnection()->executeStatement('ALTER TABLE pay_gateway DROP COLUMN custom_logo');
$em->getConnection()->insert('pay_gateway', ['name' => 'Existing', 'enabled' => 1, 'allow_single' => 1, 'allow_recurrent' => 0, 'test_mode' => 0]);
SchemaSynchronizer::syncEntities($em, [PayGateway::class]);
$row = $em->getConnection()->fetchAssociative('SELECT name, custom_logo FROM pay_gateway');
expect($row)->toBe(['name' => 'Existing', 'custom_logo' => null]);
});
test('getSearchQueryBuilder orders by gateway ascending by default', function (): void {
$repository = payGatewayEntityManager()->getRepository(PayGateway::class);
@@ -168,11 +168,12 @@ test('copies a gateway', function (): void {
'acceptedCurrencies' => null,
'testMode' => false,
'config' => null,
'customLogo' => str_repeat('a', 64) . '.png',
]);
$serviceMock = Mockery::mock(ServicePayGateway::class)->makePartial();
$em = Mockery::mock(EntityManagerInterface::class);
$em->shouldReceive('persist')->once();
$em->shouldReceive('persist')->once()->with(Mockery::on(fn (PayGateway $copy): bool => $copy->getCustomLogo() === $payGateway->getCustomLogo()));
$em->shouldReceive('flush')->once();
$repo = Mockery::mock(PayGatewayRepository::class);
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn($repo);
@@ -184,9 +184,16 @@
<input type="radio" class="btn-check border-0" name="gateway_id" id="{{ gateway_input_id }}" value="{{ gtw.id }}" autocomplete="off" {{ not gateway_selected ? 'checked' : '' }}/>
{% if gtw.logo.logo %}
<label class="btn" for="{{ gateway_input_id }}"
style="background: transparent url('{{ gtw.logo.logo }}') no-repeat center center; background-size: contain; height:{{ gtw.logo.height }}; width:{{ gtw.logo.width }};"
style="height:{{ gtw.logo.height }}; width:{{ gtw.logo.width }}; padding: 0;"
data-bs-toggle="tooltip" data-bs-title="{{ gtw.title }}"
aria-label="{{ gtw.title }}"></label>
aria-label="{{ gtw.title }}">
<picture class="d-block h-100">
{% if gtw.logo.avif|default(null) %}
<source srcset="{{ gtw.logo.avif }}" type="image/avif">
{% endif %}
<img src="{{ gtw.logo.logo }}" alt="{{ gtw.title }}" style="width: 100%; height: 100%; object-fit: contain;">
</picture>
</label>
{% else %}
<label class="btn text-nowrap" for="{{ gateway_input_id }}">{{ gtw.title }}</label>
{% endif %}
@@ -366,6 +366,7 @@
"pay_gateway.allow_recurrent",
"pay_gateway.allow_single",
"pay_gateway.config",
"pay_gateway.custom_logo",
"pay_gateway.enabled",
"pay_gateway.gateway",
"pay_gateway.id",
@@ -3486,3 +3486,25 @@ test('custom page charset repair follows the promotion patch', function (): void
$patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 127);
expect($patches)->toHaveKey(128)->and($patches[128][1])->toBe('patch128');
});
test('custom gateway image migration adds the column and skips already migrated gateways', function (bool $migrated): void {
$columns = Mockery::mock(PDOStatement::class);
$columns->expects('execute')->with([])->andReturnTrue();
$columns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn($migrated ? [['Field' => 'custom_logo']] : [['Field' => 'id']]);
$pdo = Mockery::mock(PDO::class);
$pdo->expects('prepare')->with('SHOW COLUMNS FROM `pay_gateway`')->andReturn($columns);
$sql = 'ALTER TABLE `pay_gateway` ADD COLUMN `custom_logo` VARCHAR(255) DEFAULT NULL';
if ($migrated) {
$pdo->shouldNotReceive('prepare')->with($sql);
} else {
$statement = Mockery::mock(PDOStatement::class);
$statement->expects('execute')->with([])->andReturnTrue();
$pdo->expects('prepare')->with($sql)->andReturn($statement);
}
$di = new Pimple\Container();
$di['pdo'] = $pdo;
$patcher = new UpdatePatcher();
$patcher->setDi($di);
(new ReflectionMethod($patcher, 'patch129'))->invoke($patcher);
})->with([false, true]);