Compare commits

..

No commits in common. "12b53f15ba57715863bf4db7715a2cd50c8e9ac9" and "43ac8c23c7d70361e0df5f7cf15127ca0d28722a" have entirely different histories.

14 changed files with 102 additions and 400 deletions

View file

@ -1,8 +1,8 @@
# Reproduces https://docs.part-db.de/installation/installation_guide-debian.html # Reproduces https://docs.part-db.de/installation/installation_guide-debian.html
# on Debian 13 (trixie) as closely as possible, for debugging issues reported # on Debian 12 (bookworm) as closely as possible, for debugging issues reported
# by users who followed that guide (Apache2 + mod_php, not the project's own # by users who followed that guide (Apache2 + mod_php, not the project's own
# FrankenPHP-based Dockerfile at the repo root). # FrankenPHP-based Dockerfile at the repo root).
FROM debian:13 FROM debian:12
ARG HOST_UID=1000 ARG HOST_UID=1000
ARG HOST_GID=1000 ARG HOST_GID=1000
@ -17,12 +17,12 @@ RUN apt-get update && apt-get upgrade -y && \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# --- PHP and Apache2 (guide: "PHP and Apache2 Setup") --- # --- PHP and Apache2 (guide: "PHP and Apache2 Setup") ---
# Debian 13 ships PHP 8.4 by default, so no extra PHP repo is needed. # Debian 12 ships PHP 8.2 by default, so no extra PHP repo is needed.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
apache2 php8.4 libapache2-mod-php8.4 \ apache2 php8.2 libapache2-mod-php8.2 \
php8.4-opcache php8.4-curl php8.4-gd php8.4-mbstring \ php8.2-opcache php8.2-curl php8.2-gd php8.2-mbstring \
php8.4-xml php8.4-bcmath php8.4-intl php8.4-zip php8.4-xsl \ php8.2-xml php8.2-bcmath php8.2-intl php8.2-zip php8.2-xsl \
php8.4-sqlite3 php8.4-mysql \ php8.2-sqlite3 php8.2-mysql \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# --- Composer (guide: "Composer Installation") --- # --- Composer (guide: "Composer Installation") ---

View file

@ -1 +1 @@
2.13.3 2.13.2

View file

@ -262,15 +262,7 @@ See the [information providers]({% link usage/information_provider_system.md %})
* `TRUSTED_PROXIES` (env only): Set the IP addresses (or IP blocks) of trusted reverse proxies here. This is needed to get correct * `TRUSTED_PROXIES` (env only): Set the IP addresses (or IP blocks) of trusted reverse proxies here. This is needed to get correct
IP information (see [here](https://symfony.com/doc/current/deployment/proxies.html) for more info). IP information (see [here](https://symfony.com/doc/current/deployment/proxies.html) for more info).
* `TRUSTED_HOSTS` (env only): To prevent `HTTP Host header attacks` you can set a regex containing all host names via which Part-DB * `TRUSTED_HOSTS` (env only): To prevent `HTTP Host header attacks` you can set a regex containing all host names via which Part-DB
should be accessible. If accessed via a hostname not matching the regex, an error page will be shown instead. By default this should be accessible. If accessed via the wrong hostname, an error will be shown.
is empty, meaning Part-DB accepts requests for any host name, which is not recommended for production use.
For example, if Part-DB should only be reachable under `part-db.example.invalid`, set:
```
TRUSTED_HOSTS='^(part-db\.example\.invalid)$'
```
You can specify multiple host names separated by `|`, e.g. `^(localhost|part-db\.example\.invalid)$`.
Part-DB displays a warning on the homepage (visible to administrators only) as long as this value is not set.
* `DEMO_MODE` (env only): Set Part-DB into demo mode, which forbids users to change their passwords and settings. Used for the demo * `DEMO_MODE` (env only): Set Part-DB into demo mode, which forbids users to change their passwords and settings. Used for the demo
instance. This should not be needed for normal installations. instance. This should not be needed for normal installations.
* `NO_URL_REWRITE_AVAILABLE` (allowed values `true` or `false`) (env only): Set this value to true, if your webserver does not * `NO_URL_REWRITE_AVAILABLE` (allowed values `true` or `false`) (env only): Set this value to true, if your webserver does not

View file

@ -85,10 +85,6 @@ services:
# If you use a reverse proxy in front of Part-DB, you must configure the trusted proxies IP addresses here (see reverse proxy documentation for more information): # If you use a reverse proxy in front of Part-DB, you must configure the trusted proxies IP addresses here (see reverse proxy documentation for more information):
# - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 # - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# To prevent HTTP Host header attacks, set this to a regex matching all host names Part-DB should be reachable under.
# By default (unset) Part-DB accepts requests for any host name, which is not recommended for production use.
# - TRUSTED_HOSTS=^(part-db\.example\.invalid)$
# If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here: # If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here:
# The packages will be installed automatically when the container starts # The packages will be installed automatically when the container starts
# - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer # - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
@ -100,10 +96,7 @@ services:
```bash ```bash
openssl rand -hex 32 openssl rand -hex 32
``` ```
6. Uncomment and set the `TRUSTED_HOSTS` variable to a regex matching the host name(s) Part-DB should be reachable under 6. Inside the folder, run
(e.g. `^(part-db\.example\.invalid)$`), to prevent HTTP Host header attacks. See [Configuration]({% link configuration.md %})
for more information.
7. Inside the folder, run
```bash ```bash
docker-compose up -d docker-compose up -d
@ -114,7 +107,7 @@ openssl rand -hex 32
> Otherwise Part-DB console might use the wrong configuration to execute commands. > Otherwise Part-DB console might use the wrong configuration to execute commands.
8. Create the initial database with 7. Create the initial database with
```bash ```bash
docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
@ -122,7 +115,7 @@ docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
and watch for the password output and watch for the password output
9. Part-DB is available under `http://localhost:8080` and you can log in with the username `admin` and the password shown 8. Part-DB is available under `http://localhost:8080` and you can log in with the username `admin` and the password shown
before before
The docker image uses a SQLite database and all data (database, uploads, and other media) is put into folders relative to The docker image uses a SQLite database and all data (database, uploads, and other media) is put into folders relative to
@ -179,10 +172,6 @@ services:
# When this is commented out the webUI can be used to configure the banner # When this is commented out the webUI can be used to configure the banner
#- BANNER=This is a test banner<br>with a line break #- BANNER=This is a test banner<br>with a line break
# To prevent HTTP Host header attacks, set this to a regex matching all host names Part-DB should be reachable under.
# By default (unset) Part-DB accepts requests for any host name, which is not recommended for production use.
# - TRUSTED_HOSTS=^(part-db\.example\.invalid)$
# If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here: # If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here:
# - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer # - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer

View file

@ -1,13 +1,13 @@
--- ---
title: Direct Installation on Debian 13 title: Direct Installation on Debian 12
layout: default layout: default
parent: Installation parent: Installation
nav_order: 4 nav_order: 4
--- ---
# Part-DB installation guide for Debian 13 (Trixie) # Part-DB installation guide for Debian 12 (Bookworm)
This guide shows you how to install Part-DB directly on Debian 13 using apache2 and SQLite. This guide should work with This guide shows you how to install Part-DB directly on Debian 12 using apache2 and SQLite. This guide should work with
recent Ubuntu and other Debian-based distributions with little to no changes. recent Ubuntu and other Debian-based distributions with little to no changes.
Depending on what you want to do, using the prebuilt docker images may be a better choice, as you don't need to install Depending on what you want to do, using the prebuilt docker images may be a better choice, as you don't need to install
this many dependencies. See [here]({% link installation/installation_docker.md %}) for more information on the docker this many dependencies. See [here]({% link installation/installation_docker.md %}) for more information on the docker
@ -45,10 +45,10 @@ compatibility.
Install PHP with required extensions and apache2: Install PHP with required extensions and apache2:
```bash ```bash
sudo apt install apache2 php8.4 libapache2-mod-php8.4 \ sudo apt install apache2 php8.2 libapache2-mod-php8.2 \
php8.4-opcache php8.4-curl php8.4-gd php8.4-mbstring \ php8.2-opcache php8.2-curl php8.2-gd php8.2-mbstring \
php8.4-xml php8.4-bcmath php8.4-intl php8.4-zip php8.4-xsl \ php8.2-xml php8.2-bcmath php8.2-intl php8.2-zip php8.2-xsl \
php8.4-sqlite3 php8.4-mysql php8.2-sqlite3 php8.2-mysql
``` ```
### Install composer ### Install composer
@ -145,16 +145,6 @@ A full list of configuration options can be found [here](../configuration.md).
> ``` > ```
> or edit the file with a text editor and add a new value for `APP_SECRET` (you can generate a random value with `openssl rand -hex 32`). > or edit the file with a text editor and add a new value for `APP_SECRET` (you can generate a random value with `openssl rand -hex 32`).
{: .important }
> **Set `TRUSTED_HOSTS` before going live.** By default Part-DB accepts requests for any host name, which makes it
> vulnerable to `HTTP Host header attacks`. Edit your `.env.local` with a text editor and set `TRUSTED_HOSTS` to a
> regex matching all host names Part-DB should be reachable under, e.g. if Part-DB should only be reachable via
> `part-db.example.invalid`:
> ```
> TRUSTED_HOSTS='^(part-db\.example\.invalid)$'
> ```
> See the [configuration options](../configuration.md) page for more information about `TRUSTED_HOSTS`.
Please check that the configured base currency matches your mainly used currency, as Please check that the configured base currency matches your mainly used currency, as
this can not be changed after creating price information. this can not be changed after creating price information.
@ -301,7 +291,7 @@ sudo -u www-data php bin/console cache:clear
## MySQL/MariaDB database ## MySQL/MariaDB database
To use a MySQL database, follow the steps from above (except the creation of the database, we will do this later). To use a MySQL database, follow the steps from above (except the creation of the database, we will do this later).
Debian does not ship MySQL in its repositories anymore, so we use the compatible MariaDB instead: Debian 12 does not ship MySQL in its repositories anymore, so we use the compatible MariaDB instead:
1. Install maria-db with: 1. Install maria-db with:

View file

@ -22,16 +22,6 @@ TRUSTED_PROXIES=192.168.2.10
Set the `DEFAULT_URI` environment variable to the URL of your Part-DB installation, available from the outside (so via Set the `DEFAULT_URI` environment variable to the URL of your Part-DB installation, available from the outside (so via
the reverse proxy). the reverse proxy).
You should also set the `TRUSTED_HOSTS` environment variable to a regex matching the host name(s) Part-DB is reachable
under via the reverse proxy, to prevent `HTTP Host header attacks`. For example, if Part-DB is reachable via
`part-db.example.invalid`, set:
```
TRUSTED_HOSTS='^(part-db\.example\.invalid)$'
```
See the [configuration options](../configuration.md) page for more information about `TRUSTED_HOSTS`.
## Part-DB in a subpath via reverse proxy ## Part-DB in a subpath via reverse proxy
If you put Part-DB into a subpath via the reverse proxy, you have to configure your webserver to include `X-Forwarded-Prefix` in the request headers. If you put Part-DB into a subpath via the reverse proxy, you have to configure your webserver to include `X-Forwarded-Prefix` in the request headers.

View file

@ -136,9 +136,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
} }
} }
//First we try the search v3 endpoint, which seems to give better results including pictures, but it only works $response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
//on quite exact mpn matches
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v3/global", [
'headers' => [ 'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency) 'Cookie' => new Cookie('currencyCode', $this->settings->currency)
], ],
@ -149,29 +147,20 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
$arr = $response->toArray(); $arr = $response->toArray();
//If we get exact matches, use them // Get products list
if (!empty($arr['result']['exactMatchResult'])) { $products = $arr['result']['productSearchResultVO']['productList'] ?? [];
$products = $arr['result']['exactMatchResult']; // Get product tip
} else { //Otherwise fallback onto the third search endpoint, which has a worse data quality but is more likely to return results for vague search terms $tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null;
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL."/search/third", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'json' => [
'keyword' => $term,
'currentPage' => 1,
'pageSize' => 10,
],
]);
$arr = $response->toArray();
// Get products list
$products = $arr['result']['productList'] ?? [];
}
$result = []; $result = [];
// LCSC does not display LCSC codes in the search, instead taking you directly to the
// detailed product listing. It does so utilizing a product tip field.
// If product tip exists and there are no products in the product list try a detail query
if (count($products) === 0 && $tipProductCode !== null) {
$result[] = $this->queryDetail($tipProductCode, $lightweight);
}
foreach ($products as $product) { foreach ($products as $product) {
$result[] = $this->getPartDetail($product, $lightweight); $result[] = $this->getPartDetail($product, $lightweight);
} }
@ -230,7 +219,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
provider_key: $this->getProviderKey(), provider_key: $this->getProviderKey(),
provider_id: $product['productCode'], provider_id: $product['productCode'],
name: $product['productModel'], name: $product['productModel'],
description: $this->sanitizeField($product['productIntroEn']) ?? '', description: $this->sanitizeField($product['productIntroEn']),
category: $this->sanitizeField($category ?? null), category: $this->sanitizeField($category ?? null),
manufacturer: $this->sanitizeField($product['brandNameEn'] ?? null), manufacturer: $this->sanitizeField($product['brandNameEn'] ?? null),
mpn: $this->sanitizeField($product['productModel'] ?? null), mpn: $this->sanitizeField($product['productModel'] ?? null),
@ -394,7 +383,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
]); ]);
} else { } else {
// Search API call for other terms // Search API call for other terms
$responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v3/global", [ $responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
'headers' => [ 'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency) 'Cookie' => new Cookie('currencyCode', $this->settings->currency)
], ],

View file

@ -1,96 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Generates absolute URLs for routes whose host can be trusted, for use in contexts where the host must not
* be attacker-controllable (e.g. links sent out via email).
*
* If no TRUSTED_HOSTS is configured, Symfony does not validate the Host header of the incoming request in
* any way, so it must not be trusted to generate such links (otherwise an attacker could poison them via a
* forged Host header). In that case, the host/scheme/base path are forced to the ones configured via
* DEFAULT_URI instead of the ones from the current HTTP request.
* If TRUSTED_HOSTS is configured, Symfony already rejects requests with a non-matching Host header, so the
* request's host can be trusted and is used as usual (which allows the app to be reachable under multiple
* trusted hostnames).
*/
final class TrustedUrlGenerator
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly TrustedHostsChecker $trustedHostsChecker,
#[Autowire('%partdb.default_uri%')]
private readonly string $defaultUri,
) {
}
/**
* @param array<string, mixed> $parameters
*/
public function generate(string $route, array $parameters = []): string
{
//If TRUSTED_HOSTS is configured, Symfony already validated the request's Host header, so it can be trusted
if (!$this->trustedHostsChecker->isTrustedHostsUnconfigured()) {
return $this->urlGenerator->generate($route, $parameters, UrlGeneratorInterface::ABSOLUTE_URL);
}
$parts = parse_url(rtrim($this->defaultUri, '/'));
$context = $this->urlGenerator->getContext();
//Backup the original context, so we can restore it after generating the URL
$original = [
'host' => $context->getHost(),
'scheme' => $context->getScheme(),
'httpPort' => $context->getHttpPort(),
'httpsPort' => $context->getHttpsPort(),
'baseUrl' => $context->getBaseUrl(),
];
$scheme = $parts['scheme'] ?? 'https';
$context->setScheme($scheme);
$context->setHost($parts['host'] ?? '');
if (isset($parts['port'])) {
if ($scheme === 'https') {
$context->setHttpsPort($parts['port']);
} else {
$context->setHttpPort($parts['port']);
}
}
$context->setBaseUrl($parts['path'] ?? '');
try {
return $this->urlGenerator->generate($route, $parameters, UrlGeneratorInterface::ABSOLUTE_URL);
} finally {
//Restore the original context, so the rest of the request is not affected
$context->setHost($original['host']);
$context->setScheme($original['scheme']);
$context->setHttpPort($original['httpPort']);
$context->setHttpsPort($original['httpsPort']);
$context->setBaseUrl($original['baseUrl']);
}
}
}

View file

@ -23,7 +23,6 @@ declare(strict_types=1);
namespace App\Services\UserSystem; namespace App\Services\UserSystem;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use App\Services\System\TrustedUrlGenerator;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
@ -40,8 +39,7 @@ class PasswordResetManager
public function __construct(protected MailerInterface $mailer, protected EntityManagerInterface $em, public function __construct(protected MailerInterface $mailer, protected EntityManagerInterface $em,
protected TranslatorInterface $translator, protected UserPasswordHasherInterface $userPasswordEncoder, protected TranslatorInterface $translator, protected UserPasswordHasherInterface $userPasswordEncoder,
PasswordHasherFactoryInterface $encoderFactory, PasswordHasherFactoryInterface $encoderFactory)
protected TrustedUrlGenerator $trustedUrlGenerator)
{ {
$this->passwordEncoder = $encoderFactory->getPasswordHasher(User::class); $this->passwordEncoder = $encoderFactory->getPasswordHasher(User::class);
} }
@ -65,9 +63,6 @@ class PasswordResetManager
$user->setPwResetExpires($expiration_date); $user->setPwResetExpires($expiration_date);
if ($user->getEmail() !== null && $user->getEmail() !== '') { if ($user->getEmail() !== null && $user->getEmail() !== '') {
$reset_url = $this->trustedUrlGenerator->generate('pw_reset_new_pw', ['user' => $user->getName(), 'token' => $unencrypted_token]);
$reset_url_fallback = $this->trustedUrlGenerator->generate('pw_reset_new_pw');
$address = new Address($user->getEmail(), $user->getFullName()); $address = new Address($user->getEmail(), $user->getFullName());
$mail = new TemplatedEmail(); $mail = new TemplatedEmail();
$mail->to($address); $mail->to($address);
@ -77,8 +72,6 @@ class PasswordResetManager
'expiration_date' => $expiration_date, 'expiration_date' => $expiration_date,
'token' => $unencrypted_token, 'token' => $unencrypted_token,
'user' => $user, 'user' => $user,
'reset_url' => $reset_url,
'reset_url_fallback' => $reset_url_fallback,
]); ]);
//Send email //Send email

View file

@ -72,7 +72,7 @@ class CanopySettings
/** /**
* @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant * @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
*/ */
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'choice_translation_domain' => false])] #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'translation_domain' => false])]
public string $domain = "DE"; public string $domain = "DE";
/** /**

View file

@ -6,9 +6,9 @@
<h4>{% trans with {'%name%': user.fullName|escape } %}email.hi %name%{% endtrans %},</h4> <h4>{% trans with {'%name%': user.fullName|escape } %}email.hi %name%{% endtrans %},</h4>
{% trans %}email.pw_reset.message{% endtrans %} {% trans %}email.pw_reset.message{% endtrans %}
<br> <br>
<button class="large expand" href="{{ reset_url }}">{% trans %}email.pw_reset.button{% endtrans %}</button> <button class="large expand" href="{{ url('pw_reset_new_pw', {user: user.name, token: token}) }}">{% trans %}email.pw_reset.button{% endtrans %}</button>
<br> <br>
{% trans with {'%url%': reset_url_fallback } %}email.pw_reset.fallback{% endtrans %}: {% trans with {'%url%': url('pw_reset_new_pw') } %}email.pw_reset.fallback{% endtrans %}:
<callout class="secondary"> <callout class="secondary">
<row> <row>
<columns> <columns>

View file

@ -133,21 +133,25 @@ final class LCSCProviderTest extends TestCase
{ {
$mockResponse = new MockResponse(json_encode([ $mockResponse = new MockResponse(json_encode([
'result' => [ 'result' => [
'exactMatchResult' => [[ 'productSearchResultVO' => [
'productCode' => 'C789012', 'productList' => [
'productModel' => 'Regular Component', [
'productIntroEn' => 'Regular description', 'productCode' => 'C789012',
'brandNameEn' => 'Regular Manufacturer', 'productModel' => 'Regular Component',
'encapStandard' => '0805', 'productIntroEn' => 'Regular description',
'productImageUrl' => 'https://example.com/regular.jpg', 'brandNameEn' => 'Regular Manufacturer',
'productImages' => ['https://example.com/regular1.jpg'], 'encapStandard' => '0805',
'productPriceList' => [ 'productImageUrl' => 'https://example.com/regular.jpg',
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€'] 'productImages' => ['https://example.com/regular1.jpg'],
], 'productPriceList' => [
'paramVOList' => [], ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€']
'pdfUrl' => null, ],
'weight' => null 'paramVOList' => [],
]] 'pdfUrl' => null,
'weight' => null
]
]
]
] ]
])); ]));
@ -162,6 +166,45 @@ final class LCSCProviderTest extends TestCase
$this->assertSame('Regular Component', $results[0]->name); $this->assertSame('Regular Component', $results[0]->name);
} }
public function testSearchByKeywordWithTipProduct(): void
{
$mockResponse = new MockResponse(json_encode([
'result' => [
'productSearchResultVO' => [
'productList' => []
],
'tipProductDetailUrlVO' => [
'productCode' => 'C555555'
]
]
]));
$detailResponse = new MockResponse(json_encode([
'result' => [
'productCode' => 'C555555',
'productModel' => 'Tip Component',
'productIntroEn' => 'Tip description',
'brandNameEn' => 'Tip Manufacturer',
'encapStandard' => '1206',
'productImageUrl' => null,
'productImages' => [],
'productPriceList' => [],
'paramVOList' => [],
'pdfUrl' => null,
'weight' => null
]
]));
$this->httpClient->setResponseFactory([$mockResponse, $detailResponse]);
$results = $this->provider->searchByKeyword('special');
$this->assertIsArray($results);
$this->assertCount(1, $results);
$this->assertInstanceOf(PartDetailDTO::class, $results[0]);
$this->assertSame('C555555', $results[0]->provider_id);
$this->assertSame('Tip Component', $results[0]->name);
}
public function testSearchByKeywordsBatch(): void public function testSearchByKeywordsBatch(): void
{ {
@ -267,7 +310,7 @@ final class LCSCProviderTest extends TestCase
] ]
])); ]));
$this->httpClient->setResponseFactory([$mockResponse, $mockResponse]); $this->httpClient->setResponseFactory([$mockResponse]);
$this->expectException(\RuntimeException::class); $this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('No part found with ID INVALID'); $this->expectExceptionMessage('No part found with ID INVALID');
@ -279,7 +322,8 @@ final class LCSCProviderTest extends TestCase
{ {
$mockResponse = new MockResponse(json_encode([ $mockResponse = new MockResponse(json_encode([
'result' => [ 'result' => [
'exactMatchResult' => [ 'productSearchResultVO' => [
'productList' => [
[ [
'productCode' => 'C123456', 'productCode' => 'C123456',
'productModel' => 'Component 1', 'productModel' => 'Component 1',
@ -306,6 +350,7 @@ final class LCSCProviderTest extends TestCase
'pdfUrl' => null, 'pdfUrl' => null,
'weight' => null 'weight' => null
] ]
]
] ]
] ]
])); ]));

View file

@ -1,172 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\System;
use App\Services\System\TrustedHostsChecker;
use App\Services\System\TrustedUrlGenerator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RequestContext;
final class TrustedUrlGeneratorTest extends TestCase
{
private function createGenerator(UrlGeneratorInterface $urlGenerator, string $trustedHosts, string $defaultUri): TrustedUrlGenerator
{
return new TrustedUrlGenerator($urlGenerator, new TrustedHostsChecker($trustedHosts), $defaultUri);
}
public function testUsesCurrentRequestContextWhenTrustedHostsIsConfigured(): void
{
//If TRUSTED_HOSTS is configured, the request's host is already validated by Symfony, so it should be used as-is
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('getContext');
$urlGenerator->expects($this->once())
->method('generate')
->with('pw_reset_new_pw', ['user' => 'john', 'token' => 'abc'], UrlGeneratorInterface::ABSOLUTE_URL)
->willReturn('https://request-host.example.com/pw_reset/new_pw/john/abc');
$generator = $this->createGenerator($urlGenerator, 'example\.com', 'https://trusted.example.com/');
$url = $generator->generate('pw_reset_new_pw', ['user' => 'john', 'token' => 'abc']);
$this->assertSame('https://request-host.example.com/pw_reset/new_pw/john/abc', $url);
}
public function testForcesDefaultUriHostWhenTrustedHostsIsNotConfigured(): void
{
//If TRUSTED_HOSTS is not configured, the request's Host header is attacker-controllable, so the
//host/scheme/base path configured via DEFAULT_URI must be used instead.
$context = new RequestContext();
$context->setScheme('http');
$context->setHost('attacker.evil');
$context->setBaseUrl('');
$capturedHost = null;
$capturedScheme = null;
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('getContext')->willReturn($context);
$urlGenerator->expects($this->once())
->method('generate')
->with('pw_reset_new_pw', ['user' => 'john', 'token' => 'abc'], UrlGeneratorInterface::ABSOLUTE_URL)
->willReturnCallback(function () use ($context, &$capturedHost, &$capturedScheme) {
//Capture the context state as it is seen by the URL generator during the call
$capturedHost = $context->getHost();
$capturedScheme = $context->getScheme();
return $capturedScheme.'://'.$capturedHost.'/pw_reset/new_pw/john/abc';
});
$generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com/');
$url = $generator->generate('pw_reset_new_pw', ['user' => 'john', 'token' => 'abc']);
$this->assertSame('trusted.example.com', $capturedHost);
$this->assertSame('https', $capturedScheme);
$this->assertSame('https://trusted.example.com/pw_reset/new_pw/john/abc', $url);
}
public function testRestoresOriginalContextAfterGenerating(): void
{
//The context is shared with the rest of the application, so it must not be left modified afterward
$context = new RequestContext();
$context->setScheme('http');
$context->setHost('attacker.evil');
$context->setHttpPort(8080);
$context->setBaseUrl('/original');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('getContext')->willReturn($context);
$urlGenerator->method('generate')->willReturn('https://trusted.example.com/foo');
$generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com/sub/');
$generator->generate('some_route');
$this->assertSame('attacker.evil', $context->getHost());
$this->assertSame('http', $context->getScheme());
$this->assertSame(8080, $context->getHttpPort());
$this->assertSame('/original', $context->getBaseUrl());
}
public function testRestoresOriginalContextEvenWhenGenerateThrows(): void
{
$context = new RequestContext();
$context->setScheme('http');
$context->setHost('attacker.evil');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('getContext')->willReturn($context);
$urlGenerator->method('generate')->willThrowException(new \RuntimeException('route not found'));
$generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com/');
try {
$generator->generate('unknown_route');
$this->fail('Expected exception was not thrown');
} catch (\RuntimeException) {
//Expected
}
$this->assertSame('attacker.evil', $context->getHost());
$this->assertSame('http', $context->getScheme());
}
public function testUsesHttpsPortForHttpsDefaultUri(): void
{
$context = new RequestContext();
$capturedHttpsPort = null;
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('getContext')->willReturn($context);
$urlGenerator->method('generate')->willReturnCallback(function () use ($context, &$capturedHttpsPort) {
$capturedHttpsPort = $context->getHttpsPort();
return 'https://trusted.example.com:8443/foo';
});
$generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com:8443/');
$generator->generate('some_route');
$this->assertSame(8443, $capturedHttpsPort);
}
public function testUsesBasePathFromDefaultUri(): void
{
$context = new RequestContext();
$capturedBaseUrl = null;
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('getContext')->willReturn($context);
$urlGenerator->method('generate')->willReturnCallback(function () use ($context, &$capturedBaseUrl) {
$capturedBaseUrl = $context->getBaseUrl();
return 'https://trusted.example.com/partdb/foo';
});
$generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com/partdb/');
$generator->generate('some_route');
$this->assertSame('/partdb', $capturedBaseUrl);
}
}

View file

@ -13695,24 +13695,6 @@ Buerklin-API-Authentication-Server:
<target>Sie können diesen zufällig generierten Wert verwenden (geben Sie ihn niemandem weiter):</target> <target>Sie können diesen zufällig generierten Wert verwenden (geben Sie ihn niemandem weiter):</target>
</segment> </segment>
</unit> </unit>
<unit id="OP7MacF" name="system.trusted_hosts.unconfigured.title">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.title</source>
<target>TRUSTED_HOSTS nicht konfiguriert</target>
</segment>
</unit>
<unit id="88NkHLV" name="system.trusted_hosts.unconfigured.message">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.message</source>
<target>Die Umgebungsvariable &lt;code&gt;TRUSTED_HOSTS&lt;/code&gt; ist nicht gesetzt. Das bedeutet, dass Part-DB Anfragen für jeden beliebigen Hostnamen akzeptiert, was ein Sicherheitsrisiko darstellen kann (z. B. HTTP-Host-Header-Injection). Es wird empfohlen, dies auf die Hostnamen zu beschränken, unter denen Part-DB tatsächlich erreichbar ist.</target>
</segment>
</unit>
<unit id="qZ_kgWT" name="system.trusted_hosts.unconfigured.suggestion">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.suggestion</source>
<target>Legen Sie einen regulären Ausdruck fest, der alle Hostnamen abdeckt, unter denen Part-DB erreichbar sein soll, z. B. basierend auf dem Hostnamen, den Sie für den Zugriff auf diese Seite verwendet haben. Dies ist möglich in Ihrer Datei &lt;code&gt;.env.local&lt;/code&gt; oder &lt;code&gt;docker-compose.yaml&lt;/code&gt;:</target>
</segment>
</unit>
<unit id="cEwxoSj" name="info_providers.provider_key"> <unit id="cEwxoSj" name="info_providers.provider_key">
<segment state="translated"> <segment state="translated">
<source>info_providers.provider_key</source> <source>info_providers.provider_key</source>