mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-30 21:21:42 +00:00
Compare commits
37 commits
ad0f58b8f0
...
5c7bf673ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7bf673ee | ||
|
|
12b53f15ba | ||
|
|
07d106e44d | ||
|
|
16bb09c153 | ||
|
|
9504553e88 | ||
|
|
ad539e00a7 | ||
|
|
c6983c6516 | ||
|
|
a47e56bd93 | ||
|
|
65631dde4c | ||
|
|
3f7dd59dd1 | ||
|
|
9fb69bfc03 | ||
|
|
43ac8c23c7 | ||
|
|
e80e4a1bcd | ||
|
|
e23dde8208 | ||
|
|
c8a1670f0b | ||
|
|
7edbf35e46 | ||
|
|
30aba1db6f | ||
|
|
dcc431d28c | ||
|
|
5054642d41 | ||
|
|
1a8ff831ab | ||
|
|
8161e9af87 | ||
|
|
c9a1bb12a5 | ||
|
|
92f62f69bd | ||
|
|
a396cd0f58 | ||
|
|
fc2cd5dc62 | ||
|
|
4060484248 | ||
|
|
1b740ed674 | ||
|
|
90a5ab74ff | ||
|
|
805319c6b0 | ||
|
|
b604128afa | ||
|
|
5110915de2 | ||
|
|
97f914a85a | ||
|
|
f53f77f6dc | ||
|
|
76a36386fc | ||
|
|
a356b94c34 | ||
|
|
27afa013c1 | ||
|
|
a608551309 |
55 changed files with 17266 additions and 2420 deletions
61
.docker/debian-manual-guide/Dockerfile
Normal file
61
.docker/debian-manual-guide/Dockerfile
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Reproduces https://docs.part-db.de/installation/installation_guide-debian.html
|
||||
# on Debian 13 (trixie) as closely as possible, for debugging issues reported
|
||||
# by users who followed that guide (Apache2 + mod_php, not the project's own
|
||||
# FrankenPHP-based Dockerfile at the repo root).
|
||||
FROM debian:13
|
||||
|
||||
ARG HOST_UID=1000
|
||||
ARG HOST_GID=1000
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# --- Prerequisites (guide: "Prerequisites Installation") ---
|
||||
RUN apt-get update && apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git curl zip ca-certificates software-properties-common \
|
||||
apt-transport-https lsb-release nano wget sqlite3 gnupg openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- PHP and Apache2 (guide: "PHP and Apache2 Setup") ---
|
||||
# Debian 13 ships PHP 8.4 by default, so no extra PHP repo is needed.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
apache2 php8.4 libapache2-mod-php8.4 \
|
||||
php8.4-opcache php8.4-curl php8.4-gd php8.4-mbstring \
|
||||
php8.4-xml php8.4-bcmath php8.4-intl php8.4-zip php8.4-xsl \
|
||||
php8.4-sqlite3 php8.4-mysql \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Composer (guide: "Composer Installation") ---
|
||||
RUN wget -O /tmp/composer-setup.php https://getcomposer.org/installer \
|
||||
&& php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer \
|
||||
&& chmod +x /usr/local/bin/composer \
|
||||
&& rm /tmp/composer-setup.php
|
||||
|
||||
# --- Node.js and Yarn (guide: "Node.js and Yarn Setup") ---
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor -o /usr/share/keyrings/yarnkey.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian stable main" > /etc/apt/sources.list.d/yarn.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends yarn \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Map www-data to the host user's uid/gid so files created inside the
|
||||
# bind-mounted repo (composer/yarn artifacts, chown'd dirs) don't end up
|
||||
# owned by a foreign uid on the host.
|
||||
RUN groupmod -g ${HOST_GID} www-data && usermod -u ${HOST_UID} -g ${HOST_GID} www-data \
|
||||
&& mkdir -p /var/www/partdb && chown -R www-data:www-data /var/www
|
||||
|
||||
# --- Apache2 Configuration (guide: "Apache2 Configuration") ---
|
||||
COPY partdb.conf /etc/apache2/sites-available/partdb.conf
|
||||
RUN ln -sf /etc/apache2/sites-available/partdb.conf /etc/apache2/sites-enabled/partdb.conf \
|
||||
&& a2enmod rewrite headers access_compat \
|
||||
&& rm -f /etc/apache2/sites-enabled/000-default.conf
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
WORKDIR /var/www/partdb
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["apache2ctl", "-D", "FOREGROUND"]
|
||||
41
.docker/debian-manual-guide/docker-compose.yml
Normal file
41
.docker/debian-manual-guide/docker-compose.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Reproduces a Debian 12 install following
|
||||
# https://docs.part-db.de/installation/installation_guide-debian.html
|
||||
# using the repo checkout two levels up, for debugging.
|
||||
#
|
||||
# Usage (from this directory):
|
||||
# HOST_UID=$(id -u) HOST_GID=$(id -g) docker compose up --build
|
||||
#
|
||||
# Then open http://localhost:8080 (login: admin / password printed in the
|
||||
# container logs by doctrine:migrations:migrate on first run).
|
||||
#
|
||||
# vendor/, node_modules/, var/, public/build, public/media and uploads live
|
||||
# in named volumes so composer/yarn/console never write into the host's own
|
||||
# working copy - only src/, config/, templates/, assets/, migrations/, etc.
|
||||
# are shared live for debugging.
|
||||
services:
|
||||
partdb:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
HOST_UID: ${HOST_UID:-1000}
|
||||
HOST_GID: ${HOST_GID:-1000}
|
||||
container_name: partdb-debian-guide
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ../..:/var/www/partdb
|
||||
- ./env.local:/var/www/partdb/.env.local:ro
|
||||
- partdb_vendor:/var/www/partdb/vendor
|
||||
- partdb_node_modules:/var/www/partdb/node_modules
|
||||
- partdb_var:/var/www/partdb/var
|
||||
- partdb_public_build:/var/www/partdb/public/build
|
||||
- partdb_public_media:/var/www/partdb/public/media
|
||||
- partdb_uploads:/var/www/partdb/uploads
|
||||
|
||||
volumes:
|
||||
partdb_vendor:
|
||||
partdb_node_modules:
|
||||
partdb_var:
|
||||
partdb_public_build:
|
||||
partdb_public_media:
|
||||
partdb_uploads:
|
||||
48
.docker/debian-manual-guide/entrypoint.sh
Normal file
48
.docker/debian-manual-guide/entrypoint.sh
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#!/bin/bash
|
||||
# Reproduces the app-setup steps from
|
||||
# https://docs.part-db.de/installation/installation_guide-debian.html
|
||||
# against the repo bind-mounted at /var/www/partdb.
|
||||
set -euo pipefail
|
||||
|
||||
cd /var/www/partdb
|
||||
|
||||
# Only chown the named-volume mount points (see docker-compose.yml) - they're
|
||||
# fresh docker volumes owned by root until first use. The bind-mounted source
|
||||
# tree doesn't need it (www-data's uid/gid is mapped to the host user's at
|
||||
# image build time, so ownership already matches), and recursing into it
|
||||
# would also try (and fail) to chown the read-only .env.local mount.
|
||||
chown -R www-data:www-data \
|
||||
var vendor node_modules public/build public/media uploads
|
||||
|
||||
as_www() {
|
||||
# -p preserves the environment (APP_ENV/APP_SECRET/DATABASE_URL from
|
||||
# docker-compose) across the user switch. Plain `su` resets it, which
|
||||
# would silently fall back to whatever's baked into .env.local.
|
||||
su -p -s /bin/bash www-data -c "$*"
|
||||
}
|
||||
|
||||
if [ ! -f vendor/autoload.php ] || [ "${FORCE_REINSTALL:-0}" = "1" ]; then
|
||||
echo "==> composer install -o"
|
||||
as_www "composer install -o"
|
||||
fi
|
||||
|
||||
if [ ! -d node_modules/.bin ] || [ "${FORCE_REINSTALL:-0}" = "1" ]; then
|
||||
echo "==> yarn install"
|
||||
as_www "yarn install"
|
||||
fi
|
||||
|
||||
if [ ! -f public/build/manifest.json ] || [ "${FORCE_REINSTALL:-0}" = "1" ]; then
|
||||
echo "==> yarn build"
|
||||
as_www "yarn build"
|
||||
fi
|
||||
|
||||
echo "==> cache:clear"
|
||||
as_www "php bin/console cache:clear"
|
||||
|
||||
echo "==> partdb:check-requirements"
|
||||
as_www "php bin/console partdb:check-requirements" || true
|
||||
|
||||
echo "==> doctrine:migrations:migrate"
|
||||
as_www "php bin/console doctrine:migrations:migrate --no-interaction"
|
||||
|
||||
exec "$@"
|
||||
11
.docker/debian-manual-guide/env.local
Normal file
11
.docker/debian-manual-guide/env.local
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Bind-mounted over /var/www/partdb/.env.local inside the container only.
|
||||
# The host's real .env.local (dev, MySQL on 127.0.0.1) is never touched or
|
||||
# read - this guarantees APP_ENV/DATABASE_URL are correct for BOTH Apache/
|
||||
# mod_php web requests and CLI (console/composer) commands, without relying
|
||||
# on environment variables being propagated into Apache's subprocess env
|
||||
# (which is what caused the "WebProfilerBundle" dev-bundle error: web
|
||||
# requests were silently falling back to the host .env.local's APP_ENV=dev,
|
||||
# while --no-dev composer install never installed that bundle).
|
||||
APP_ENV=prod
|
||||
APP_SECRET=0000000000000000000000000000000000000000000000000000000000debug
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/app.db"
|
||||
15
.docker/debian-manual-guide/partdb.conf
Normal file
15
.docker/debian-manual-guide/partdb.conf
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Verbatim from https://docs.part-db.de/installation/installation_guide-debian.html
|
||||
<VirtualHost *:80>
|
||||
ServerName partdb.lan
|
||||
ServerAlias www.partdb.lan
|
||||
|
||||
DocumentRoot /var/www/partdb/public
|
||||
<Directory /var/www/partdb/public>
|
||||
AllowOverride All
|
||||
Order Allow,Deny
|
||||
Allow from All
|
||||
</Directory>
|
||||
|
||||
ErrorLog /var/log/apache2/partdb_error.log
|
||||
CustomLog /var/log/apache2/partdb_access.log combined
|
||||
</VirtualHost>
|
||||
8
.env
8
.env
|
|
@ -5,6 +5,14 @@
|
|||
# Share that value with nobody and keep it secret
|
||||
APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
|
||||
|
||||
# Change this and uncomment the following line to set the trusted hosts for your Part-DB installation, aka under which
|
||||
# domain names it is reachable. This is makes things more secure, because it can prevent certain header attacks.
|
||||
# You have to escape dots in the domain name with a backslash
|
||||
#TRUSTED_HOSTS='^(your-domain\.invalid)$'
|
||||
|
||||
# You can also allow multiple domain names, e.g. for testing or development purposes, with an | between the domain names.
|
||||
#TRUSTED_HOSTS='^(localhost|your-domain\.invalid)$'
|
||||
|
||||
###################################################################################
|
||||
# Database settings
|
||||
###################################################################################
|
||||
|
|
|
|||
4
.github/workflows/assets_artifact_build.yml
vendored
4
.github/workflows/assets_artifact_build.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
|
|
|
|||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
|
|
|
|||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
|
@ -81,7 +81,7 @@ jobs:
|
|||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.13.1
|
||||
2.13.3
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||
"doctrine/orm": "^3.2.0",
|
||||
"dompdf/dompdf": "^3.1.2",
|
||||
"gregwar/captcha-bundle": "^2.1.0",
|
||||
"gregwar/captcha-bundle": "^3.0.0",
|
||||
"hshn/base64-encoded-file": "^6.0",
|
||||
"jbtronics/2fa-webauthn": "^3.0.0",
|
||||
"jbtronics/dompdf-font-loader-bundle": "^1.0.0",
|
||||
|
|
@ -57,10 +57,10 @@
|
|||
"scheb/2fa-trusted-device": "^v7.11.0",
|
||||
"shivas/versioning-bundle": "^4.0",
|
||||
"spatie/db-dumper": "^3.3.1",
|
||||
"symfony/ai-bundle": "^0.10.0",
|
||||
"symfony/ai-lm-studio-platform": "^v0.10.0",
|
||||
"symfony/ai-ollama-platform": "^0.10.0",
|
||||
"symfony/ai-open-router-platform": "^0.10.0",
|
||||
"symfony/ai-bundle": "^0.11.0",
|
||||
"symfony/ai-lm-studio-platform": "^v0.11.0",
|
||||
"symfony/ai-ollama-platform": "^0.11.0",
|
||||
"symfony/ai-open-router-platform": "^0.11.0",
|
||||
"symfony/apache-pack": "^1.0",
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
|
|
|
|||
788
composer.lock
generated
788
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@ parameters:
|
|||
|
||||
# This is used as workaround for places where we can not access the settings directly (like the 2FA application names)
|
||||
partdb.title: '%env(string:settings:customization:instanceName)%' # The title shown inside of Part-DB (e.g. in the navbar and on homepage)
|
||||
partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl', 'hu'] # The languages that are shown in user drop down menu
|
||||
partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl', 'hu', 'ko'] # The languages that are shown in user drop down menu
|
||||
|
||||
partdb.default_uri: '%env(addSlash:string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails
|
||||
|
||||
|
|
|
|||
|
|
@ -2770,6 +2770,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* host?: string|Param, // Default: "https://api.decart.ai/v1"
|
||||
* http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client"
|
||||
* },
|
||||
* deepgram?: array{
|
||||
* api_key?: string|Param,
|
||||
* endpoint?: string|Param, // Deepgram REST API endpoint // Default: "https://api.deepgram.com/v1/"
|
||||
* http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client"
|
||||
* },
|
||||
* deepseek?: array{
|
||||
* api_key?: string|Param,
|
||||
* http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client"
|
||||
|
|
@ -2810,6 +2815,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* host_url?: string|Param, // Default: "http://127.0.0.1:1234"
|
||||
* http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client"
|
||||
* },
|
||||
* minimax?: array{
|
||||
* endpoint?: string|Param, // Default: "https://api.minimax.io/v1"
|
||||
* api_key?: string|Param,
|
||||
* http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client"
|
||||
* },
|
||||
* mistral?: array{
|
||||
* api_key?: string|Param,
|
||||
* http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client"
|
||||
|
|
@ -2931,6 +2941,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* account_id?: string|Param,
|
||||
* api_key?: string|Param,
|
||||
* index_name?: string|Param,
|
||||
* http_client?: string|Param, // Default: "http_client"
|
||||
* dimensions?: int|Param, // Default: 1536
|
||||
* metric?: string|Param, // Default: "cosine"
|
||||
* endpoint?: string|Param,
|
||||
|
|
@ -3087,6 +3098,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* namespace?: string|Param,
|
||||
* database?: string|Param,
|
||||
* table?: string|Param,
|
||||
* http_client?: string|Param, // Default: "http_client"
|
||||
* vector_field?: string|Param, // Default: "_vectors"
|
||||
* strategy?: string|Param, // Default: "cosine"
|
||||
* dimensions?: int|Param, // Default: 1536
|
||||
|
|
@ -3096,6 +3108,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* endpoint?: string|Param,
|
||||
* api_key?: string|Param,
|
||||
* collection?: string|Param,
|
||||
* http_client?: string|Param, // Default: "http_client"
|
||||
* vector_field?: string|Param, // Default: "_vectors"
|
||||
* dimensions?: int|Param, // Default: 1536
|
||||
* }>,
|
||||
|
|
|
|||
|
|
@ -262,7 +262,15 @@ 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
|
||||
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
|
||||
should be accessible. If accessed via the wrong hostname, an error will be shown.
|
||||
should be accessible. If accessed via a hostname not matching the regex, an error page will be shown instead. By default this
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -84,7 +84,11 @@ 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):
|
||||
# - 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:
|
||||
# The packages will be installed automatically when the container starts
|
||||
# - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
|
||||
|
|
@ -96,7 +100,10 @@ services:
|
|||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
6. Inside the folder, run
|
||||
6. Uncomment and set the `TRUSTED_HOSTS` variable to a regex matching the host name(s) Part-DB should be reachable under
|
||||
(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
|
||||
docker-compose up -d
|
||||
|
|
@ -107,7 +114,7 @@ openssl rand -hex 32
|
|||
> Otherwise Part-DB console might use the wrong configuration to execute commands.
|
||||
|
||||
|
||||
7. Create the initial database with
|
||||
8. Create the initial database with
|
||||
|
||||
```bash
|
||||
docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
|
||||
|
|
@ -115,7 +122,7 @@ docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
|
|||
|
||||
and watch for the password output
|
||||
|
||||
8. Part-DB is available under `http://localhost:8080` and you can log in with the username `admin` and the password shown
|
||||
9. Part-DB is available under `http://localhost:8080` and you can log in with the username `admin` and the password shown
|
||||
before
|
||||
|
||||
The docker image uses a SQLite database and all data (database, uploads, and other media) is put into folders relative to
|
||||
|
|
@ -171,7 +178,11 @@ services:
|
|||
# Override value if you want to show a given text on homepage.
|
||||
# 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
|
||||
|
||||
|
||||
# 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:
|
||||
# - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
---
|
||||
title: Direct Installation on Debian 12
|
||||
title: Direct Installation on Debian 13
|
||||
layout: default
|
||||
parent: Installation
|
||||
nav_order: 4
|
||||
---
|
||||
|
||||
# Part-DB installation guide for Debian 12 (Bookworm)
|
||||
# Part-DB installation guide for Debian 13 (Trixie)
|
||||
|
||||
This guide shows you how to install Part-DB directly on Debian 12 using apache2 and SQLite. This guide should work with
|
||||
This guide shows you how to install Part-DB directly on Debian 13 using apache2 and SQLite. This guide should work with
|
||||
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
|
||||
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:
|
||||
|
||||
```bash
|
||||
sudo apt install apache2 php8.2 libapache2-mod-php8.2 \
|
||||
php8.2-opcache php8.2-curl php8.2-gd php8.2-mbstring \
|
||||
php8.2-xml php8.2-bcmath php8.2-intl php8.2-zip php8.2-xsl \
|
||||
php8.2-sqlite3 php8.2-mysql
|
||||
sudo apt install apache2 php8.4 libapache2-mod-php8.4 \
|
||||
php8.4-opcache php8.4-curl php8.4-gd php8.4-mbstring \
|
||||
php8.4-xml php8.4-bcmath php8.4-intl php8.4-zip php8.4-xsl \
|
||||
php8.4-sqlite3 php8.4-mysql
|
||||
```
|
||||
|
||||
### Install composer
|
||||
|
|
@ -145,6 +145,16 @@ 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`).
|
||||
|
||||
{: .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
|
||||
this can not be changed after creating price information.
|
||||
|
||||
|
|
@ -291,7 +301,7 @@ sudo -u www-data php bin/console cache:clear
|
|||
## MySQL/MariaDB database
|
||||
|
||||
To use a MySQL database, follow the steps from above (except the creation of the database, we will do this later).
|
||||
Debian 12 does not ship MySQL in its repositories anymore, so we use the compatible MariaDB instead:
|
||||
Debian does not ship MySQL in its repositories anymore, so we use the compatible MariaDB instead:
|
||||
|
||||
1. Install maria-db with:
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,16 @@ 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
|
||||
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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ To connect KiCad with Part-DB do the following steps:
|
|||
```
|
||||
4. Replace the `root_url` with the URL of your Part-DB instance plus `/en/kicad-api/`. You can find the right value for this in the Part-DB user settings page under "API endpoints" in the "API tokens" panel.
|
||||
5. Replace the `token` field value with the token you have generated in step 1.
|
||||
6. Open KiCad and add this created file as a library in the KiCad symbol table under (Preferences --> Manage Symbol Libraries)
|
||||
6. Open KiCad and add this created file as a HTTP library in the KiCad symbol table under (Preferences --> Manage Symbol Libraries)
|
||||
|
||||
If you then place a new part, the library dialog opens, and you should be able to see the categories and parts from Part-DB.
|
||||
|
||||
|
|
|
|||
|
|
@ -120,12 +120,23 @@ DirectoryIndex index.php
|
|||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
# Set a strict CSP for all static assets not handled by PHP.
|
||||
# PHP responses already carry their own CSP via NelmioSecurityBundle, so setifempty leaves those untouched.
|
||||
Header always setifempty Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; sandbox;"
|
||||
Header always setifempty X-Content-Type-Options "nosniff"
|
||||
# Set a strict CSP for everything Apache serves, except the front controller (index.php),
|
||||
# whose response CSP is set by NelmioSecurityBundle in PHP. Excluding it via a negative
|
||||
# FilesMatch (rather than a blanket rule relying on "setifempty" to skip PHP responses)
|
||||
# avoids depending on mod_headers detecting the PHP-set header at the right time: on a
|
||||
# real Apache 2.4/mod_php install, "setifempty" did not reliably suppress the static policy,
|
||||
# so BOTH Content-Security-Policy headers ended up on the same PHP response, and browsers
|
||||
# enforce the intersection of all CSP headers on a response - so "script-src 'none';
|
||||
# sandbox" silently disabled all scripts/styles site-wide. mod_rewrite's internal redirect
|
||||
# to index.php re-runs directory-scoped matching against the new target, so this FilesMatch
|
||||
# reliably excludes it regardless of the originally requested URL.
|
||||
<FilesMatch "^(?!index\.php$).+$">
|
||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; sandbox;"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
</FilesMatch>
|
||||
|
||||
# SVG files get a slightly different CSP because they can embed resources and must not be framed.
|
||||
# (Declared after the general rule above so its more specific "set" wins for SVG files.)
|
||||
<FilesMatch "\.(svg|svg\.gz|svg\.br)$">
|
||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; sandbox;"
|
||||
</FilesMatch>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated on Mon Jun 22 07:31:48 UTC 2026
|
||||
# Generated on Mon Jul 20 05:45:44 UTC 2026
|
||||
# This file contains all footprints available in the offical KiCAD library
|
||||
Audio_Module:Reverb_BTDR-1H
|
||||
Audio_Module:Reverb_BTDR-1V
|
||||
|
|
@ -7732,6 +7732,15 @@ Connector_TE-Connectivity:TE_Micro-MaTch_2-215079-0_2x10_P1.27mm_Vertical
|
|||
Connector_TE-Connectivity:TE_Micro-MaTch_215079-4_2x02_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_215079-6_2x03_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_215079-8_2x04_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_7-188275-4_2x02_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_7-188275-6_2x03_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_7-188275-8_2x04_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_8-188275-0_2x05_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_8-188275-2_2x06_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_8-188275-4_2x07_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_8-188275-6_2x08_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_8-188275-8_2x09_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_Micro-MaTch_9-188275-0_2x10_P1.27mm_Vertical
|
||||
Connector_TE-Connectivity:TE_T4041037031-000_M8_03_Socket_Straight
|
||||
Connector_TE-Connectivity:TE_T4041037041-000_M8_04_Socket_Straight
|
||||
Connector_USB:USB3_A_Molex_48393-001
|
||||
|
|
@ -9150,6 +9159,7 @@ Inductor_SMD:L_APV_APH1770
|
|||
Inductor_SMD:L_APV_APH2213
|
||||
Inductor_SMD:L_AVX_LMLP07A7
|
||||
Inductor_SMD:L_Abracon_ASPI-0425
|
||||
Inductor_SMD:L_Abracon_ASPI-0628
|
||||
Inductor_SMD:L_Abracon_ASPI-0630LR
|
||||
Inductor_SMD:L_Abracon_ASPI-3012S
|
||||
Inductor_SMD:L_Abracon_ASPI-4030S
|
||||
|
|
@ -12565,6 +12575,8 @@ Package_SO:HTSSOP-16-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask2.46x2.31mm
|
|||
Package_SO:HTSSOP-16-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask2.46x2.31mm_ThermalVias
|
||||
Package_SO:HTSSOP-16-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask2.66x2.46mm
|
||||
Package_SO:HTSSOP-16-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask2.66x2.46mm_ThermalVias
|
||||
Package_SO:HTSSOP-16-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask2.78x3.37mm
|
||||
Package_SO:HTSSOP-16-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask2.78x3.37mm_ThermalVias
|
||||
Package_SO:HTSSOP-16-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask3x3mm_ThermalVias
|
||||
Package_SO:HTSSOP-16-1EP_4.4x5mm_P0.65mm_EP3x3mm
|
||||
Package_SO:HTSSOP-20-1EP_4.4x6.5mm_P0.65mm_EP2.74x3.86mm
|
||||
|
|
@ -12919,6 +12931,8 @@ Package_SO:Texas_DYY0016A_TSOT-23-16_2x4.2mm_P0.5mm
|
|||
Package_SO:Texas_HSOP-8-1EP_3.9x4.9mm_P1.27mm
|
||||
Package_SO:Texas_HSOP-8-1EP_3.9x4.9mm_P1.27mm_ThermalVias
|
||||
Package_SO:Texas_HTSOP-8-1EP_3.9x4.9mm_P1.27mm_EP2.95x4.9mm_Mask2.4x3.1mm_ThermalVias
|
||||
Package_SO:Texas_HTSSOP-14-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask2.94x3.34mm
|
||||
Package_SO:Texas_HTSSOP-14-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask2.94x3.34mm_ThermalVias
|
||||
Package_SO:Texas_HTSSOP-14-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask3.155x3.255mm
|
||||
Package_SO:Texas_HTSSOP-14-1EP_4.4x5mm_P0.65mm_EP3.4x5mm_Mask3.155x3.255mm_ThermalVias
|
||||
Package_SO:Texas_PW0020A_TSSOP-20_4.4x6.5mm_P0.65mm
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,7 @@ use App\Entity\Parts\Part;
|
|||
use App\Services\System\AppSecretChecker;
|
||||
use App\Services\System\BannerHelper;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Services\System\TrustedHostsChecker;
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
|
|
@ -41,6 +42,7 @@ class HomepageController extends AbstractController
|
|||
private readonly DataTableFactory $dataTable,
|
||||
private readonly BannerHelper $bannerHelper,
|
||||
private readonly AppSecretChecker $appSecretChecker,
|
||||
private readonly TrustedHostsChecker $trustedHostsChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +92,7 @@ class HomepageController extends AbstractController
|
|||
'new_version_url' => $updateAvailableManager->getLatestVersionUrl(),
|
||||
'insecure_app_secret' => $this->appSecretChecker->isInsecureSecret(),
|
||||
'suggested_app_secret' => $this->appSecretChecker->isInsecureSecret() ? $this->appSecretChecker->generateSecret() : null,
|
||||
'trusted_hosts_unconfigured' => $this->trustedHostsChecker->isTrustedHostsUnconfigured(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class ToolsController extends AbstractController
|
|||
'detailed_error_pages' => $this->getParameter('partdb.error_pages.show_help'),
|
||||
'error_page_admin_email' => $this->getParameter('partdb.error_pages.admin_email'),
|
||||
'configured_max_file_size' => $settings->system->attachments->maxFileSize,
|
||||
'effective_max_file_size' => $attachmentSubmitHandler->getMaximumAllowedUploadSize(),
|
||||
'effective_max_file_size' => $attachmentSubmitHandler->getMaximumEffectiveUploadSize(),
|
||||
'saml_enabled' => $this->getParameter('partdb.saml.enabled'),
|
||||
|
||||
//PHP section
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ class AttachmentFormType extends AbstractType
|
|||
|
||||
public function finishView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['max_upload_size'] = $this->submitHandler->getMaximumAllowedUploadSize();
|
||||
$view->vars['max_upload_size'] = $this->submitHandler->getMaximumEffectiveUploadSize();
|
||||
}
|
||||
|
||||
public function getBlockPrefix(): string
|
||||
|
|
|
|||
|
|
@ -217,6 +217,14 @@ class AttachmentSubmitHandler
|
|||
|
||||
//If no file was uploaded, but we have base64 encoded data, create a file from it
|
||||
if (!$file && $upload->data !== null) {
|
||||
if (strlen($upload->data) > $this->getMaximumUserConfiguredUploadSize() * 4 / 3) { //Base64 encoding increases the size of the data by 4/3, so we have to check for that
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'The given base64 data is too big! Maximum size is %.1f MB!',
|
||||
$this->getMaximumUserConfiguredUploadSize() / 1000 / 1000
|
||||
));
|
||||
}
|
||||
|
||||
$file = new UploadedBase64EncodedFile(new Base64EncodedFile($upload->data), $upload->filename ?? 'base64');
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +233,15 @@ class AttachmentSubmitHandler
|
|||
|
||||
//When a file is given then upload it, otherwise check if we need to download the URL
|
||||
if ($file instanceof UploadedFile) {
|
||||
//Check the file size, to avoid uploading too big files.
|
||||
//The file is not necessarily validated as it can also come from an Base64 source
|
||||
if ($file->getSize() > $this->getMaximumUserConfiguredUploadSize()) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'The uploaded file is too big! Maximum size is %.1f MB!',
|
||||
$this->getMaximumUserConfiguredUploadSize() / 1000 / 1000
|
||||
));
|
||||
}
|
||||
|
||||
$this->upload($attachment, $file, $secure_attachment);
|
||||
} elseif ($upload->downloadUrl && $attachment->hasExternal()) {
|
||||
|
|
@ -399,9 +416,30 @@ class AttachmentSubmitHandler
|
|||
//Open a temporary file in the attachment folder
|
||||
$fs->mkdir($attachment_folder);
|
||||
$fileHandler = fopen($tmp_path, 'wb');
|
||||
|
||||
$bytesDownloaded = 0;
|
||||
$maxSize = $this->getMaximumUserConfiguredUploadSize(); //We use the maximum user configured size here, PHPs limits dont apply
|
||||
|
||||
//Write the downloaded data to file
|
||||
foreach ($this->httpClient->stream($response) as $chunk) {
|
||||
fwrite($fileHandler, $chunk->getContent());
|
||||
$content = $chunk->getContent();
|
||||
$bytesDownloaded += strlen($content);
|
||||
|
||||
//Ensure the size does not get too large to avoid filling up the disk easily.
|
||||
//If the file is too big, cancel the download and delete the temporary file.
|
||||
if ($bytesDownloaded > $maxSize) {
|
||||
$response->cancel();
|
||||
fclose($fileHandler);
|
||||
unlink($tmp_path); //Delete the temporary file, because it is too big
|
||||
|
||||
throw new AttachmentDownloadException(
|
||||
sprintf(
|
||||
'The downloaded file is too big! Maximum size is %.1f MB!',
|
||||
$maxSize / 1000 / 1000
|
||||
));
|
||||
}
|
||||
|
||||
fwrite($fileHandler, $content);
|
||||
}
|
||||
fclose($fileHandler);
|
||||
|
||||
|
|
@ -505,10 +543,10 @@ class AttachmentSubmitHandler
|
|||
}
|
||||
|
||||
/*
|
||||
* Returns the maximum allowed upload size in bytes.
|
||||
* Returns the maximum effective upload size in bytes.
|
||||
* This is the minimum value of Part-DB max_file_size, and php.ini's post_max_size and upload_max_filesize.
|
||||
*/
|
||||
public function getMaximumAllowedUploadSize(): int
|
||||
public function getMaximumEffectiveUploadSize(): int
|
||||
{
|
||||
if ($this->max_upload_size_bytes) {
|
||||
return $this->max_upload_size_bytes;
|
||||
|
|
@ -523,6 +561,15 @@ class AttachmentSubmitHandler
|
|||
return $this->max_upload_size_bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum user configured upload size in bytes.
|
||||
* @return int
|
||||
*/
|
||||
public function getMaximumUserConfiguredUploadSize(): int
|
||||
{
|
||||
return $this->parseFileSizeString($this->settings->maxFileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG file, if the attachment is an internal SVG file.
|
||||
* @param Attachment $attachment
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
namespace App\Services\ImportExportSystem;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use App\Helpers\FilenameSanatizer;
|
||||
|
|
@ -179,7 +180,11 @@ class EntityExporter
|
|||
|
||||
foreach ($columns as $column) {
|
||||
$cellCoordinate = Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
|
||||
$worksheet->setCellValue($cellCoordinate, $column);
|
||||
if (is_numeric(trim($column, '"'))) { // Check if the column value is numeric after trimming quotes, as values are surrounded by quotes in CSV
|
||||
$worksheet->setCellValueExplicit($cellCoordinate, $column, DataType::TYPE_NUMERIC);
|
||||
} else {
|
||||
$worksheet->setCellValueExplicit($cellCoordinate, $column, DataType::TYPE_STRING);
|
||||
}
|
||||
$colIndex++;
|
||||
}
|
||||
$rowIndex++;
|
||||
|
|
@ -268,7 +273,7 @@ class EntityExporter
|
|||
|
||||
//Remove percent for fallback
|
||||
$fallback = str_replace("%", "_", $filename);
|
||||
|
||||
|
||||
// Create the disposition of the file
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
|
|
|
|||
|
|
@ -136,7 +136,9 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
}
|
||||
}
|
||||
|
||||
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
|
||||
//First we try the search v3 endpoint, which seems to give better results including pictures, but it only works
|
||||
//on quite exact mpn matches
|
||||
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v3/global", [
|
||||
'headers' => [
|
||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||
],
|
||||
|
|
@ -147,20 +149,29 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
|
||||
$arr = $response->toArray();
|
||||
|
||||
// Get products list
|
||||
$products = $arr['result']['productSearchResultVO']['productList'] ?? [];
|
||||
// Get product tip
|
||||
$tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null;
|
||||
//If we get exact matches, use them
|
||||
if (!empty($arr['result']['exactMatchResult'])) {
|
||||
$products = $arr['result']['exactMatchResult'];
|
||||
} 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
|
||||
$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 = [];
|
||||
|
||||
// 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) {
|
||||
$result[] = $this->getPartDetail($product, $lightweight);
|
||||
}
|
||||
|
|
@ -219,7 +230,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $product['productCode'],
|
||||
name: $product['productModel'],
|
||||
description: $this->sanitizeField($product['productIntroEn']),
|
||||
description: $this->sanitizeField($product['productIntroEn']) ?? '',
|
||||
category: $this->sanitizeField($category ?? null),
|
||||
manufacturer: $this->sanitizeField($product['brandNameEn'] ?? null),
|
||||
mpn: $this->sanitizeField($product['productModel'] ?? null),
|
||||
|
|
@ -383,7 +394,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
]);
|
||||
} else {
|
||||
// Search API call for other terms
|
||||
$responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
|
||||
$responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v3/global", [
|
||||
'headers' => [
|
||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ class BarcodeHelper
|
|||
{
|
||||
$svg = $this->barcodeAsSVG($content, $type);
|
||||
$base64 = $this->dataUri($svg, 'image/svg+xml');
|
||||
$alt_text ??= $content;
|
||||
|
||||
$alt_text ??= htmlspecialchars($content);
|
||||
|
||||
return '<img src="'.$base64.'" width="'.$width.'" style="min-height: 25px;" alt="'.$alt_text.'"/>';
|
||||
}
|
||||
|
||||
|
|
@ -94,4 +94,4 @@ class BarcodeHelper
|
|||
|
||||
return $repr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,9 +44,9 @@ namespace App\Services\LabelSystem\PlaceholderProviders;
|
|||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
|
||||
final class AbstractDBElementProvider implements PlaceholderProviderInterface
|
||||
final readonly class AbstractDBElementProvider implements PlaceholderProviderInterface
|
||||
{
|
||||
public function __construct(private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
|
||||
public function __construct(private ElementTypeNameGenerator $elementTypeNameGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ final class AbstractDBElementProvider implements PlaceholderProviderInterface
|
|||
{
|
||||
if ($label_target instanceof AbstractDBElement) {
|
||||
if ('[[TYPE]]' === $placeholder) {
|
||||
return $this->elementTypeNameGenerator->getLocalizedTypeLabel($label_target);
|
||||
return $this->elementTypeNameGenerator->typeLabel($label_target);
|
||||
}
|
||||
|
||||
if ('[[ID]]' === $placeholder) {
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ use App\Services\LabelSystem\LabelBarcodeGenerator;
|
|||
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
|
||||
use Com\Tecnick\Barcode\Exception;
|
||||
|
||||
final class BarcodeProvider implements PlaceholderProviderInterface
|
||||
final readonly class BarcodeProvider implements PlaceholderProviderInterface
|
||||
{
|
||||
public function __construct(private readonly LabelBarcodeGenerator $barcodeGenerator,
|
||||
private readonly BarcodeContentGenerator $barcodeContentGenerator,
|
||||
private readonly BarcodeHelper $barcodeHelper)
|
||||
public function __construct(private LabelBarcodeGenerator $barcodeGenerator,
|
||||
private BarcodeContentGenerator $barcodeContentGenerator,
|
||||
private BarcodeHelper $barcodeHelper)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,11 +53,11 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|||
* Provides Placeholders for infos about global infos like Installation name or datetimes.
|
||||
* @see \App\Tests\Services\LabelSystem\PlaceholderProviders\GlobalProvidersTest
|
||||
*/
|
||||
final class GlobalProviders implements PlaceholderProviderInterface
|
||||
final readonly class GlobalProviders implements PlaceholderProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly UrlGeneratorInterface $url_generator,
|
||||
private Security $security,
|
||||
private UrlGeneratorInterface $url_generator,
|
||||
private CustomizationSettings $customizationSettings,
|
||||
)
|
||||
{
|
||||
|
|
@ -66,13 +66,13 @@ final class GlobalProviders implements PlaceholderProviderInterface
|
|||
public function replace(string $placeholder, object $label_target, array $options = []): ?string
|
||||
{
|
||||
if ('[[INSTALL_NAME]]' === $placeholder) {
|
||||
return $this->customizationSettings->instanceName;
|
||||
return htmlspecialchars($this->customizationSettings->instanceName);
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ('[[USERNAME]]' === $placeholder) {
|
||||
if ($user instanceof User) {
|
||||
return $user->getName();
|
||||
return htmlspecialchars($user->getName());
|
||||
}
|
||||
|
||||
return 'anonymous';
|
||||
|
|
@ -80,7 +80,7 @@ final class GlobalProviders implements PlaceholderProviderInterface
|
|||
|
||||
if ('[[USERNAME_FULL]]' === $placeholder) {
|
||||
if ($user instanceof User) {
|
||||
return $user->getFullName(true);
|
||||
return htmlspecialchars($user->getFullName(true));
|
||||
}
|
||||
|
||||
return 'anonymous';
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ final class NamedElementProvider implements PlaceholderProviderInterface
|
|||
public function replace(string $placeholder, object $label_target, array $options = []): ?string
|
||||
{
|
||||
if ($label_target instanceof NamedElementInterface && '[[NAME]]' === $placeholder) {
|
||||
return $label_target->getName();
|
||||
return htmlspecialchars($label_target->getName());
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ use Locale;
|
|||
/**
|
||||
* @see \App\Tests\Services\LabelSystem\PlaceholderProviders\PartLotProviderTest
|
||||
*/
|
||||
final class PartLotProvider implements PlaceholderProviderInterface
|
||||
final readonly class PartLotProvider implements PlaceholderProviderInterface
|
||||
{
|
||||
public function __construct(private readonly LabelTextReplacer $labelTextReplacer, private readonly AmountFormatter $amountFormatter)
|
||||
public function __construct(private LabelTextReplacer $labelTextReplacer, private AmountFormatter $amountFormatter)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -66,11 +66,11 @@ final class PartLotProvider implements PlaceholderProviderInterface
|
|||
}
|
||||
|
||||
if ('[[LOT_NAME]]' === $placeholder) {
|
||||
return $label_target->getName();
|
||||
return htmlspecialchars($label_target->getName());
|
||||
}
|
||||
|
||||
if ('[[LOT_COMMENT]]' === $placeholder) {
|
||||
return $label_target->getComment();
|
||||
return htmlspecialchars($label_target->getComment());
|
||||
}
|
||||
|
||||
if ('[[EXPIRATION_DATE]]' === $placeholder) {
|
||||
|
|
@ -95,19 +95,19 @@ final class PartLotProvider implements PlaceholderProviderInterface
|
|||
}
|
||||
|
||||
if ('[[LOCATION]]' === $placeholder) {
|
||||
return $label_target->getStorageLocation() instanceof StorageLocation ? $label_target->getStorageLocation()->getName() : '';
|
||||
return $label_target->getStorageLocation() instanceof StorageLocation ? htmlspecialchars($label_target->getStorageLocation()->getName()) : '';
|
||||
}
|
||||
|
||||
if ('[[LOCATION_FULL]]' === $placeholder) {
|
||||
return $label_target->getStorageLocation() instanceof StorageLocation ? $label_target->getStorageLocation()->getFullPath() : '';
|
||||
return $label_target->getStorageLocation() instanceof StorageLocation ? htmlspecialchars($label_target->getStorageLocation()->getFullPath()) : '';
|
||||
}
|
||||
|
||||
if ('[[OWNER]]' === $placeholder) {
|
||||
return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getFullName() : '';
|
||||
return $label_target->getOwner() instanceof User ? htmlspecialchars($label_target->getOwner()->getFullName()) : '';
|
||||
}
|
||||
|
||||
if ('[[OWNER_USERNAME]]' === $placeholder) {
|
||||
return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getUsername() : '';
|
||||
return $label_target->getOwner() instanceof User ? htmlspecialchars($label_target->getOwner()->getUsername()) : '';
|
||||
}
|
||||
|
||||
return $this->labelTextReplacer->handlePlaceholder($placeholder, $label_target->getPart());
|
||||
|
|
|
|||
|
|
@ -54,11 +54,11 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
/**
|
||||
* @see \App\Tests\Services\LabelSystem\PlaceholderProviders\PartProviderTest
|
||||
*/
|
||||
final class PartProvider implements PlaceholderProviderInterface
|
||||
final readonly class PartProvider implements PlaceholderProviderInterface
|
||||
{
|
||||
private readonly MarkdownConverter $inlineConverter;
|
||||
private MarkdownConverter $inlineConverter;
|
||||
|
||||
public function __construct(private readonly SIFormatter $siFormatter, private readonly TranslatorInterface $translator)
|
||||
public function __construct(private SIFormatter $siFormatter, private TranslatorInterface $translator)
|
||||
{
|
||||
$environment = new Environment();
|
||||
$environment->addExtension(new InlinesOnlyExtension());
|
||||
|
|
@ -72,27 +72,27 @@ final class PartProvider implements PlaceholderProviderInterface
|
|||
}
|
||||
|
||||
if ('[[CATEGORY]]' === $placeholder) {
|
||||
return $part->getCategory() instanceof Category ? $part->getCategory()->getName() : '';
|
||||
return $part->getCategory() instanceof Category ? htmlspecialchars($part->getCategory()->getName()) : '';
|
||||
}
|
||||
|
||||
if ('[[CATEGORY_FULL]]' === $placeholder) {
|
||||
return $part->getCategory() instanceof Category ? $part->getCategory()->getFullPath() : '';
|
||||
return $part->getCategory() instanceof Category ? htmlspecialchars($part->getCategory()->getFullPath()) : '';
|
||||
}
|
||||
|
||||
if ('[[MANUFACTURER]]' === $placeholder) {
|
||||
return $part->getManufacturer() instanceof Manufacturer ? $part->getManufacturer()->getName() : '';
|
||||
return $part->getManufacturer() instanceof Manufacturer ? htmlspecialchars($part->getManufacturer()->getName()) : '';
|
||||
}
|
||||
|
||||
if ('[[MANUFACTURER_FULL]]' === $placeholder) {
|
||||
return $part->getManufacturer() instanceof Manufacturer ? $part->getManufacturer()->getFullPath() : '';
|
||||
return $part->getManufacturer() instanceof Manufacturer ? htmlspecialchars($part->getManufacturer()->getFullPath()) : '';
|
||||
}
|
||||
|
||||
if ('[[FOOTPRINT]]' === $placeholder) {
|
||||
return $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '';
|
||||
return $part->getFootprint() instanceof Footprint ? htmlspecialchars($part->getFootprint()->getName()) : '';
|
||||
}
|
||||
|
||||
if ('[[FOOTPRINT_FULL]]' === $placeholder) {
|
||||
return $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getFullPath() : '';
|
||||
return $part->getFootprint() instanceof Footprint ? htmlspecialchars($part->getFootprint()->getFullPath()) : '';
|
||||
}
|
||||
|
||||
if ('[[MASS]]' === $placeholder) {
|
||||
|
|
@ -100,15 +100,15 @@ final class PartProvider implements PlaceholderProviderInterface
|
|||
}
|
||||
|
||||
if ('[[MPN]]' === $placeholder) {
|
||||
return $part->getManufacturerProductNumber();
|
||||
return htmlspecialchars($part->getManufacturerProductNumber());
|
||||
}
|
||||
|
||||
if ('[[IPN]]' === $placeholder) {
|
||||
return $part->getIpn() ?? '';
|
||||
return $part->getIpn() ? htmlspecialchars($part->getIpn()) : '';
|
||||
}
|
||||
|
||||
if ('[[TAGS]]' === $placeholder) {
|
||||
return $part->getTags();
|
||||
return htmlspecialchars($part->getTags());
|
||||
}
|
||||
|
||||
if ('[[M_STATUS]]' === $placeholder) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ interface PlaceholderProviderInterface
|
|||
/**
|
||||
* Determines the real value of this placeholder.
|
||||
* If the placeholder is not supported, null must be returned.
|
||||
* The placeholder provider has to handle HTML escaping, as the output of this function will be used directly in the label template.
|
||||
*
|
||||
* @param string $placeholder The placeholder (e.g. "%%PLACEHOLDER%%") that should be replaced
|
||||
* @param object $label_target The object that is targeted by the label
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ class StorelocationProvider implements PlaceholderProviderInterface
|
|||
{
|
||||
if ($label_target instanceof StorageLocation) {
|
||||
if ('[[OWNER]]' === $placeholder) {
|
||||
return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getFullName() : '';
|
||||
return $label_target->getOwner() instanceof User ? htmlspecialchars($label_target->getOwner()->getFullName()) : '';
|
||||
}
|
||||
|
||||
if ('[[OWNER_USERNAME]]' === $placeholder) {
|
||||
return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getUsername() : '';
|
||||
return $label_target->getOwner() instanceof User ? htmlspecialchars($label_target->getOwner()->getUsername()) : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,13 +55,13 @@ final class StructuralDBElementProvider implements PlaceholderProviderInterface
|
|||
return strip_tags((string) $label_target->getComment());
|
||||
}
|
||||
if ('[[FULL_PATH]]' === $placeholder) {
|
||||
return $label_target->getFullPath();
|
||||
return htmlspecialchars($label_target->getFullPath());
|
||||
}
|
||||
if ('[[PARENT]]' === $placeholder) {
|
||||
return $label_target->getParent() instanceof AbstractStructuralDBElement ? $label_target->getParent()->getName() : '';
|
||||
return $label_target->getParent() instanceof AbstractStructuralDBElement ? htmlspecialchars($label_target->getParent()->getName()) : '';
|
||||
}
|
||||
if ('[[PARENT_FULL_PATH]]' === $placeholder) {
|
||||
return $label_target->getParent() instanceof AbstractStructuralDBElement ? $label_target->getParent()->getFullPath() : '';
|
||||
return $label_target->getParent() instanceof AbstractStructuralDBElement ? htmlspecialchars($label_target->getParent()->getFullPath()) : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
45
src/Services/System/TrustedHostsChecker.php
Normal file
45
src/Services/System/TrustedHostsChecker.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?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;
|
||||
|
||||
/**
|
||||
* Checks whether the TRUSTED_HOSTS environment variable has been configured.
|
||||
*/
|
||||
final readonly class TrustedHostsChecker
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire('%env(TRUSTED_HOSTS)%')]
|
||||
private string $trustedHosts,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool True if TRUSTED_HOSTS is not configured (meaning Part-DB accepts requests with any Host header), false otherwise.
|
||||
*/
|
||||
public function isTrustedHostsUnconfigured(): bool
|
||||
{
|
||||
return trim($this->trustedHosts) === '';
|
||||
}
|
||||
}
|
||||
96
src/Services/System/TrustedUrlGenerator.php
Normal file
96
src/Services/System/TrustedUrlGenerator.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
namespace App\Services\UserSystem;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\System\TrustedUrlGenerator;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
|
|
@ -39,7 +40,8 @@ class PasswordResetManager
|
|||
|
||||
public function __construct(protected MailerInterface $mailer, protected EntityManagerInterface $em,
|
||||
protected TranslatorInterface $translator, protected UserPasswordHasherInterface $userPasswordEncoder,
|
||||
PasswordHasherFactoryInterface $encoderFactory)
|
||||
PasswordHasherFactoryInterface $encoderFactory,
|
||||
protected TrustedUrlGenerator $trustedUrlGenerator)
|
||||
{
|
||||
$this->passwordEncoder = $encoderFactory->getPasswordHasher(User::class);
|
||||
}
|
||||
|
|
@ -63,6 +65,9 @@ class PasswordResetManager
|
|||
$user->setPwResetExpires($expiration_date);
|
||||
|
||||
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());
|
||||
$mail = new TemplatedEmail();
|
||||
$mail->to($address);
|
||||
|
|
@ -72,6 +77,8 @@ class PasswordResetManager
|
|||
'expiration_date' => $expiration_date,
|
||||
'token' => $unencrypted_token,
|
||||
'user' => $user,
|
||||
'reset_url' => $reset_url,
|
||||
'reset_url_fallback' => $reset_url_fallback,
|
||||
]);
|
||||
|
||||
//Send email
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'translation_domain' => false])]
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'choice_translation_domain' => false])]
|
||||
public string $domain = "DE";
|
||||
|
||||
/**
|
||||
|
|
|
|||
39
templates/bundles/TwigBundle/Exception/error400.html.twig
Normal file
39
templates/bundles/TwigBundle/Exception/error400.html.twig
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{% extends "bundles/TwigBundle/Exception/error.html.twig" %}
|
||||
|
||||
{% set untrusted_host = exception.message starts with 'Untrusted Host' or exception.message starts with 'Invalid Host' %}
|
||||
{% set seen_host = untrusted_host ? (exception.message|split('"'))[1]|default(null) : null %}
|
||||
|
||||
{% block status_comment %}
|
||||
{% if untrusted_host %}
|
||||
Part-DB was accessed using a host name that is not marked as trusted.<br>
|
||||
{% if seen_host %}
|
||||
The host name Part-DB saw was: <code>{{ seen_host }}</code>
|
||||
{% else %}
|
||||
<code>{{ exception.message }}</code>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Your browser sent a request that the server could not understand.
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_info %}
|
||||
{% if untrusted_host %}
|
||||
<b><i>Untrusted host name.</i></b><br>
|
||||
<p>For security reasons (to prevent HTTP Host header attacks), Part-DB only responds to requests using host names that were explicitly marked as trusted, once more than one host name is used to access it (e.g. behind a reverse proxy).</p>
|
||||
<p>Try following things:</p>
|
||||
<ul>
|
||||
<li>Ensure that you are using the correct URL to access Part-DB.</li>
|
||||
<li>Set the <code>TRUSTED_HOSTS</code> environment variable in your <code>.env.local</code> file (or your docker-compose environment) to a regular expression matching all host names Part-DB should be reachable under, e.g.
|
||||
{% if seen_host %}
|
||||
<code>TRUSTED_HOSTS='^({{ seen_host|replace({'.': '\\.'}) }})$'</code> (if <code>{{ seen_host }}</code> is a host name you trust)
|
||||
{% else %}
|
||||
<code>TRUSTED_HOSTS='^(localhost|example\.com)$'</code>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>If Part-DB is running behind a reverse proxy, also make sure that <code>TRUSTED_PROXIES</code> is configured correctly, so the original host name is forwarded properly.</li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd> after changing the configuration.</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
{{ parent() }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -95,6 +95,16 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if trusted_hosts_unconfigured and is_granted('@system.server_infos') %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h5><i class="fa-solid fa-triangle-exclamation fa-fw"></i> {% trans %}system.trusted_hosts.unconfigured.title{% endtrans %}</h5>
|
||||
<p class="mb-1">{% trans %}system.trusted_hosts.unconfigured.message{% endtrans %}</p>
|
||||
<p class="mb-0">{% trans %}system.trusted_hosts.unconfigured.suggestion{% endtrans %}
|
||||
<br><code>TRUSTED_HOSTS='^({{ app.request.host|replace({'.': '\\.'}) }})$'</code></p>
|
||||
<small>{% trans %}update_manager.new_version_available.only_administrators_can_see{% endtrans %}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('@system.show_updates') %}
|
||||
{{ nv.new_version_alert(new_version_available, new_version, new_version_url) }}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
<h4>{% trans with {'%name%': user.fullName|escape } %}email.hi %name%{% endtrans %},</h4>
|
||||
{% trans %}email.pw_reset.message{% endtrans %}
|
||||
<br>
|
||||
<button class="large expand" href="{{ url('pw_reset_new_pw', {user: user.name, token: token}) }}">{% trans %}email.pw_reset.button{% endtrans %}</button>
|
||||
<button class="large expand" href="{{ reset_url }}">{% trans %}email.pw_reset.button{% endtrans %}</button>
|
||||
<br>
|
||||
{% trans with {'%url%': url('pw_reset_new_pw') } %}email.pw_reset.fallback{% endtrans %}:
|
||||
{% trans with {'%url%': reset_url_fallback } %}email.pw_reset.fallback{% endtrans %}:
|
||||
<callout class="secondary">
|
||||
<row>
|
||||
<columns>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,28 @@ final class EntityExporterTest extends WebTestCase
|
|||
unlink($tempFile);
|
||||
}
|
||||
|
||||
public function testExportExcelFormulaInjectionPrevention(): void
|
||||
{
|
||||
// Values starting with formula characters must be stored as plain strings, not evaluated formulas
|
||||
$entity = (new Category())->setName('=1+1')->setComment('@SUM(A1)');
|
||||
|
||||
$xlsxData = $this->service->exportEntities([$entity], ['format' => 'xlsx', 'level' => 'simple']);
|
||||
$this->assertNotEmpty($xlsxData);
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test_formula') . '.xlsx';
|
||||
file_put_contents($tempFile, $xlsxData);
|
||||
|
||||
$spreadsheet = IOFactory::load($tempFile);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
// The formula-prefixed name must be stored as a plain string, not a formula
|
||||
$cell = $worksheet->getCell('A2');
|
||||
$this->assertSame(\PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING, $cell->getDataType());
|
||||
$this->assertSame('=1+1', $cell->getValue());
|
||||
|
||||
unlink($tempFile);
|
||||
}
|
||||
|
||||
public function testExportExcelFromRequest(): void
|
||||
{
|
||||
$entities = $this->getEntities();
|
||||
|
|
|
|||
|
|
@ -133,25 +133,21 @@ final class LCSCProviderTest extends TestCase
|
|||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productSearchResultVO' => [
|
||||
'productList' => [
|
||||
[
|
||||
'productCode' => 'C789012',
|
||||
'productModel' => 'Regular Component',
|
||||
'productIntroEn' => 'Regular description',
|
||||
'brandNameEn' => 'Regular Manufacturer',
|
||||
'encapStandard' => '0805',
|
||||
'productImageUrl' => 'https://example.com/regular.jpg',
|
||||
'productImages' => ['https://example.com/regular1.jpg'],
|
||||
'productPriceList' => [
|
||||
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€']
|
||||
],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]
|
||||
]
|
||||
'exactMatchResult' => [[
|
||||
'productCode' => 'C789012',
|
||||
'productModel' => 'Regular Component',
|
||||
'productIntroEn' => 'Regular description',
|
||||
'brandNameEn' => 'Regular Manufacturer',
|
||||
'encapStandard' => '0805',
|
||||
'productImageUrl' => 'https://example.com/regular.jpg',
|
||||
'productImages' => ['https://example.com/regular1.jpg'],
|
||||
'productPriceList' => [
|
||||
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€']
|
||||
],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]]
|
||||
]
|
||||
]));
|
||||
|
||||
|
|
@ -166,45 +162,6 @@ final class LCSCProviderTest extends TestCase
|
|||
$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
|
||||
{
|
||||
|
|
@ -310,7 +267,7 @@ final class LCSCProviderTest extends TestCase
|
|||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
$this->httpClient->setResponseFactory([$mockResponse, $mockResponse]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('No part found with ID INVALID');
|
||||
|
|
@ -322,8 +279,7 @@ final class LCSCProviderTest extends TestCase
|
|||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productSearchResultVO' => [
|
||||
'productList' => [
|
||||
'exactMatchResult' => [
|
||||
[
|
||||
'productCode' => 'C123456',
|
||||
'productModel' => 'Component 1',
|
||||
|
|
@ -350,7 +306,6 @@ final class LCSCProviderTest extends TestCase
|
|||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]));
|
||||
|
|
|
|||
172
tests/Services/System/TrustedUrlGeneratorTest.php
Normal file
172
tests/Services/System/TrustedUrlGeneratorTest.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
83
translations/frontend.ko.xlf
Normal file
83
translations/frontend.ko.xlf
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="ko">
|
||||
<file id="frontend.en">
|
||||
<unit id="eLrezdb" name="search.placeholder">
|
||||
<segment state="translated">
|
||||
<source>search.placeholder</source>
|
||||
<target>검색</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="R4hoCqe" name="part.labelp">
|
||||
<segment state="translated">
|
||||
<source>part.labelp</source>
|
||||
<target>부품</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
|
||||
<segment state="translated">
|
||||
<source>entity.select.group.new_not_added_to_DB</source>
|
||||
<target>신규 (아직 DB에 추가되지 않음)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9rnHbSK" name="user.password_strength.very_weak">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.very_weak</source>
|
||||
<target>매우 약함</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="gKHmHwM" name="user.password_strength.weak">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.weak</source>
|
||||
<target>약함</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="c44gN8b" name="user.password_strength.medium">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.medium</source>
|
||||
<target>보통</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NwiBLHc" name="user.password_strength.strong">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.strong</source>
|
||||
<target>강함</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Bw.iCUm" name="user.password_strength.very_strong">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.very_strong</source>
|
||||
<target>매우 강함</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="U5IhkwB" name="search.submit">
|
||||
<segment state="translated">
|
||||
<source>search.submit</source>
|
||||
<target>이동!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8d38e7538" name="user.password_strength.crack_time">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.crack_time</source>
|
||||
<target>예상 해독 시간: %time%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dBtnOk01" name="dialog.btn.ok">
|
||||
<segment state="translated">
|
||||
<source>dialog.btn.ok</source>
|
||||
<target>확인</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dBtnCcl1" name="dialog.btn.cancel">
|
||||
<segment state="translated">
|
||||
<source>dialog.btn.cancel</source>
|
||||
<target>취소</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dBtnDny1" name="dialog.btn.deny">
|
||||
<segment state="translated">
|
||||
<source>dialog.btn.deny</source>
|
||||
<target>아니요</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
@ -13695,6 +13695,24 @@ Buerklin-API-Authentication-Server:
|
|||
<target>Sie können diesen zufällig generierten Wert verwenden (geben Sie ihn niemandem weiter):</target>
|
||||
</segment>
|
||||
</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 <code>TRUSTED_HOSTS</code> 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 <code>.env.local</code> oder <code>docker-compose.yaml</code>:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cEwxoSj" name="info_providers.provider_key">
|
||||
<segment state="translated">
|
||||
<source>info_providers.provider_key</source>
|
||||
|
|
|
|||
|
|
@ -675,7 +675,7 @@ Sub elements will be moved upwards.</target>
|
|||
<unit id="maYlJO8" name="part_list.loading.message">
|
||||
<segment state="translated">
|
||||
<source>part_list.loading.message</source>
|
||||
<target>This can take a moment. If this message do not disappear, try to reload the page.</target>
|
||||
<target>This can take a moment. If this message does not disappear, try to reload the page.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wKzcAZd" name="vendor.base.javascript_hint">
|
||||
|
|
@ -960,7 +960,7 @@ Sub elements will be moved upwards.</target>
|
|||
<unit id="I6zehgT" name="email.pw_reset.message">
|
||||
<segment state="translated">
|
||||
<source>email.pw_reset.message</source>
|
||||
<target>somebody (hopefully you) requested a reset of your password. If this request was not made by you, ignore this mail.</target>
|
||||
<target>Somebody (hopefully you) requested a reset of your password. If this request was not made by you, ignore this mail.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P8NjvoC" name="email.pw_reset.button">
|
||||
|
|
@ -2143,7 +2143,7 @@ Also note that without two-factor authentication, your account is no longer as w
|
|||
<unit id="h3376Kl" name="tfa_google.step.download">
|
||||
<segment state="translated">
|
||||
<source>tfa_google.step.download</source>
|
||||
<target>Download an authenticator app (e.g. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>)</target>
|
||||
<target>Download an authenticator app (e.g. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> or <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eriwJoR" name="tfa_google.step.scan">
|
||||
|
|
@ -3065,7 +3065,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
|
|||
<unit id="AdYtBQG" name="attachment.edit.url.help">
|
||||
<segment state="translated">
|
||||
<source>attachment.edit.url.help</source>
|
||||
<target>You can specify an URL to an external file here, or input an keyword which is used to search in built-in resources (e.g. footprints)</target>
|
||||
<target>You can specify a URL to an external file here, or input a keyword which is used to search in built-in resources (e.g. footprints)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="jXplbqd" name="attachment.edit.name">
|
||||
|
|
@ -4208,7 +4208,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
|
|||
</notes>
|
||||
<segment state="translated">
|
||||
<source>validator.noLockout</source>
|
||||
<target>You can not withdraw yourself the "change permission" permission, to prevent that you lockout yourself accidentally.</target>
|
||||
<target>You can not withdraw yourself the "change permission" permission, to prevent that you lock yourself out accidentally.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tS2P7Jn" name="attachment_type.edit.filetype_filter">
|
||||
|
|
@ -4703,7 +4703,7 @@ Element 1 -> Element 1.2</target>
|
|||
</notes>
|
||||
<segment state="translated">
|
||||
<source>storelocation.edit.is_full.label</source>
|
||||
<target>Storelocation full</target>
|
||||
<target>Storage location full</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XF4C7ZG" name="storelocation.edit.is_full.help">
|
||||
|
|
@ -4713,7 +4713,7 @@ Element 1 -> Element 1.2</target>
|
|||
</notes>
|
||||
<segment state="translated">
|
||||
<source>storelocation.edit.is_full.help</source>
|
||||
<target>If this option is selected, it is neither possible to add new [[part]] to this storelocation or to increase the amount of existing [[part]].</target>
|
||||
<target>If this option is selected, it is neither possible to add new [[part]] to this storage location or to increase the amount of existing [[part]].</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="V1su4ac" name="storelocation.limit_to_existing.label">
|
||||
|
|
@ -4733,7 +4733,7 @@ Element 1 -> Element 1.2</target>
|
|||
</notes>
|
||||
<segment state="translated">
|
||||
<source>storelocation.limit_to_existing.help</source>
|
||||
<target>If this option is activated, it is not possible to add new [[part]] to this storelocation, but the amount of existing [[part]] can be increased.</target>
|
||||
<target>If this option is activated, it is not possible to add new [[part]] to this storage location, but the amount of existing [[part]] can be increased.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tGVVeof" name="storelocation.only_single_part.label">
|
||||
|
|
@ -5988,7 +5988,7 @@ Element 1 -> Element 1.2</target>
|
|||
</notes>
|
||||
<segment state="translated">
|
||||
<source>log.type.security.trusted_device_reset</source>
|
||||
<target>Trusted devices resetted</target>
|
||||
<target>Trusted devices reset</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="a.Le1i0" name="log.type.collection_element_deleted">
|
||||
|
|
@ -6123,7 +6123,7 @@ Element 1 -> Element 1.2</target>
|
|||
<unit id="uxw7_77" name="group.edit.enforce_2fa.help">
|
||||
<segment state="translated">
|
||||
<source>group.edit.enforce_2fa.help</source>
|
||||
<target>If this option is enabled, every direct member of this group, has to configure at least one second-factor for authentication. Recommended for administrative groups with much permissions.</target>
|
||||
<target>If this option is enabled, every direct member of this group, has to configure at least one second-factor for authentication. Recommended for administrative groups with many permissions.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NwsYm0P" name="selectpicker.nothing_selected">
|
||||
|
|
@ -6363,7 +6363,7 @@ Element 1 -> Element 1.2</target>
|
|||
<unit id="CdphPiM" name="tools.reel_calc.explanation">
|
||||
<segment state="translated">
|
||||
<source>tools.reel_calc.explanation</source>
|
||||
<target>This calculator gives you an estimation, how many parts are remaining on an SMD reel. Measure the noted the dimensions on the reel (or use some of the presets) and click "Update" to get an result.</target>
|
||||
<target>This calculator gives you an estimation, how many parts are remaining on an SMD reel. Measure the noted the dimensions on the reel (or use the presets) and click "Update" to get a result.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="21dEh63" name="perm.tools.reel_calculator">
|
||||
|
|
@ -7893,7 +7893,7 @@ Element 1 -> Element 1.2</target>
|
|||
<unit id="h9kfyaE" name="log.element_edited.changed_fields.is_full">
|
||||
<segment state="translated">
|
||||
<source>log.element_edited.changed_fields.is_full</source>
|
||||
<target>Storelocation full</target>
|
||||
<target>Storage location full</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="K754HtO" name="log.element_edited.changed_fields.limit_to_existing_parts">
|
||||
|
|
@ -8289,13 +8289,13 @@ Element 1 -> Element 1.2</target>
|
|||
<unit id="gt2SlQN" name="import.create_unknown_datastructures">
|
||||
<segment state="translated">
|
||||
<source>import.create_unknown_datastructures</source>
|
||||
<target>Create unknown datastructures</target>
|
||||
<target>Create unknown data structures</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".TjGPnB" name="import.create_unknown_datastructures.help">
|
||||
<segment state="translated">
|
||||
<source>import.create_unknown_datastructures.help</source>
|
||||
<target>If this is selected, datastructures (like categories, footprints, etc.) which does not exist in the database yet, will be automatically created. If this is not selected, only existing data structures will be used, and if no matching data structure is found, the part will get assigned nothing</target>
|
||||
<target>If this is selected, data structures (like categories, footprints, etc.) which does not exist in the database yet, will be automatically created. If this is not selected, only existing data structures will be used, and if no matching data structure is found, the part will get assigned nothing</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="y_rsaV8" name="import.path_delimiter">
|
||||
|
|
@ -8307,7 +8307,7 @@ Element 1 -> Element 1.2</target>
|
|||
<unit id="AOG.hXc" name="import.path_delimiter.help">
|
||||
<segment state="translated">
|
||||
<source>import.path_delimiter.help</source>
|
||||
<target>The delimiter used to mark different levels in data structure pathes like category, footprint, etc.</target>
|
||||
<target>The delimiter used to mark different levels in data structure paths like category, footprint, etc.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="p_IxB9K" name="parts.import.help_documentation">
|
||||
|
|
@ -8367,7 +8367,7 @@ Element 1 -> Element 1.2</target>
|
|||
<unit id="tNnYa0u" name="project.bom_import.type.kicad_pcbnew">
|
||||
<segment state="translated">
|
||||
<source>project.bom_import.type.kicad_pcbnew</source>
|
||||
<target>KiCAD Pcbnew BOM (CSV file)</target>
|
||||
<target>KiCad Pcbnew BOM (CSV file)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="AtW_ooO" name="project.bom_import.clear_existing_bom">
|
||||
|
|
@ -8535,7 +8535,7 @@ Element 1 -> Element 1.2</target>
|
|||
<unit id="sg8NMZk" name="log.user_login.ip_anonymize_hint">
|
||||
<segment state="translated">
|
||||
<source>log.user_login.ip_anonymize_hint</source>
|
||||
<target>If the last digits of the IP address are missing, then the GDPR mode is enabled, in which IP addresses are anynomized.</target>
|
||||
<target>If the last digits of the IP address are missing, then the GDPR mode is enabled, in which IP addresses are anonymized.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="K_LOvwj" name="log.user_not_allowed.unauthorized_access_attempt_to">
|
||||
|
|
@ -8571,7 +8571,7 @@ Element 1 -> Element 1.2</target>
|
|||
<unit id="YQafwyc" name="error_table.error">
|
||||
<segment state="translated">
|
||||
<source>error_table.error</source>
|
||||
<target>An error occured during your request.</target>
|
||||
<target>An error occurred during your request.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="SgZGiGG" name="part.table.invalid_regex">
|
||||
|
|
@ -9347,7 +9347,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
|||
<unit id="Y8bGSEj" name="permission.legend.dependency_note">
|
||||
<segment state="translated">
|
||||
<source>permission.legend.dependency_note</source>
|
||||
<target>Please note that some permission operations depend on each other. If you encounter a warning that missing permissions were corrected and a permission was set to allow again, you have to set the dependent operation to forbid too. The dependents can normally found right of an operation.</target>
|
||||
<target>Please note that some permission operations depend on each other. If you encounter a warning that missing permissions were corrected and a permission was set to allow again, you have to set the dependent operation to forbid too. The dependents can normally be found right of an operation.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zWj5h2X" name="log.part_stock_changed.timestamp">
|
||||
|
|
@ -12152,7 +12152,7 @@ Buerklin-API Authentication server:
|
|||
<unit id="RFhwYWd" name="info_providers.search.error.transport_exception">
|
||||
<segment state="translated">
|
||||
<source>info_providers.search.error.transport_exception</source>
|
||||
<target>Transport error while retrieving information from the providers. Check that your server has internet accesss. See server logs for more info.</target>
|
||||
<target>Transport error while retrieving information from the providers. Check that your server has internet access. See server logs for more info.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PExOL_J" name="update_manager.title">
|
||||
|
|
@ -13697,6 +13697,24 @@ Buerklin-API Authentication server:
|
|||
<target>You can use this randomly generated value (share it with nobody):</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="OP7MacF" name="system.trusted_hosts.unconfigured.title">
|
||||
<segment state="translated">
|
||||
<source>system.trusted_hosts.unconfigured.title</source>
|
||||
<target>TRUSTED_HOSTS not configured</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="88NkHLV" name="system.trusted_hosts.unconfigured.message">
|
||||
<segment state="translated">
|
||||
<source>system.trusted_hosts.unconfigured.message</source>
|
||||
<target>The <code>TRUSTED_HOSTS</code> environment variable is not set. This means Part-DB will accept requests for any host name, which can be a security risk (e.g. HTTP Host header injection). It is recommended to restrict this to the host names Part-DB is actually reachable under.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qZ_kgWT" name="system.trusted_hosts.unconfigured.suggestion">
|
||||
<segment state="translated">
|
||||
<source>system.trusted_hosts.unconfigured.suggestion</source>
|
||||
<target>Set it to a regular expression matching all host names Part-DB should be reachable under in your <code>.env.local</code> or <code>docker-compose.yaml</code> file, e.g. based on the host name you used to access this page:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cEwxoSj" name="info_providers.provider_key">
|
||||
<segment state="translated">
|
||||
<source>info_providers.provider_key</source>
|
||||
|
|
|
|||
13749
translations/messages.ko.xlf
Normal file
13749
translations/messages.ko.xlf
Normal file
File diff suppressed because it is too large
Load diff
23
translations/security.ko.xlf
Normal file
23
translations/security.ko.xlf
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="ko">
|
||||
<file id="security.en">
|
||||
<unit id="GrLNa9P" name="user.login_error.user_disabled">
|
||||
<segment state="translated">
|
||||
<source>user.login_error.user_disabled</source>
|
||||
<target>계정이 비활성화되어 있습니다! 오류라고 생각되면 관리자에게 문의하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IFQ5XrG" name="saml.error.cannot_login_local_user_per_saml">
|
||||
<segment state="translated">
|
||||
<source>saml.error.cannot_login_local_user_per_saml</source>
|
||||
<target>로컬 사용자는 SSO로 로그인할 수 없습니다! 로컬 사용자 비밀번호를 사용하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wOYPZmb" name="saml.error.cannot_login_saml_user_locally">
|
||||
<segment state="translated">
|
||||
<source>saml.error.cannot_login_saml_user_locally</source>
|
||||
<target>SAML 사용자는 로컬 인증으로 로그인할 수 없습니다! SSO 로그인을 사용하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
275
translations/validators.ko.xlf
Normal file
275
translations/validators.ko.xlf
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="ko">
|
||||
<file id="validators.en">
|
||||
<unit id="cRbk.cm" name="part.master_attachment.must_be_picture">
|
||||
<segment state="translated">
|
||||
<source>part.master_attachment.must_be_picture</source>
|
||||
<target>미리보기 첨부파일은 유효한 사진이어야 합니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v8HkcJB" name="structural.entity.unique_name">
|
||||
<segment state="translated">
|
||||
<source>structural.entity.unique_name</source>
|
||||
<target>이 계층에 같은 이름의 요소가 이미 있습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dW7b2B_" name="parameters.validator.min_lesser_typical">
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.min_lesser_typical</source>
|
||||
<target>값은 일반값({{ compared_value }}) 이하여야 합니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Yfp2uC5" name="parameters.validator.min_lesser_max">
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.min_lesser_max</source>
|
||||
<target>값은 최대값({{ compared_value }})보다 작아야 합니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P6b.8Ou" name="parameters.validator.max_greater_typical">
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.max_greater_typical</source>
|
||||
<target>값은 일반값({{ compared_value }}) 이상이어야 합니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P41193Y" name="validator.user.username_already_used">
|
||||
<segment state="translated">
|
||||
<source>validator.user.username_already_used</source>
|
||||
<target>같은 이름의 사용자가 이미 있습니다</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="EKPQiyf" name="user.invalid_username">
|
||||
<segment state="translated">
|
||||
<source>user.invalid_username</source>
|
||||
<target>사용자 이름은 문자, 숫자, 밑줄, 마침표, 더하기, 빼기만 포함할 수 있으며 @로 시작할 수 없습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_v.DMg." name="validator.noneofitschild.self">
|
||||
<notes>
|
||||
<note category="state" priority="1">obsolete</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>validator.noneofitschild.self</source>
|
||||
<target>요소는 자기 자신의 상위가 될 수 없습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="W90LyFQ" name="validator.noneofitschild.children">
|
||||
<notes>
|
||||
<note category="state" priority="1">obsolete</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>validator.noneofitschild.children</source>
|
||||
<target>하위 요소를 상위 요소로 지정할 수 없습니다 (순환이 발생합니다)!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GAUS.LK" name="validator.select_valid_category">
|
||||
<segment state="translated">
|
||||
<source>validator.select_valid_category</source>
|
||||
<target>유효한 카테고리를 선택하세요!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="h6qELde" name="validator.part_lot.only_existing">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.only_existing</source>
|
||||
<target>이 위치는 "기존 항목만"으로 표시되어 새 부품을 추가할 수 없습니다</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Prriyy0" name="validator.part_lot.location_full.no_increase">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.location_full.no_increase</source>
|
||||
<target>위치가 가득 찼습니다. 수량을 늘릴 수 없습니다 (새 값은 {{ old_amount }}보다 작아야 합니다).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eeEjB4s" name="validator.part_lot.location_full">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.location_full</source>
|
||||
<target>위치가 가득 찼습니다. 새 부품을 추가할 수 없습니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2yWi8eP" name="validator.part_lot.single_part">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.single_part</source>
|
||||
<target>이 위치는 단일 부품만 보관할 수 있으며 이미 가득 찼습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="A.TFhbb" name="validator.attachment.must_not_be_null">
|
||||
<segment state="translated">
|
||||
<source>validator.attachment.must_not_be_null</source>
|
||||
<target>첨부파일 형식을 선택해야 합니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".lqKoij" name="validator.orderdetail.supplier_must_not_be_null">
|
||||
<segment state="translated">
|
||||
<source>validator.orderdetail.supplier_must_not_be_null</source>
|
||||
<target>판매처를 선택해야 합니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bcNZzK." name="validator.measurement_unit.use_si_prefix_needs_unit">
|
||||
<segment state="translated">
|
||||
<source>validator.measurement_unit.use_si_prefix_needs_unit</source>
|
||||
<target>SI 접두사를 사용하려면 단위 기호를 설정해야 합니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="gZ5FFL1" name="part.ipn.must_be_unique">
|
||||
<segment state="translated">
|
||||
<source>part.ipn.must_be_unique</source>
|
||||
<target>내부 부품 번호는 고유해야 합니다. {{ value }}은(는) 이미 사용 중입니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P31Yg.d" name="validator.project.bom_entry.name_or_part_needed">
|
||||
<segment state="translated">
|
||||
<source>validator.project.bom_entry.name_or_part_needed</source>
|
||||
<target>부품 BOM 항목에는 부품을 선택하고, 부품이 아닌 BOM 항목에는 이름을 설정해야 합니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5CEup_N" name="project.bom_entry.name_already_in_bom">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.name_already_in_bom</source>
|
||||
<target>같은 이름의 BOM 항목이 이미 있습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="jB3B50E" name="project.bom_entry.part_already_in_bom">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.part_already_in_bom</source>
|
||||
<target>이 부품은 이미 BOM에 있습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NdkzP1n" name="project.bom_entry.mountnames_quantity_mismatch">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.mountnames_quantity_mismatch</source>
|
||||
<target>장착 이름의 수는 BOM 수량과 일치해야 합니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8teRCgR" name="project.bom_entry.can_not_add_own_builds_part">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.can_not_add_own_builds_part</source>
|
||||
<target>프로젝트 자신의 빌드 부품을 BOM에 추가할 수 없습니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="asBxPxe" name="project.bom_has_to_include_all_subelement_parts">
|
||||
<segment state="translated">
|
||||
<source>project.bom_has_to_include_all_subelement_parts</source>
|
||||
<target>프로젝트 BOM에는 모든 하위 프로젝트의 빌드 부품이 포함되어야 합니다. %project_name% 프로젝트의 %part_name% 부품이 누락되었습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="uxaE9Ct" name="project.bom_entry.price_not_allowed_on_parts">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.price_not_allowed_on_parts</source>
|
||||
<target>부품과 연결된 BOM 항목에는 가격을 설정할 수 없습니다. 대신 부품에 가격을 정의하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xZ68Nzl" name="validator.project_build.lot_bigger_than_needed">
|
||||
<segment state="translated">
|
||||
<source>validator.project_build.lot_bigger_than_needed</source>
|
||||
<target>필요한 것보다 많은 출고 수량을 선택했습니다! 불필요한 수량을 제거하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="68_.V_X" name="validator.project_build.lot_smaller_than_needed">
|
||||
<segment state="translated">
|
||||
<source>validator.project_build.lot_smaller_than_needed</source>
|
||||
<target>제작에 필요한 것보다 적은 출고 수량을 선택했습니다! 수량을 추가하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yZGS8uZ" name="part.name.must_match_category_regex">
|
||||
<segment state="translated">
|
||||
<source>part.name.must_match_category_regex</source>
|
||||
<target>부품 이름이 카테고리에서 지정한 정규 표현식과 일치하지 않습니다: %regex%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q8wP5Jd" name="validator.attachment.name_not_blank">
|
||||
<segment state="translated">
|
||||
<source>validator.attachment.name_not_blank</source>
|
||||
<target>여기에 값을 설정하거나, 파일을 업로드하여 파일 이름을 첨부파일 이름으로 자동 사용하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="DH0IkNR" name="validator.part_lot.owner_must_match_storage_location_owner">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.owner_must_match_storage_location_owner</source>
|
||||
<target>이 로트의 소유자는 선택한 보관 위치의 소유자(%owner_name%)와 일치해야 합니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TzySicw" name="validator.part_lot.owner_must_not_be_anonymous">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.owner_must_not_be_anonymous</source>
|
||||
<target>로트 소유자는 익명 사용자일 수 없습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GthNWUb" name="validator.part_association.must_set_an_value_if_type_is_other">
|
||||
<segment state="translated">
|
||||
<source>validator.part_association.must_set_an_value_if_type_is_other</source>
|
||||
<target>유형을 "기타"로 설정한 경우 이를 설명하는 값을 설정해야 합니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Be4Im81" name="validator.part_association.part_cannot_be_associated_with_itself">
|
||||
<segment state="translated">
|
||||
<source>validator.part_association.part_cannot_be_associated_with_itself</source>
|
||||
<target>부품은 자기 자신과 연관될 수 없습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="q5Ej6Xm" name="validator.part_association.already_exists">
|
||||
<segment state="translated">
|
||||
<source>validator.part_association.already_exists</source>
|
||||
<target>이 부품과의 연관이 이미 있습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="HbI5bga" name="validator.part_lot.vendor_barcode_must_be_unique">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.vendor_barcode_must_be_unique</source>
|
||||
<target>이 공급업체 바코드 값은 다른 로트에서 이미 사용되었습니다. 바코드는 고유해야 합니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ufQJh7E" name="validator.year_2038_bug_on_32bit">
|
||||
<segment state="translated">
|
||||
<source>validator.year_2038_bug_on_32bit</source>
|
||||
<target>기술적 제약으로 인해 32비트 시스템에서는 2038-01-19 이후의 날짜를 선택할 수 없습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iM9yb_p" name="validator.fileSize.invalidFormat">
|
||||
<segment state="translated">
|
||||
<source>validator.fileSize.invalidFormat</source>
|
||||
<target>유효하지 않은 파일 크기 형식입니다. 정수와 함께 킬로, 메가, 기가바이트를 뜻하는 K, M, G 접미사를 사용하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZFxQ0BZ" name="validator.invalid_range">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_range</source>
|
||||
<target>지정한 범위가 유효하지 않습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="m4gp2P_" name="validator.google_code.wrong_code">
|
||||
<segment state="translated">
|
||||
<source>validator.google_code.wrong_code</source>
|
||||
<target>유효하지 않은 코드입니다. 인증 앱이 올바르게 설정되어 있는지, 서버와 인증 기기의 시간이 정확한지 확인하세요.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="I330cr5" name="settings.synonyms.type_synonyms.collection_type.duplicate">
|
||||
<segment state="translated">
|
||||
<source>settings.synonyms.type_synonyms.collection_type.duplicate</source>
|
||||
<target>이 유형과 언어에 대한 번역이 이미 정의되어 있습니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zT_j_oQ" name="validator.invalid_gtin">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_gtin</source>
|
||||
<target>유효한 GTIN / EAN이 아닙니다!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vnpejmb" name="info_providers.validation.provider_id_without_key">
|
||||
<segment state="translated">
|
||||
<source>info_providers.validation.provider_id_without_key</source>
|
||||
<target>제공자 ID를 지정하려면 정보 제공자도 지정하거나, 둘 다 제거해야 합니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yFlA5OA" name="info_providers.validation.provider_url_without_key">
|
||||
<segment state="translated">
|
||||
<source>info_providers.validation.provider_url_without_key</source>
|
||||
<target>제공자 URL을 지정하려면 정보 제공자도 지정해야 합니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="gUHUXoV" name="info_providers.validation.provider_key_without_id">
|
||||
<segment state="translated">
|
||||
<source>info_providers.validation.provider_key_without_id</source>
|
||||
<target>정보 제공자를 지정하려면 제공자 ID도 지정하거나, 둘 다 제거해야 합니다.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
Loading…
Add table
Add a link
Reference in a new issue