Merge branch 'master' into mcp

This commit is contained in:
Jan Böhmer 2026-07-20 21:01:07 +02:00
commit 7c0b47d8f8
173 changed files with 27011 additions and 7959 deletions

View 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"]

View 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:

View 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 "$@"

View 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"

View 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>

View file

@ -51,5 +51,18 @@
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
header ?Permissions-Policy "browsing-topics=()"
# Set a strict CSP and nosniff for all static assets not handled by PHP.
# ? means "set only if not already present", so PHP responses carrying a Nelmio CSP are left untouched.
header ?Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; sandbox;"
header ?X-Content-Type-Options "nosniff"
# SVG files get a slightly different CSP because they can embed resources and must not be framed.
@svg path *.svg *.svg.gz *.svg.br
header @svg Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; sandbox;"
# Prevent PHP execution in the media upload directory
@php_in_media path_regexp (?i)^/media/.*\.(php[3-8]?|phar|phtml|pht|phps)$
respond @php_in_media 403
php_server
}

View file

@ -15,6 +15,14 @@
AllowOverride All
</Directory>
# Prevent PHP execution in the media upload directory (server-level, not .htaccess,
# because public/media is a Docker volume and .htaccess there may not be present)
<Directory /var/www/html/public/media>
<FilesMatch "(?i)\.(php[3-8]?|phar|phtml|pht|phps)$">
Require all denied
</FilesMatch>
</Directory>
# Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the loglevel for particular

23
.env
View file

@ -1,6 +1,18 @@
#### Part-DB Configuration
# See https://docs.part-db.de/configuration.html for documentation of available options
# Change this to a random value to secure your installation! You can generate a random string with "openssl rand -hex 16"
# 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
###################################################################################
@ -145,6 +157,16 @@ DISABLE_YEAR2038_BUG_CHECK=0
#TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
#TRUSTED_HOSTS='^(localhost|example\.com)$'
###################################################################################
# Logging settings
###################################################################################
# The minimum level a deprecation notice must have to be written to the var/log/<env>_deprecations.log file.
# Deprecation notices are logged with level "info", so this disables the deprecation log by default.
# Set to debug to log all deprecation notices
DEPRECATION_LOG_LEVEL=emergency
###> symfony/lock ###
# Choose one of the stores below
@ -158,7 +180,6 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###

View file

@ -27,7 +27,7 @@ jobs:
APP_ENV: prod
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -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 }}
@ -65,7 +65,7 @@ jobs:
${{ runner.os }}-yarn-
- name: Setup node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '22'

View file

@ -32,7 +32,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
-
name: Docker meta
id: docker_meta

View file

@ -32,7 +32,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
-
name: Docker meta
id: docker_meta

View file

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -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') }}

View file

@ -46,7 +46,7 @@ jobs:
if: matrix.db-type == 'postgres'
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -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 }}
@ -104,7 +104,7 @@ jobs:
run: composer install --prefer-dist --no-progress
- name: Setup node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '22'
@ -129,7 +129,7 @@ jobs:
run: ./bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
env_vars: PHP_VERSION,DB_TYPE
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -193,7 +193,7 @@ RUN a2dissite 000-default.conf && \
a2enmod proxy_fcgi setenvif && \
a2enconf php${PHP_VERSION}-fpm && \
a2enconf docker-php && \
a2enmod rewrite
a2enmod rewrite headers
# Install composer and yarn dependencies for Part-DB
USER www-data

View file

@ -1 +1 @@
2.12.0
2.13.3

View file

@ -19,8 +19,7 @@
import {Controller} from "@hotwired/stimulus";
import {visit} from "@hotwired/turbo";
import * as bootbox from "bootbox";
import "../../css/components/bootbox_extensions.css";
import {ConfirmSwal} from "../../helpers/swal";
import "../../css/components/dirty_form.css";
/**
@ -207,11 +206,10 @@ export default class extends Controller {
}
_confirmNavigation(onConfirm) {
bootbox.confirm({
title: this.confirmTitleValue,
message: this.confirmMessageValue,
callback: (result) => { if (result) onConfirm(); }
});
ConfirmSwal.fire({
titleText: this.confirmTitleValue,
text: this.confirmMessageValue,
}).then(({isConfirmed}) => { if (isConfirmed) onConfirm(); });
}
_handleLinkClick(event) {

View file

@ -51,7 +51,7 @@ export default class extends Controller {
//Make the state persistent over reloads
if(localStorage.getItem(STORAGE_KEY) === 'true') {
sidebarHide();
this.hideSidebar();
}
}

View file

@ -19,8 +19,7 @@
import {Controller} from "@hotwired/stimulus";
import * as bootbox from "bootbox";
import "../../css/components/bootbox_extensions.css";
import {AlertSwal, ConfirmSwal} from "../../helpers/swal";
import accept from "attr-accept";
export default class extends Controller {
@ -62,7 +61,7 @@ export default class extends Controller {
if(!prototype) {
console.warn("Prototype is not set, we cannot create a new element. This is most likely due to missing permissions.");
bootbox.alert("You do not have the permissions to create a new element. (No protoype element is set)");
AlertSwal.fire({"text": "You do not have the permissions to create a new element. (No protoype element is set)"});
return;
}
@ -226,8 +225,10 @@ export default class extends Controller {
}
if(this.deleteMessageValue) {
bootbox.confirm(this.deleteMessageValue, (result) => {
if (result) {
ConfirmSwal.fire({
text: this.deleteMessageValue,
}).then(({isConfirmed}) => {
if (isConfirmed) {
del();
}
});

View file

@ -38,9 +38,7 @@ import 'datatables.net-colreorder-bs5';
import 'datatables.net-responsive-bs5';
import '../../../js/lib/datatables';
//import 'datatables.net-select-bs5';
//Use the local version containing the fix for the select extension
import '../../../js/lib/dataTables.select.mjs';
import 'datatables.net-select-bs5';
const EVENT_DT_LOADED = 'dt:loaded';

View file

@ -20,7 +20,7 @@
import DatatablesController from "./datatables_controller.js";
import TomSelect from "tom-select";
import * as bootbox from "bootbox";
import {ConfirmSwal} from "../../../helpers/swal";
/**
* This is the datatables controller for parts lists
@ -146,15 +146,17 @@ export default class extends DatatablesController {
bubbles: true, //This line is important, otherwise Turbo will not receive the event
});
const confirm = bootbox.confirm({
message: message, title: title, callback: function (result) {
//If the dialog was confirmed, then submit the form.
if (result) {
that._confirmed = true;
form.dispatchEvent(that._our_event);
} else {
that._confirmed = false;
}
ConfirmSwal.fire({
titleText: title,
text: message,
icon: "warning"
}).then(({isConfirmed}) => {
//If the dialog was confirmed, then submit the form.
if (isConfirmed) {
that._confirmed = true;
form.dispatchEvent(that._our_event);
} else {
that._confirmed = false;
}
});
}

View file

@ -19,8 +19,7 @@
import {Controller} from "@hotwired/stimulus";
import * as bootbox from "bootbox";
import "../../css/components/bootbox_extensions.css";
import {ConfirmSwal} from "../../helpers/swal";
export default class extends Controller
{
@ -48,32 +47,33 @@ export default class extends Controller
const submitter = event.submitter;
const that = this;
const confirm = bootbox.confirm({
message: message, title: title, callback: function (result) {
//If the dialog was confirmed, then submit the form.
if (result) {
//Set a flag to prevent the dialog from popping up again and allowing turbo to submit the form
that._confirmed = true;
ConfirmSwal.fire({
titleText: title,
html: message, //Message contains a <br> tag and no user injectable HTML
}).then(({isConfirmed}) => {
//If the dialog was confirmed, then submit the form.
if (isConfirmed) {
//Set a flag to prevent the dialog from popping up again and allowing turbo to submit the form
that._confirmed = true;
//Create a submit button in the form and click it to submit the form
//Before a submit event was dispatched, but this caused weird issues on Firefox causing the delete request being posted twice (and the second time was returning 404). See https://github.com/Part-DB/Part-DB-server/issues/273
const submit_btn = document.createElement('button');
submit_btn.type = 'submit';
submit_btn.style.display = 'none';
//Create a submit button in the form and click it to submit the form
//Before a submit event was dispatched, but this caused weird issues on Firefox causing the delete request being posted twice (and the second time was returning 404). See https://github.com/Part-DB/Part-DB-server/issues/273
const submit_btn = document.createElement('button');
submit_btn.type = 'submit';
submit_btn.style.display = 'none';
//If the clicked button has a value, set it on the submit button
if (submitter.value) {
submit_btn.value = submitter.value;
}
if (submitter.name) {
submit_btn.name = submitter.name;
}
form.appendChild(submit_btn);
submit_btn.click();
} else {
that._confirmed = false;
//If the clicked button has a value, set it on the submit button
if (submitter.value) {
submit_btn.value = submitter.value;
}
if (submitter.name) {
submit_btn.name = submitter.name;
}
form.appendChild(submit_btn);
submit_btn.click();
} else {
that._confirmed = false;
}
});
}
}
}

View file

@ -19,8 +19,7 @@
import {Controller} from "@hotwired/stimulus";
import * as bootbox from "bootbox";
import "../../css/components/bootbox_extensions.css";
import {ConfirmSwal} from "../../helpers/swal";
export default class extends Controller
{
@ -53,20 +52,19 @@ export default class extends Controller
const that = this;
bootbox.confirm({
title: this.titleValue,
message: this.messageValue,
callback: (result) => {
if (result) {
//Set a flag to prevent the dialog from popping up again and allowing turbo to submit the form
that._confirmed = true;
ConfirmSwal.fire({
titleText: this.titleValue,
text: this.messageValue,
}).then(({isConfirmed}) => {
if (isConfirmed) {
//Set a flag to prevent the dialog from popping up again and allowing turbo to submit the form
that._confirmed = true;
//Click the link
that.element.click();
} else {
that._confirmed = false;
}
//Click the link
that.element.click();
} else {
that._confirmed = false;
}
});
}
}
}

View file

@ -19,12 +19,14 @@
import {Controller} from "@hotwired/stimulus";
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
import * as zxcvbnDePackage from '@zxcvbn-ts/language-de';
import * as zxcvbnFrPackage from '@zxcvbn-ts/language-fr';
import * as zxcvbnJaPackage from '@zxcvbn-ts/language-ja';
import * as zxcvbnItPackage from '@zxcvbn-ts/language-it';
import * as zxcvbnPlPackage from '@zxcvbn-ts/language-pl';
import {trans} from '../../translator.js';
/* stimulusFetch: 'lazy' */
@ -34,6 +36,8 @@ export default class extends Controller {
static targets = ["badge", "warning"]
_zxcvbnFactory;
_getTranslations() {
//Get the current locale
const locale = document.documentElement.lang;
@ -43,6 +47,10 @@ export default class extends Controller {
return zxcvbnFrPackage.translations;
} else if (locale.includes('ja')) {
return zxcvbnJaPackage.translations;
} else if (locale.includes('it')) {
return zxcvbnItPackage.translations;
} else if (locale.includes('pl')) {
return zxcvbnPlPackage.translations;
}
//Fallback to english
@ -56,34 +64,39 @@ export default class extends Controller {
//Configure zxcvbn
const options = {
graphs: zxcvbnCommonPackage.adjacencyGraphs,
useLevenshtein: true,
dictionary: {
...zxcvbnCommonPackage.dictionary,
// We could use the english dictionary here too, but it is very big. So we just use the common words
//...zxcvbnEnPackage.dictionary,
...zxcvbnEnPackage.dictionary,
...zxcvbnDePackage.dictionary,
"partdb": ['part-db', 'partdb', 'part_db', 'part-db-symfony', 'partdb-symfony', 'part_db_symfony'],
},
translations: this._getTranslations(),
};
zxcvbnOptions.setOptions(options);
this._zxcvbnFactory = new ZxcvbnFactory(options);
//Add event listener to the password input field
this._passwordInput.addEventListener('input', this._onPasswordInput.bind(this));
}
_onPasswordInput() {
async _onPasswordInput() {
//Retrieve the password
const password = this._passwordInput.value;
//Estimate the password strength
const result = zxcvbn(password);
const result = await this._zxcvbnFactory.checkAsync(password);
//Update the badge
this.badgeTarget.parentElement.classList.remove("d-none");
this._setBadgeToLevel(result.score);
this._setBadgeToLevel(result.score, result.crackTimes.onlineNoThrottlingXPerSecond.display);
this.warningTarget.innerHTML = result.feedback.warning;
}
_setBadgeToLevel(level) {
_setBadgeToLevel(level, time = null) {
let text, classes;
switch (level) {
@ -118,5 +131,11 @@ export default class extends Controller {
//Re-add the classes
this.badgeTarget.classList.add("badge");
this.badgeTarget.classList.add(...classes.split(" "));
if (time) {
this.badgeTarget.setAttribute("title", trans("user.password_strength.crack_time", {"%time%": time}));
} else {
this.badgeTarget.removeAttribute("title");
}
}
}

View file

@ -18,7 +18,7 @@
*/
import {Controller} from "@hotwired/stimulus";
import * as bootbox from "bootbox";
import {AlertSwal} from "../../helpers/swal";
export default class extends Controller {
@ -35,12 +35,12 @@ export default class extends Controller {
const part_distance = document.getElementById('reel_part_distance').value;
if (dia_inner == "" || dia_outer == "" || tape_thickness == "") {
bootbox.alert(this.errorMissingValuesValue);
AlertSwal.fire({title: this.errorMissingValuesValue});
return;
}
if (dia_outer**dia_outer < dia_inner**dia_inner) {
bootbox.alert(this.errorOuterGreaterInnerValue);
AlertSwal.fire({title: this.errorOuterGreaterInnerValue});
return;
}
@ -61,12 +61,12 @@ export default class extends Controller {
return;
}
var parts_per_meter = 1 / (part_distance / 1000);
const parts_per_meter = 1 / (part_distance / 1000);
document.getElementById('result_parts_per_meter').textContent = parts_per_meter.toFixed(2) + ' 1/m';
var parts_amount = (length/1000) * parts_per_meter;
const parts_amount = (length / 1000) * parts_per_meter;
document.getElementById('result_amount').textContent = Math.floor(parts_amount);
document.getElementById('result_amount').textContent = Math.floor(parts_amount).toString();
}
}
}

View file

@ -0,0 +1,50 @@
/*
* 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/>.
*/
/**
* Respect the dark mode of Bootstrap 5 set via data-bs-theme="dark" on the <html> element. This is done by overriding the CSS variables of the bootstrap-5 theme of SweetAlert2.
*/
html[data-bs-theme="dark"] [data-swal2-theme='bootstrap-5'] {
/* POPUP */
--swal2-background: #212529;
--swal2-color: #fff;
--swal2-border: 1px solid #495057;
/* INPUT */
--swal2-input-background: #2b3035;
--swal2-input-border: 1px solid #495057;
--swal2-input-focus-border: 1px solid #86b7fe;
--swal2-input-focus-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075), 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
/* VALIDATION MESSAGE */
--swal2-validation-message-background: #2c0b0e;
--swal2-validation-message-color: #ea868f;
/* FOOTER */
--swal2-footer-border-color: #495057;
--swal2-footer-background: #343a40;
--swal2-footer-color: #adb5bd;
/* CLOSE BUTTON */
--swal2-close-button-color: #fff;
/* TOASTS */
--swal2-toast-border: 1px solid #495057;
}

44
assets/helpers/swal.js Normal file
View file

@ -0,0 +1,44 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
import Swal from 'sweetalert2';
import 'sweetalert2/themes/bootstrap-5.css';
import '../css/components/swal.css'
import { trans } from '../translator';
const BaseSwal = Swal.mixin({
position: "top",
theme: "bootstrap-5",
confirmButtonText: trans('dialog.btn.ok'),
cancelButtonText: trans('dialog.btn.cancel'),
denyButtonText: trans('dialog.btn.deny'),
});
const ConfirmSwal = BaseSwal.mixin({
showCancelButton: true,
showCloseButton: true,
icon: "warning",
});
const AlertSwal = BaseSwal.mixin({
showCloseButton: true,
icon: "info",
});
export { ConfirmSwal, AlertSwal, BaseSwal, BaseSwal as default,};

View file

@ -30,21 +30,21 @@ import '../css/app/images.css';
// start the Stimulus application
import '../stimulus_bootstrap';
// Need jQuery? Install it with "yarn add jquery", then uncomment to require it.
const $ = require('jquery');
import $ from 'jquery';
//Only include javascript
import '@fortawesome/fontawesome-free/css/all.css'
require('bootstrap');
import 'bootstrap';
import "./error_handler";
import "./tab_remember";
import "./register_events";
import "./tristate_checkboxes";
//Define jquery globally
global.$ = global.jQuery = require("jquery");
// Expose jQuery globally so legacy plugins and Bootstrap's jQuery integration
// can find it on window at runtime.
global.$ = global.jQuery = $;
//Use the local WASM file for the ZXing library
import {

View file

@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as bootbox from "bootbox";
import Swal from "../helpers/swal";
/**
* If this class is imported the user is shown an error dialog if he calls an page via Turbo and an error is responded.
@ -40,21 +40,6 @@ class ErrorHandlerHelper {
_showAlert(statusText, statusCode, location, responseHTML)
{
const httpStatusToText = {
'200': 'OK',
'201': 'Created',
'202': 'Accepted',
'203': 'Non-Authoritative Information',
'204': 'No Content',
'205': 'Reset Content',
'206': 'Partial Content',
'300': 'Multiple Choices',
'301': 'Moved Permanently',
'302': 'Found',
'303': 'See Other',
'304': 'Not Modified',
'305': 'Use Proxy',
'306': 'Unused',
'307': 'Temporary Redirect',
'400': 'Bad Request',
'401': 'Unauthorized',
'402': 'Payment Required',
@ -83,49 +68,67 @@ class ErrorHandlerHelper {
'505': 'HTTP Version Not Supported',
};
//If the statusText is empty, we use the status code as text
if (!statusText) {
statusText = httpStatusToText[statusCode];
}
//Create error text
const title = statusText + ' (Status ' + statusCode + ')';
let trimString = function (string, length) {
return string.length > length ?
string.substring(0, length) + '...' :
string;
const userFriendlyMessages = {
'400': 'The request was invalid or malformed.',
'401': 'You need to log in to access this resource.',
'403': 'You don\'t have permission to access this resource.',
'404': 'The requested page or resource could not be found.',
'408': 'The request timed out. Please check your connection and try again.',
'409': 'There was a conflict with the current state of the resource.',
'429': 'Too many requests sent. Please wait a moment and try again.',
'500': 'An internal server error occurred. This is not your fault.',
'502': 'The server received an invalid response from an upstream service.',
'503': 'The service is temporarily unavailable. Please try again later.',
'504': 'The server did not respond in time. Please try again later.',
};
const short_location = trimString(location, 50);
if (!statusText) {
statusText = httpStatusToText[String(statusCode)] ?? 'Unknown Error';
}
const alert = bootbox.alert(
{
size: 'large',
message: function() {
let url = location;
let msg = `Error calling <a href="${url}">${short_location}</a>.<br>`;
msg += '<b>Try to reload the page or contact the administrator if this error persists.</b>';
const title = `${statusText} <small class="text-muted fs-6">(HTTP ${statusCode})</small>`;
const friendlyMsg = userFriendlyMessages[String(statusCode)]
?? 'An unexpected error occurred. Please try again or contact the administrator.';
msg += '<br><br><a class=\"btn btn-outline-secondary mb-2\" data-bs-toggle=\"collapse\" href=\"#iframe_div\" >' + 'View details' + "</a>";
msg += "<div class=\" collapse\" id='iframe_div'><iframe height='512' width='100%' id='error-iframe'></iframe></div>";
const short_location = location.length > 80
? location.substring(0, 80) + '…'
: location;
return msg;
},
title: title,
callback: function () {
//Remove blur
$('#content').removeClass('loading-content');
}
const msg = `
<p class="mb-3">${friendlyMsg}</p>
<p class="text-muted small mb-3">If this error keeps happening, please contact your administrator.</p>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#swal-error-details" aria-expanded="false">
<i class="fas fa-code me-1"></i>Technical details
</button>
<div class="collapse mt-2" id="swal-error-details">
<iframe height="400" width="100%" id="error-iframe" style="border:1px solid var(--bs-border-color);border-radius:var(--bs-border-radius);"></iframe>
</div>`;
});
const footer = `<span class="text-muted small">Error while loading: <a href="${location}" class="text-muted text-decoration-none" style="opacity:0.7;">${short_location}</a></span>`;
alert.init(function (){
var dstFrame = document.getElementById('error-iframe');
//@ts-ignore
var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document;
dstDoc.write(responseHTML)
dstDoc.close();
Swal.fire({
icon: 'error',
title: title,
html: msg,
footer: footer,
width: '90%',
confirmButtonText: '<i class="fas fa-rotate-right me-1"></i>Reload page',
showCancelButton: true,
cancelButtonText: 'Close',
showCloseButton: true,
reverseButtons: true,
didOpen: () => {
const dstFrame = document.getElementById('error-iframe');
//@ts-ignore
const dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document;
dstDoc.write(responseHTML);
dstDoc.close();
},
}).then((result) => {
document.getElementById('content').classList.remove('loading-content');
if (result.isConfirmed) {
window.location.reload();
}
});
}
@ -171,4 +174,4 @@ class ErrorHandlerHelper {
}
}
export default new ErrorHandlerHelper();
export default new ErrorHandlerHelper();

File diff suppressed because it is too large Load diff

View file

@ -19,15 +19,14 @@
'use strict';
import {Dropdown} from "bootstrap";
import {Dropdown, Modal, Tooltip} from "bootstrap";
import ClipboardJS from "clipboard";
import {Modal} from "bootstrap";
class RegisterEventHelper {
constructor() {
this.registerTooltips();
this.configureDropdowns();
// Only register special character input if enabled in configuration
const keybindingsEnabled = document.body.dataset.keybindingsSpecialCharacters !== 'false';
if (keybindingsEnabled) {
@ -40,8 +39,6 @@ class RegisterEventHelper {
});
this.registerModalDropRemovalOnFormSubmit();
}
registerModalDropRemovalOnFormSubmit() {
@ -83,11 +80,17 @@ class RegisterEventHelper {
registerTooltips() {
const handler = () => {
$(".tooltip").remove();
document.querySelectorAll('.tooltip').forEach(el => el.remove());
//Exclude dropdown buttons from tooltips, otherwise we run into endless errors from bootstrap (bootstrap.esm.js:614 Bootstrap doesn't allow more than one instance per element. Bound instance: bs.dropdown.)
$('a[title], label[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i[title], small[title]')
//@ts-ignore
.tooltip("hide").tooltip({container: "body", placement: "auto", boundary: 'window'});
const tooltipSelector = 'a[title], label[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i[title], small[title]';
document.querySelectorAll(tooltipSelector).forEach(el => {
const existing = Tooltip.getInstance(el);
if (existing) {
existing.dispose();
}
new Tooltip(el, {container: 'body', placement: 'auto', boundary: 'window'});
});
};
this.registerLoadHandler(handler);
@ -95,242 +98,240 @@ class RegisterEventHelper {
}
registerSpecialCharInput() {
this.registerLoadHandler(() => {
//@ts-ignore
$("input[type=text], input[type=search]").unbind("keydown").keydown(function (event) {
let use_special_char = event.altKey;
let greek_char = "";
if (use_special_char){
//Use the key property to determine the greek letter (as it is independent of the keyboard layout)
switch(event.key) {
//Greek letters
case "a": //Alpha (lowercase)
greek_char = "\u03B1";
break;
case "A": //Alpha (uppercase)
greek_char = "\u0391";
break;
case "b": //Beta (lowercase)
greek_char = "\u03B2";
break;
case "B": //Beta (uppercase)
greek_char = "\u0392";
break;
case "g": //Gamma (lowercase)
greek_char = "\u03B3";
break;
case "G": //Gamma (uppercase)
greek_char = "\u0393";
break;
case "d": //Delta (lowercase)
greek_char = "\u03B4";
break;
case "D": //Delta (uppercase)
greek_char = "\u0394";
break;
case "e": //Epsilon (lowercase)
greek_char = "\u03B5";
break;
case "E": //Epsilon (uppercase)
greek_char = "\u0395";
break;
case "z": //Zeta (lowercase)
greek_char = "\u03B6";
break;
case "Z": //Zeta (uppercase)
greek_char = "\u0396";
break;
case "h": //Eta (lowercase)
greek_char = "\u03B7";
break;
case "H": //Eta (uppercase)
greek_char = "\u0397";
break;
case "q": //Theta (lowercase)
greek_char = "\u03B8";
break;
case "Q": //Theta (uppercase)
greek_char = "\u0398";
break;
case "i": //Iota (lowercase)
greek_char = "\u03B9";
break;
case "I": //Iota (uppercase)
greek_char = "\u0399";
break;
case "k": //Kappa (lowercase)
greek_char = "\u03BA";
break;
case "K": //Kappa (uppercase)
greek_char = "\u039A";
break;
case "l": //Lambda (lowercase)
greek_char = "\u03BB";
break;
case "L": //Lambda (uppercase)
greek_char = "\u039B";
break;
case "m": //Mu (lowercase)
greek_char = "\u03BC";
break;
case "M": //Mu (uppercase)
greek_char = "\u039C";
break;
case "n": //Nu (lowercase)
greek_char = "\u03BD";
break;
case "N": //Nu (uppercase)
greek_char = "\u039D";
break;
case "x": //Xi (lowercase)
greek_char = "\u03BE";
break;
case "X": //Xi (uppercase)
greek_char = "\u039E";
break;
case "o": //Omicron (lowercase)
greek_char = "\u03BF";
break;
case "O": //Omicron (uppercase)
greek_char = "\u039F";
break;
case "p": //Pi (lowercase)
greek_char = "\u03C0";
break;
case "P": //Pi (uppercase)
greek_char = "\u03A0";
break;
case "r": //Rho (lowercase)
greek_char = "\u03C1";
break;
case "R": //Rho (uppercase)
greek_char = "\u03A1";
break;
case "s": //Sigma (lowercase)
greek_char = "\u03C3";
break;
case "S": //Sigma (uppercase)
greek_char = "\u03A3";
break;
case "t": //Tau (lowercase)
greek_char = "\u03C4";
break;
case "T": //Tau (uppercase)
greek_char = "\u03A4";
break;
case "u": //Upsilon (lowercase)
greek_char = "\u03C5";
break;
case "U": //Upsilon (uppercase)
greek_char = "\u03A5";
break;
case "f": //Phi (lowercase)
greek_char = "\u03C6";
break;
case "F": //Phi (uppercase)
greek_char = "\u03A6";
break;
case "c": //Chi (lowercase)
greek_char = "\u03C7";
break;
case "C": //Chi (uppercase)
greek_char = "\u03A7";
break;
case "y": //Psi (lowercase)
greek_char = "\u03C8";
break;
case "Y": //Psi (uppercase)
greek_char = "\u03A8";
break;
case "w": //Omega (lowercase)
greek_char = "\u03C9";
break;
case "W": //Omega (uppercase)
greek_char = "\u03A9";
break;
}
//Use keycodes for special characters as the shift char on the number keys are layout dependent
switch (event.keyCode) {
case 49: //1 key
//Product symbol on shift, sum on no shift
greek_char = event.shiftKey ? "\u220F" : "\u2211";
break;
case 50: //2 key
//Integral on no shift, partial derivative on shift
greek_char = event.shiftKey ? "\u2202" : "\u222B";
break;
case 51: //3 key
//Less than or equal on no shift, greater than or equal on shift
greek_char = event.shiftKey ? "\u2265" : "\u2264";
break;
case 52: //4 key
//Empty set on shift, infinity on no shift
greek_char = event.shiftKey ? "\u2205" : "\u221E";
break;
case 53: //5 key
//Not equal on shift, approx equal on no shift
greek_char = event.shiftKey ? "\u2260" : "\u2248";
break;
case 54: //6 key
//Element of on no shift, not element of on shift
greek_char = event.shiftKey ? "\u2209" : "\u2208";
break;
case 55: //7 key
//And on shift, or on no shift
greek_char = event.shiftKey ? "\u2227" : "\u2228";
break;
case 56: //8 key
//Proportional to on shift, angle on no shift
greek_char = event.shiftKey ? "\u221D" : "\u2220";
break;
case 57: //9 key
//Cube root on shift, square root on no shift
greek_char = event.shiftKey ? "\u221B" : "\u221A";
break;
case 48: //0 key
//Minus-Plus on shift, plus-minus on no shift
greek_char = event.shiftKey ? "\u2213" : "\u00B1";
break;
//Special characters
case 219: //hyphen (or ß on german layout)
//Copyright on no shift, TM on shift
greek_char = event.shiftKey ? "\u2122" : "\u00A9";
break;
case 191: //forward slash (or # on german layout)
//Generic currency on no shift, paragraph on shift
greek_char = event.shiftKey ? "\u00B6" : "\u00A4";
break;
//Currency symbols
case 192: //: or (ö on german layout)
//Euro on no shift, pound on shift
greek_char = event.shiftKey ? "\u00A3" : "\u20AC";
break;
case 221: //; or (ä on german layout)
//Yen on no shift, dollar on shift
greek_char = event.shiftKey ? "\u0024" : "\u00A5";
break;
}
if(greek_char=="") return;
let $txt = $(this);
//@ts-ignore
let caretPos = $txt[0].selectionStart;
let textAreaTxt = $txt.val().toString();
$txt.val(textAreaTxt.substring(0, caretPos) + greek_char + textAreaTxt.substring(caretPos) );
const keydownHandler = function(event) {
let use_special_char = event.altKey;
let greek_char = "";
if (use_special_char){
//Use the key property to determine the greek letter (as it is independent of the keyboard layout)
switch(event.key) {
//Greek letters
case "a": //Alpha (lowercase)
greek_char = "α";
break;
case "A": //Alpha (uppercase)
greek_char = "Α";
break;
case "b": //Beta (lowercase)
greek_char = "β";
break;
case "B": //Beta (uppercase)
greek_char = "Β";
break;
case "g": //Gamma (lowercase)
greek_char = "γ";
break;
case "G": //Gamma (uppercase)
greek_char = "Γ";
break;
case "d": //Delta (lowercase)
greek_char = "δ";
break;
case "D": //Delta (uppercase)
greek_char = "Δ";
break;
case "e": //Epsilon (lowercase)
greek_char = "ε";
break;
case "E": //Epsilon (uppercase)
greek_char = "Ε";
break;
case "z": //Zeta (lowercase)
greek_char = "ζ";
break;
case "Z": //Zeta (uppercase)
greek_char = "Ζ";
break;
case "h": //Eta (lowercase)
greek_char = "η";
break;
case "H": //Eta (uppercase)
greek_char = "Η";
break;
case "q": //Theta (lowercase)
greek_char = "θ";
break;
case "Q": //Theta (uppercase)
greek_char = "Θ";
break;
case "i": //Iota (lowercase)
greek_char = "ι";
break;
case "I": //Iota (uppercase)
greek_char = "Ι";
break;
case "k": //Kappa (lowercase)
greek_char = "κ";
break;
case "K": //Kappa (uppercase)
greek_char = "Κ";
break;
case "l": //Lambda (lowercase)
greek_char = "λ";
break;
case "L": //Lambda (uppercase)
greek_char = "Λ";
break;
case "m": //Mu (lowercase)
greek_char = "μ";
break;
case "M": //Mu (uppercase)
greek_char = "Μ";
break;
case "n": //Nu (lowercase)
greek_char = "ν";
break;
case "N": //Nu (uppercase)
greek_char = "Ν";
break;
case "x": //Xi (lowercase)
greek_char = "ξ";
break;
case "X": //Xi (uppercase)
greek_char = "Ξ";
break;
case "o": //Omicron (lowercase)
greek_char = "ο";
break;
case "O": //Omicron (uppercase)
greek_char = "Ο";
break;
case "p": //Pi (lowercase)
greek_char = "π";
break;
case "P": //Pi (uppercase)
greek_char = "Π";
break;
case "r": //Rho (lowercase)
greek_char = "ρ";
break;
case "R": //Rho (uppercase)
greek_char = "Ρ";
break;
case "s": //Sigma (lowercase)
greek_char = "σ";
break;
case "S": //Sigma (uppercase)
greek_char = "Σ";
break;
case "t": //Tau (lowercase)
greek_char = "τ";
break;
case "T": //Tau (uppercase)
greek_char = "Τ";
break;
case "u": //Upsilon (lowercase)
greek_char = "υ";
break;
case "U": //Upsilon (uppercase)
greek_char = "Υ";
break;
case "f": //Phi (lowercase)
greek_char = "φ";
break;
case "F": //Phi (uppercase)
greek_char = "Φ";
break;
case "c": //Chi (lowercase)
greek_char = "χ";
break;
case "C": //Chi (uppercase)
greek_char = "Χ";
break;
case "y": //Psi (lowercase)
greek_char = "ψ";
break;
case "Y": //Psi (uppercase)
greek_char = "Ψ";
break;
case "w": //Omega (lowercase)
greek_char = "ω";
break;
case "W": //Omega (uppercase)
greek_char = "Ω";
break;
}
//Use keycodes for special characters as the shift char on the number keys are layout dependent
switch (event.keyCode) {
case 49: //1 key
//Product symbol on shift, sum on no shift
greek_char = event.shiftKey ? "∏" : "∑";
break;
case 50: //2 key
//Integral on no shift, partial derivative on shift
greek_char = event.shiftKey ? "∂" : "∫";
break;
case 51: //3 key
//Less than or equal on no shift, greater than or equal on shift
greek_char = event.shiftKey ? "≥" : "≤";
break;
case 52: //4 key
//Empty set on shift, infinity on no shift
greek_char = event.shiftKey ? "∅" : "∞";
break;
case 53: //5 key
//Not equal on shift, approx equal on no shift
greek_char = event.shiftKey ? "≠" : "≈";
break;
case 54: //6 key
//Element of on no shift, not element of on shift
greek_char = event.shiftKey ? "∉" : "∈";
break;
case 55: //7 key
//And on shift, or on no shift
greek_char = event.shiftKey ? "∧" : "";
break;
case 56: //8 key
//Proportional to on shift, angle on no shift
greek_char = event.shiftKey ? "∝" : "∠";
break;
case 57: //9 key
//Cube root on shift, square root on no shift
greek_char = event.shiftKey ? "∛" : "√";
break;
case 48: //0 key
//Minus-Plus on shift, plus-minus on no shift
greek_char = event.shiftKey ? "∓" : "±";
break;
//Special characters
case 219: //hyphen (or ß on german layout)
//Copyright on no shift, TM on shift
greek_char = event.shiftKey ? "™" : "©";
break;
case 191: //forward slash (or # on german layout)
//Generic currency on no shift, paragraph on shift
greek_char = event.shiftKey ? "¶" : "¤";
break;
//Currency symbols
case 192: //: or (ö on german layout)
//Euro on no shift, pound on shift
greek_char = event.shiftKey ? "£" : "€";
break;
case 221: //; or (ä on german layout)
//Yen on no shift, dollar on shift
greek_char = event.shiftKey ? "$" : "¥";
break;
}
if(greek_char=="") return;
const txt = event.currentTarget;
const caretPos = txt.selectionStart;
const textAreaTxt = txt.value;
txt.value = textAreaTxt.substring(0, caretPos) + greek_char + textAreaTxt.substring(caretPos);
}
};
this.registerLoadHandler(() => {
document.querySelectorAll('input[type=text], input[type=search]').forEach(input => {
input.removeEventListener('keydown', keydownHandler);
input.addEventListener('keydown', keydownHandler);
});
//@ts-ignore
this.greek_once = true;
})
});
}
}
export default new RegisterEventHelper();
export default new RegisterEventHelper();

20
assets/themes/brite.js Normal file
View file

@ -0,0 +1,20 @@
/*
* 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/>.
*/
import "bootswatch/dist/brite/bootstrap.css";

View file

@ -29,7 +29,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",
@ -60,9 +60,10 @@
"scheb/2fa-trusted-device": "^v7.11.0",
"shivas/versioning-bundle": "^4.0",
"spatie/db-dumper": "^3.3.1",
"symfony/ai-bundle": "^0.9.0",
"symfony/ai-lm-studio-platform": "^0.9.0",
"symfony/ai-open-router-platform": "^0.9.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.*",
@ -73,6 +74,7 @@
"symfony/flex": "^v2.3.1",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/html-sanitizer": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/http-kernel": "7.4.*",
"symfony/mailer": "7.4.*",

2112
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,3 +2,4 @@ ai:
platform:
lmstudio:
host_url: '%env(string:settings:ai_lmstudio:hostURL)%'
http_client: 'app.http_client.ai_lmstudio'

View file

@ -0,0 +1,6 @@
ai:
platform:
ollama:
endpoint: '%env(string:settings:ai_ollama:endpoint)%'
api_key: '%env(string:settings:ai_ollama:apiKey)%'
http_client: 'app.http_client.ai_ollama'

View file

@ -2,3 +2,4 @@ ai:
platform:
openrouter:
api_key: '%env(string:settings:ai_openrouter:apiKey)%'
http_client: 'app.http_client.ai_openrouter'

View file

@ -1,16 +0,0 @@
services:
EasyCorp\EasyLog\EasyLogHandler:
public: false
arguments: ['%kernel.logs_dir%/%kernel.environment%.log']
#// FIXME: How to add this configuration automatically without messing up with the monolog configuration?
#monolog:
# handlers:
# buffered:
# type: buffer
# handler: easylog
# channels: ['!event']
# level: debug
# easylog:
# type: service
# id: EasyCorp\EasyLog\EasyLogHandler

View file

@ -20,16 +20,16 @@
declare(strict_types=1);
use Symfony\Config\DoctrineConfig;
/**
* This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
* This file enables native lazy objects on PHP 8.4+.
* We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
*
* TODO: Remove this file when we drop support for PHP < 8.4
*/
return static function(DoctrineConfig $doctrine) {
//On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
if (PHP_VERSION_ID >= 80400) {
$doctrine->orm()->enableNativeLazyObjects(true);
}
};
// On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
if (PHP_VERSION_ID >= 80400) {
return ['doctrine' => ['orm' => ['enable_native_lazy_objects' => true]]];
}
return [];

View file

@ -51,6 +51,7 @@ when@prod:
type: stream
channels: [deprecation]
path: "%kernel.logs_dir%/%kernel.environment%_deprecations.log"
level: "%env(DEPRECATION_LOG_LEVEL)%"
when@docker:
monolog:
@ -75,3 +76,4 @@ when@docker:
type: stream
channels: [deprecation]
path: "%kernel.logs_dir%/%kernel.environment%_deprecations.log"
level: "%env(DEPRECATION_LOG_LEVEL)%"

View file

@ -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
@ -53,6 +53,7 @@ parameters:
# Themes commented here by default, are not really usable, because of display problems. Enable them at your own risk!
partdb.available_themes:
- bootstrap
- brite
- cerulean
- cosmo
- cyborg

View file

@ -121,7 +121,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }
* @psalm-type ServicesConfig = array{
* _defaults?: DefaultsType,
* _instanceof?: InstanceofType,
* _instanceof?: array<class-string, InstanceofType>,
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
* }
* @psalm-type ExtensionType = array<string, mixed>
@ -653,7 +653,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* time_based_uuid_node?: scalar|Param|null,
* },
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* sanitizers?: array<string, array{ // Default: []
* allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false
* allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false
@ -718,7 +718,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
@ -769,7 +769,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
@ -801,7 +801,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
@ -2436,6 +2436,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* jsonapi?: array{
* use_iri_as_id?: bool|Param, // Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses. // Default: true
* allow_client_generated_id?: bool|Param, // Allow client-generated IDs on JSON:API POST per https://jsonapi.org/format/#crud-creating-client-ids. Off by default to prevent id spoofing on public endpoints. // Default: false
* },
* eager_loading?: bool|array{
* enabled?: bool|Param, // Default: true
@ -2769,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"
@ -2809,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"
@ -2873,8 +2884,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enable_translation?: bool|Param, // Enable translation for the system prompt // Default: false
* translation_domain?: string|Param, // The translation domain for the system prompt // Default: null
* },
* tools?: bool|array{
* enabled?: bool|Param, // Default: true
* tools?: bool|array{ // Tools are opt-in: set to true to inject all services tagged with "ai.tool", or configure an explicit list of tools. When the option is omitted (or set to null or false), no tools are registered.
* enabled?: bool|Param, // Default: false
* services?: list<string|array{ // Default: []
* service?: string|Param,
* agent?: string|Param,
@ -2885,6 +2896,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* keep_tool_messages?: bool|Param, // Keep tool messages in the conversation history // Default: false
* include_sources?: bool|Param, // Include sources exposed by tools as part of the tool result metadata // Default: false
* max_tool_calls?: scalar|Param|null, // Maximum number of tool calls per agent call, null to disable // Default: 50
* fault_tolerant_toolbox?: bool|Param, // Continue the agent run even if a tool call fails // Default: true
* speech?: bool|array{ // Speech (TTS/STT) decorator configuration
* enabled?: bool|Param, // Default: true
@ -2929,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,
@ -3085,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
@ -3094,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
* }>,

View file

@ -52,6 +52,28 @@ services:
alias: 'doctrine.migrations.dependency_factory'
####################################################################################################################
# AI provider HTTP clients (with configurable timeouts)
####################################################################################################################
app.http_client.ai_ollama:
class: Symfony\Contracts\HttpClient\HttpClientInterface
factory: ['@http_client', 'withOptions']
arguments:
- { timeout: '%env(int:settings:ai_ollama:timeout)%' }
app.http_client.ai_lmstudio:
class: Symfony\Contracts\HttpClient\HttpClientInterface
factory: ['@http_client', 'withOptions']
arguments:
- { timeout: '%env(int:settings:ai_lmstudio:timeout)%' }
app.http_client.ai_openrouter:
class: Symfony\Contracts\HttpClient\HttpClientInterface
factory: ['@http_client', 'withOptions']
arguments:
- { timeout: '%env(int:settings:ai_openrouter:timeout)%' }
####################################################################################################################
# Email
####################################################################################################################

View file

@ -114,10 +114,21 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `datastructure_create`: Creation of a new data structure (e.g. category, manufacturer, ...)
* `CHECK_FOR_UPDATES` (default `1`): Set this to 0 if you do not want Part-DB to connect to GitHub to check for new
versions, or if your server cannot connect to the internet.
* `APP_SECRET` (env only): This variable is a configuration parameter used for various security-related purposes,
particularly for securing and protecting various aspects of your application. It's a secret key that is used for
cryptographic operations and security measures (session management, CSRF protection, etc..). Therefore this
value should be handled as confidential data and not shared publicly.
* `APP_SECRET` (env only): A secret key used by Symfony for cryptographic operations — signing cookies, generating
CSRF tokens, and other security-sensitive tasks. **You must change this from the default value before exposing
Part-DB to any network.** The default value shipped with Part-DB is publicly known; leaving it in place would allow
an attacker to forge signed cookies and bypass CSRF protection.
Generate a secure value and add it to `.env.local`:
```bash
echo "APP_SECRET=$(openssl rand -hex 32)" >> .env.local
```
For Docker, pass it in the `environment` section of your `docker-compose.yaml`:
```yaml
environment:
- APP_SECRET=<output of: openssl rand -hex 32>
```
Part-DB displays a warning on the homepage (visible to administrators only) as long as the default value is in use.
* `SHOW_PART_IMAGE_OVERLAY`: Set to 0 to disable the part image overlay, which appears if you hover over an image in the
part image gallery
* `IPN_SUGGEST_REGEX`: A global regular expression, that part IPNs have to fulfill. Enforce your own format for your users.
@ -251,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
@ -268,9 +287,13 @@ See the [information providers]({% link usage/information_provider_system.md %})
* `BANNER`: You can configure the text that should be shown as the banner on the homepage. Useful especially for docker
containers. In all other applications you can just change the `config/banner.md` file.
* `DISABLE_YEAR2038_BUG_CHECK` (env only): If set to `1`, the year 2038 bug check is disabled on 32-bit systems, and dates after
2038 are no longer forbidden. However this will lead to 500 error messages when rendering dates after 2038 as all current
2038 are no longer forbidden. However, this will lead to 500 error messages when rendering dates after 2038 as all current
32-bit PHP versions can not format these dates correctly. This setting is for the case that future PHP versions will
handle this correctly on 32-bit systems. 64-bit systems are not affected by this bug, and the check is always disabled.
* `DEPRECATION_LOG_LEVEL` (default `emergency`) (env only): In the `prod` and `docker` environments, PHP/Symfony
deprecation notices are written to their own `var/log/<env>_deprecations.log` file. This option sets the minimum log
level a deprecation notice must have to be written there. Since deprecation notices are logged with level `info`,
the default value of `emergency` effectively disables this dedicated deprecation log. Set it to `debug` to enable it.
## Banner

View file

@ -47,7 +47,10 @@ services:
- DATABASE_URL=sqlite:///%kernel.project_dir%/var/db/app.db
# In docker env logs will be redirected to stderr
- APP_ENV=docker
# Secret key used to sign cookies and CSRF tokens. MUST be changed to a unique random value before going live!
# Generate one with: openssl rand -hex 32
- APP_SECRET=CHANGE_ME
# Uncomment this, if you want to use the automatic database migration feature. With this you have you do not have to
# run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/
# folder (under .automigration-backup), so you can restore it, if the migration fails.
@ -81,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
@ -89,7 +96,14 @@ services:
4. Customize the settings by changing the environment variables (or adding new ones). See [Configuration]({% link
configuration.md %}) for more information.
5. Inside the folder, run
5. Make sure to change the `APP_SECRET` variable to a unique random value (32 characters). You can generate one with the following command:
```bash
openssl rand -hex 32
```
6. Uncomment and set the `TRUSTED_HOSTS` variable to a regex matching the host name(s) Part-DB should be reachable under
(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
@ -100,7 +114,7 @@ services:
> Otherwise Part-DB console might use the wrong configuration to execute commands.
6. Create the initial database with
8. Create the initial database with
```bash
docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
@ -108,7 +122,7 @@ docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
and watch for the password output
6. 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
@ -121,6 +135,7 @@ If you want to use MySQL as a database, you can use the following docker-compose
{: .warning }
> You have to replace the values for MYSQL_ROOT_PASSWORD and MYSQL_PASSWORD with your own passwords!!
> You have to change MYSQL_PASSWORD in the database section and for the DATABASE_URL in the partdb section.
> Generate a random string for APP_SECRET.
```yaml
version: '3.3'
@ -142,14 +157,19 @@ services:
environment:
# Replace SECRET_USER_PASSWORD with the value of MYSQL_PASSWORD from below
- DATABASE_URL=mysql://partdb:SECRET_USER_PASSWORD@database:3306/partdb
# Secret key used to sign cookies. MUST be changed to a unique random value before going live!
# Generate one with: openssl rand -hex 32
- APP_SECRET=CHANGE_ME
# In docker env logs will be redirected to stderr
- APP_ENV=docker
# Uncomment this, if you want to use the automatic database migration feature. With this you do not have to
# run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/
# folder (under .automigration-backup), so you can restore it, if the migration fails.
# This feature is currently experimental, so use it at your own risk!
# - DB_AUTOMIGRATE=true
# Uncomment this, if you want to use the automatic database migration feature. With this you do not have to
# run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/
# folder (under .automigration-backup), so you can restore it, if the migration fails.
# This feature is currently experimental, so use it at your own risk!
# - DB_AUTOMIGRATE=true
# You can configure Part-DB using the webUI or environment variables
# However you can add any other environment configuration you want here
@ -158,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

View file

@ -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
@ -136,6 +136,25 @@ cp .env .env.local
In your `.env.local` you can configure Part-DB according to your wishes and overwrite web interface settings.
A full list of configuration options can be found [here](../configuration.md).
{: .important }
> **Change `APP_SECRET` before going live.** The default value shipped with Part-DB is publicly known and must not be
> used in production — it would allow an attacker to forge signed cookies and bypass CSRF protection.
> Generate a new value and add it to your `.env.local`:
> ```bash
> echo "APP_SECRET=$(openssl rand -hex 32)" >> .env.local
> ```
> 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.
@ -223,7 +242,7 @@ sudo ln -s /etc/apache2/sites-available/partdb.conf /etc/apache2/sites-enabled/p
Configure apache to show pretty URL paths for Part-DB (`/label/dialog` instead of `/index.php/label/dialog`):
```bash
sudo a2enmod rewrite
sudo a2enmod rewrite headers
```
If you want to access Part-DB via the IP-Address of the server, instead of the domain name, you have to remove the
@ -282,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:

View file

@ -36,6 +36,10 @@ server {
root /var/www/partdb/public;
location / {
# Headers are set here for static assets. PHP responses are served via the index.php location
# below and inherit neither of these headers, so Nelmio's PHP-side CSP is unaffected.
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; sandbox;" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri /index.php$is_args$args;
}
@ -52,10 +56,17 @@ server {
location ~ \.php$ {
return 404;
}
# Set Content-Security-Policy for svg files, to block embedded javascript in there
# Prevent PHP execution in the media upload directory
location ~* ^/media/.*\.(php[3-8]?|phar|phtml|pht|phps)$ {
return 403;
}
# SVG files get a slightly different CSP because they can embed resources and must not be framed.
# This regex location takes precedence over location /, so headers must be repeated here.
location ~* \.svg$ {
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';";
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; sandbox;" always;
add_header X-Content-Type-Options "nosniff" always;
}
error_log /var/log/nginx/parts.error.log;

View file

@ -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.

View file

@ -25,3 +25,10 @@ You need to supply an API key for OpenRouter to use it as an AI platform in Part
[LMStudio](https://lmstudio.ai/) is a local LLM hosting solution that allows you to run LLMs on your own hardware. You can use LMStudio to host your own LLM and connect it to Part-DB for AI features.
Currently only LMStudio without any authentication is supported. Supply your LMStudio instance URL (including the port) to use it as an AI platform in Part-DB.
You have to set a model by hand, as suggestions currently do not work yet. Ensure the context length is suitable for your application.
### Ollama
[Ollama](https://ollama.com/) is another local LLM hosting solution that allows you to run LLMs on your own hardware. You can use Ollama to host your own LLM and connect it to Part-DB for AI features.
Supply your Ollama instance URL (including the port) and an optional API key for authentication to use it as an AI platform in Part-DB. The model selector should give you suggestions about available models.
Ensure the context length is suitable for your application.

View file

@ -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.

View file

@ -109,6 +109,21 @@ supplier is not specified, the price and supplier product number fields will be
* **`supplier_product_number`** or **`supplier_part_number`** or * **`spn`**: The supplier product number of the part.
* **`price`**: The price of the part in the base currency of the database (by default euro).
The following fields set the EDA / KiCad metadata of the part (see [EDA / KiCad integration](eda_integration.md)).
These can be given either as the flat column names below, or in the nested `eda_info.*` form used by the
CSV/JSON export and the [example file](#example-data) (e.g. `eda_info.kicad_symbol`), so a file exported from
Part-DB can be re-imported without renaming its headers:
* **`eda_kicad_symbol`** or **`kicad_symbol`**: The KiCad symbol of the part, e.g. `Device:R`.
* **`eda_kicad_footprint`** or **`kicad_footprint`**: The KiCad footprint, e.g. `Resistor_SMD:R_0805_2012Metric`.
* **`eda_reference_prefix`** or **`kicad_reference`**: The schematic reference prefix, e.g. `R`, `C`, `L`.
* **`eda_value`** or **`kicad_value`**: The value shown in the EDA tool, e.g. `10k`.
* **`eda_exclude_from_bom`** (or **`eda_exclude_bom`**), **`eda_exclude_from_board`** (or **`eda_exclude_board`**),
**`eda_exclude_from_sim`** (or **`eda_exclude_sim`**): Booleans (`1`/`0`) to exclude the part from the BOM, board
or simulation.
* **`eda_visibility`**: Boolean (`1`/`0`) controlling whether the part is exposed to the EDA tool. The inverse
convenience alias **`eda_invisible`** is also accepted (so `eda_invisible=1` hides the part).
#### Example data
Here you can find some example data for the import of parts, you can use it as a template for your own import (

View file

@ -13,7 +13,7 @@
"bootstrap": "^5.1.3",
"core-js": "^3.38.0",
"intl-messageformat": "^10.5.11",
"jquery": "^3.5.1",
"jquery": "^4.0.0",
"popper.js": "^1.14.7",
"regenerator-runtime": "^0.14.1",
"webpack": "^5.74.0",
@ -38,20 +38,21 @@
"@algolia/autocomplete-theme-classic": "^1.17.0",
"@jbtronics/bs-treeview": "^1.0.1",
"@part-db/html5-qrcode": "^4.0.0",
"@zxcvbn-ts/core": "^3.0.2",
"@zxcvbn-ts/language-common": "^3.0.3",
"@zxcvbn-ts/language-de": "^3.0.1",
"@zxcvbn-ts/language-en": "^3.0.1",
"@zxcvbn-ts/language-fr": "^3.0.1",
"@zxcvbn-ts/language-ja": "^3.0.1",
"@zxcvbn-ts/core": "^4.1.2",
"@zxcvbn-ts/language-common": "^4.1.2",
"@zxcvbn-ts/language-de": "^4.1.1",
"@zxcvbn-ts/language-en": "^4.1.1",
"@zxcvbn-ts/language-fr": "^4.1.1",
"@zxcvbn-ts/language-it": "^4.1.1",
"@zxcvbn-ts/language-ja": "^4.1.1",
"@zxcvbn-ts/language-pl": "^4.1.1",
"attr-accept": "^2.2.5",
"barcode-detector": "^3.0.5",
"bootbox": "^6.0.0",
"bootswatch": "^5.1.3",
"bs-custom-file-input": "^1.3.4",
"ckeditor5": "^48.0.0",
"clipboard": "^2.0.4",
"compression-webpack-plugin": "^11.1.0",
"compression-webpack-plugin": "^12.0.0",
"datatables.net": "^2.0.0",
"datatables.net-bs5": "^2.0.0",
"datatables.net-buttons-bs5": "^3.0.0",
@ -69,11 +70,12 @@
"marked-mangle": "^1.0.1",
"pdfmake": "^0.3.7",
"stimulus-use": "^0.52.0",
"sweetalert2": "^11.26.25",
"tom-select": "^2.1.0",
"ts-loader": "^9.2.6",
"typescript": "^6.0.2"
},
"resolutions": {
"jquery": "^3.5.1"
"jquery": "^4.0.0"
}
}

View file

@ -119,9 +119,25 @@ DirectoryIndex index.php
</IfModule>
</IfModule>
# Set Content-Security-Policy for svg files (and compressed variants), to block embedded javascript in there
<IfModule mod_headers.c>
<FilesMatch "\.(svg|svg\.gz|svg\.br)$">
Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';"
# 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>
</IfModule>
# 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>
</IfModule>

View file

@ -1,4 +1,4 @@
# Generated on Mon May 25 06:41:46 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
@ -8293,6 +8302,7 @@ Converter_DCDC:Converter_DCDC_Hamamatsu_C11204-1_THT
Converter_DCDC:Converter_DCDC_MeanWell_NID30_THT
Converter_DCDC:Converter_DCDC_MeanWell_NID60_THT
Converter_DCDC:Converter_DCDC_MeanWell_NSD10_THT
Converter_DCDC:Converter_DCDC_MeanWell_SMU02x-xxN_THT
Converter_DCDC:Converter_DCDC_Murata_CRE1xxxxxx3C_THT
Converter_DCDC:Converter_DCDC_Murata_CRE1xxxxxxDC_THT
Converter_DCDC:Converter_DCDC_Murata_CRE1xxxxxxSC_THT
@ -9149,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
@ -12012,7 +12023,6 @@ Package_DFN_QFN:WDFN-8-1EP_4x3mm_P0.65mm_EP2.4x1.8mm_ThermalVias
Package_DFN_QFN:WDFN-8-1EP_6x5mm_P1.27mm_EP3.4x4mm
Package_DFN_QFN:WDFN-8-1EP_8x6mm_P1.27mm_EP6x4.8mm
Package_DFN_QFN:WDFN-8-1EP_8x6mm_P1.27mm_EP6x4.8mm_ThermalVias
Package_DFN_QFN:WDFN-8_2x2mm_P0.5mm
Package_DFN_QFN:WFDFPN-8-1EP_3x2mm_P0.5mm_EP1.25x1.35mm
Package_DFN_QFN:WQFN-14-1EP_2.5x2.5mm_P0.5mm_EP1.45x1.45mm
Package_DFN_QFN:WQFN-14-1EP_2.5x2.5mm_P0.5mm_EP1.45x1.45mm_ThermalVias
@ -12358,7 +12368,6 @@ Package_DirectFET:DirectFET_SQ
Package_DirectFET:DirectFET_ST
Package_LCC:Analog_LCC-8_5x5mm_P1.27mm
Package_LCC:MO047AD_PLCC-52_19.1x19.1mm_P1.27mm
Package_LCC:PLCC-20
Package_LCC:PLCC-20_9.0x9.0mm_P1.27mm
Package_LCC:PLCC-20_SMD-Socket
Package_LCC:PLCC-20_THT-Socket
@ -12566,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
@ -12920,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
@ -13032,8 +13045,6 @@ Package_SON:WSON-6-1EP_2x2mm_P0.65mm_EP1x1.6mm
Package_SON:WSON-6-1EP_2x2mm_P0.65mm_EP1x1.6mm_ThermalVias
Package_SON:WSON-6-1EP_3x3mm_P0.95mm
Package_SON:WSON-6_1.5x1.5mm_P0.5mm
Package_SON:WSON-8-1EP_2x2mm_P0.5mm_EP0.9x1.6mm
Package_SON:WSON-8-1EP_2x2mm_P0.5mm_EP0.9x1.6mm_ThermalVias
Package_SON:WSON-8-1EP_3x2.5mm_P0.5mm_EP1.2x1.5mm_PullBack
Package_SON:WSON-8-1EP_3x2.5mm_P0.5mm_EP1.2x1.5mm_PullBack_ThermalVias
Package_SON:WSON-8-1EP_3x3mm_P0.5mm_EP1.2x2mm

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
# Ignore everything except this .gitignore
*
!.gitignore
!.gitignore
!.htaccess

10
public/media/.htaccess Normal file
View file

@ -0,0 +1,10 @@
# Deny access to PHP and PHP-like files to prevent remote code execution
<FilesMatch "(?i)\.(php[3-8]?|phar|phtml|pht|phps)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Command;
use App\Services\System\AppSecretChecker;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@ -33,7 +34,9 @@ use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
#[AsCommand('partdb:check-requirements', 'Checks if the requirements Part-DB needs or recommends are fulfilled.')]
class CheckRequirementsCommand extends Command
{
public function __construct(protected ContainerBagInterface $params)
public function __construct(protected ContainerBagInterface $params,
private readonly AppSecretChecker $appSecretChecker
)
{
parent::__construct();
}
@ -121,6 +124,16 @@ class CheckRequirementsCommand extends Command
$io->success('Debug mode disabled.');
}
//Check if APP_SECRET has been changed from the default
if ($io->isVerbose()) {
$io->comment('Checking APP_SECRET...');
}
if ($this->appSecretChecker->isInsecureSecret()) {
$io->warning('APP_SECRET is set to the default value shipped with Part-DB. This is a security risk! Generate a new secret (e.g. using "openssl rand -hex 32") and set it as APP_SECRET in your .env.local file.');
} elseif (!$only_issues) {
$io->success('APP_SECRET has been changed from the default value.');
}
}
protected function checkPHPExtensions(SymfonyStyle $io, bool $only_issues = false): void

View file

@ -93,6 +93,8 @@ class AttachmentFileController extends AbstractController
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $attachment->getFilename());
$this->setAttachmentCSPHeaders($response);
return $response;
}
@ -112,6 +114,16 @@ class AttachmentFileController extends AbstractController
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $attachment->getFilename());
$this->setAttachmentCSPHeaders($response);
return $response;
}
private function setAttachmentCSPHeaders(Response $response): Response
{
//Set an CSP that disallow to run any scripts, styles or images from the attachment render page, as it is not used anywhere else for now and can be a security risk if used without proper precautions, so it should be opt-in
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; sandbox;");
return $response;
}

View file

@ -24,8 +24,10 @@ namespace App\Controller;
use App\DataTables\LogDataTable;
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;
@ -36,8 +38,12 @@ use Symfony\Component\Routing\Attribute\Route;
class HomepageController extends AbstractController
{
public function __construct(private readonly DataTableFactory $dataTable, private readonly BannerHelper $bannerHelper)
{
public function __construct(
private readonly DataTableFactory $dataTable,
private readonly BannerHelper $bannerHelper,
private readonly AppSecretChecker $appSecretChecker,
private readonly TrustedHostsChecker $trustedHostsChecker,
) {
}
@ -84,6 +90,9 @@ class HomepageController extends AbstractController
'new_version_available' => $updateAvailableManager->isUpdateAvailable(),
'new_version' => $updateAvailableManager->getLatestVersionString(),
'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(),
]);
}
}

View file

@ -90,7 +90,7 @@ class PartImportExportController extends AbstractController
goto ret;
}
if (!isset($errors) || $errors) {
if (!isset($errors) || $errors) { //@phpstan-ignore-line
$this->addFlash('error', 'parts.import.flash.error');
} else {
$this->addFlash('success', 'parts.import.flash.success');

View file

@ -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

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
@ -55,7 +56,7 @@ class TreeController extends AbstractController
#[Route(path: '/category/{id}', name: 'tree_category')]
#[Route(path: '/categories', name: 'tree_category_root')]
public function categoryTree(?Category $category = null): JsonResponse
public function categoryTree(#[MapEntity(id: 'id')] ?Category $category = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@categories.read')) {
$tree = $this->treeGenerator->getTreeView(Category::class, $category, 'list_parts_root');
@ -68,7 +69,7 @@ class TreeController extends AbstractController
#[Route(path: '/footprint/{id}', name: 'tree_footprint')]
#[Route(path: '/footprints', name: 'tree_footprint_root')]
public function footprintTree(?Footprint $footprint = null): JsonResponse
public function footprintTree(#[MapEntity(id: 'id')] ?Footprint $footprint = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@footprints.read')) {
$tree = $this->treeGenerator->getTreeView(Footprint::class, $footprint, 'list_parts_root');
@ -80,7 +81,7 @@ class TreeController extends AbstractController
#[Route(path: '/location/{id}', name: 'tree_location')]
#[Route(path: '/locations', name: 'tree_location_root')]
public function locationTree(?StorageLocation $location = null): JsonResponse
public function locationTree(#[MapEntity(id: 'id')] ?StorageLocation $location = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@storelocations.read')) {
$tree = $this->treeGenerator->getTreeView(StorageLocation::class, $location, 'list_parts_root');
@ -93,7 +94,7 @@ class TreeController extends AbstractController
#[Route(path: '/manufacturer/{id}', name: 'tree_manufacturer')]
#[Route(path: '/manufacturers', name: 'tree_manufacturer_root')]
public function manufacturerTree(?Manufacturer $manufacturer = null): JsonResponse
public function manufacturerTree(#[MapEntity(id: 'id')] ?Manufacturer $manufacturer = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@manufacturers.read')) {
$tree = $this->treeGenerator->getTreeView(Manufacturer::class, $manufacturer, 'list_parts_root');
@ -106,7 +107,7 @@ class TreeController extends AbstractController
#[Route(path: '/supplier/{id}', name: 'tree_supplier')]
#[Route(path: '/suppliers', name: 'tree_supplier_root')]
public function supplierTree(?Supplier $supplier = null): JsonResponse
public function supplierTree(#[MapEntity(id: 'id')] ?Supplier $supplier = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@suppliers.read')) {
$tree = $this->treeGenerator->getTreeView(Supplier::class, $supplier, 'list_parts_root');
@ -119,7 +120,7 @@ class TreeController extends AbstractController
#[Route(path: '/device/{id}', name: 'tree_device')]
#[Route(path: '/devices', name: 'tree_device_root')]
public function deviceTree(?Project $device = null): JsonResponse
public function deviceTree(#[MapEntity(id: 'id')] ?Project $device = null): JsonResponse
{
if ($this->isGranted('@projects.read')) {
$tree = $this->treeGenerator->getTreeView(Project::class, $device, 'devices');

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\DataTables;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\PrettyBoolColumn;
use App\DataTables\Column\RowClassColumn;
@ -40,14 +41,19 @@ use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class AttachmentDataTable implements DataTableTypeInterface
final readonly class AttachmentDataTable implements DataTableTypeInterface
{
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityURLGenerator $entityURLGenerator, private readonly AttachmentManager $attachmentHelper, private readonly AttachmentURLGenerator $attachmentURLGenerator, private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
public function __construct(private TranslatorInterface $translator, private EntityURLGenerator $entityURLGenerator, private AttachmentManager $attachmentHelper, private AttachmentURLGenerator $attachmentURLGenerator, private ElementTypeNameGenerator $elementTypeNameGenerator)
{
}
public function configure(DataTable $dataTable, array $options): void
{
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$dataTable->add('dont_matter', RowClassColumn::class, [
'render' => function ($value, Attachment $context): string {
//Mark attachments yellow which have an internal file linked that doesn't exist
@ -59,10 +65,10 @@ final class AttachmentDataTable implements DataTableTypeInterface
},
]);
$dataTable->add('picture', TextColumn::class, [
$dataTable->add('picture', HTMLColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, Attachment $context): string {
'data' => function (Attachment $context): string {
if ($context->isPicture()
&& $this->attachmentHelper->isInternalFileExisting($context)) {
@ -95,65 +101,65 @@ final class AttachmentDataTable implements DataTableTypeInterface
'orderField' => 'NATSORT(attachment.name)',
]);
$dataTable->add('attachment_type', TextColumn::class, [
$dataTable->add('attachment_type', HTMLColumn::class, [
'label' => 'attachment.table.type',
'field' => 'attachment_type.name',
'orderField' => 'NATSORT(attachment_type.name)',
'render' => fn($value, Attachment $context): string => sprintf(
'data' => fn(Attachment $context, $value): string => sprintf(
'<a href="%s">%s</a>',
$this->entityURLGenerator->editURL($context->getAttachmentType()),
htmlspecialchars((string) $value)
),
]);
$dataTable->add('element', TextColumn::class, [
$dataTable->add('element', HTMLColumn::class, [
'label' => 'attachment.table.element',
//'propertyPath' => 'element.name',
'render' => fn($value, Attachment $context): string => sprintf(
'data' => fn(Attachment $context): string => sprintf(
'<a href="%s">%s</a>',
$this->entityURLGenerator->infoURL($context->getElement()),
$this->elementTypeNameGenerator->getTypeNameCombination($context->getElement(), true)
),
]);
$dataTable->add('internal_link', TextColumn::class, [
$dataTable->add('internal_link', HTMLColumn::class, [
'label' => 'attachment.table.internal_file',
'propertyPath' => 'filename',
'orderField' => 'NATSORT(attachment.original_filename)',
'render' => function ($value, Attachment $context) {
'data' => function (Attachment $context, $value) {
if ($this->attachmentHelper->isInternalFileExisting($context)) {
return sprintf(
'<a href="%s" target="_blank" data-no-ajax>%s</a>',
$this->entityURLGenerator->viewURL($context),
htmlspecialchars($value)
htmlspecialchars((string) $value)
);
}
return $value;
}
return htmlspecialchars((string) $value);
},
]);
$dataTable->add('external_link', TextColumn::class, [
$dataTable->add('external_link', HTMLColumn::class, [
'label' => 'attachment.table.external_link',
'propertyPath' => 'host',
'orderField' => 'attachment.external_path',
'render' => function ($value, Attachment $context) {
'data' => function (Attachment $context, $value) {
if ($context->hasExternal()) {
return sprintf(
'<a href="%s" class="link-external" title="%s" target="_blank" rel="noopener">%s</a>',
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars($value),
htmlspecialchars((string) $value),
);
}
return $value;
}
return htmlspecialchars((string) $value);
},
]);
$dataTable->add('filesize', TextColumn::class, [
$dataTable->add('filesize', HTMLColumn::class, [
'label' => $this->translator->trans('attachment.table.filesize'),
'render' => function ($value, Attachment $context) {
'data' => function (Attachment $context) {
if (!$context->hasInternal()) {
return sprintf(
'<span class="badge bg-primary">
@ -168,7 +174,7 @@ final class AttachmentDataTable implements DataTableTypeInterface
'<span class="badge bg-secondary">
<i class="fas fa-hdd fa-fw"></i> %s
</span>',
$this->attachmentHelper->getHumanFileSize($context)
htmlspecialchars($this->attachmentHelper->getHumanFileSize($context))
);
}

View file

@ -78,7 +78,7 @@ class EntityColumn extends AbstractColumn
);
}
return sprintf('<i>%s</i>', $value);
return sprintf('<i>%s</i>', htmlspecialchars($value));
}
return '';

View file

@ -1,7 +1,11 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
* 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
@ -16,31 +20,18 @@
* 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/>.
*/
namespace App\DataTables\Column;
.modal-body > .bootbox-close-button {
position: absolute;
top: 0;
right: 0;
padding: 0.5rem 0.75rem;
z-index: 1;
}
.modal .bootbox-close-button {
font-weight: 100;
}
use Omines\DataTablesBundle\Column\TextColumn;
button.bootbox-close-button {
padding: 0;
background-color: transparent;
border: 0;
-webkit-appearance: none;
/**
* A TextColumn whose value is always treated as raw HTML and therefore never passed through htmlspecialchars().
* The value returned by the 'data' option must already contain properly escaped/sanitized HTML, as it is output as-is.
*/
class HTMLColumn extends TextColumn
{
public function isRaw(): bool
{
return true;
}
}
.bootbox-close-button {
/* float: right; */
font-size: 1.40625rem;
font-weight: 600;
line-height: 1;
color: #000;
text-shadow: none;
opacity: .5;
}

View file

@ -87,9 +87,9 @@ class IconLinkColumn extends AbstractColumn
return sprintf(
'<a class="btn btn-primary btn-sm %s" href="%s" title="%s"><i class="%s"></i></a>',
$disabled ? 'disabled' : '',
$href,
$title,
$icon
htmlspecialchars($href),
htmlspecialchars($title ?? ''),
htmlspecialchars($icon ?? '')
);
}

View file

@ -22,9 +22,9 @@ declare(strict_types=1);
*/
namespace App\DataTables;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\RowClassColumn;
use Omines\DataTablesBundle\Adapter\ArrayAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableFactory;
use Omines\DataTablesBundle\DataTableTypeInterface;
@ -32,7 +32,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ErrorDataTable implements DataTableTypeInterface
final readonly class ErrorDataTable implements DataTableTypeInterface
{
public function configureOptions(OptionsResolver $optionsResolver): void
{
@ -49,6 +49,11 @@ class ErrorDataTable implements DataTableTypeInterface
public function configure(DataTable $dataTable, array $options): void
{
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$optionsResolver = new OptionsResolver();
$this->configureOptions($optionsResolver);
$options = $optionsResolver->resolve($options);
@ -58,9 +63,9 @@ class ErrorDataTable implements DataTableTypeInterface
'render' => fn($value, $context): string => 'table-warning',
])
->add('error', TextColumn::class, [
->add('error', HTMLColumn::class, [
'label' => 'error_table.error',
'render' => fn($value, $context): string => '<i class="fa-solid fa-triangle-exclamation fa-fw"></i> ' . $value,
'data' => fn($context, $value): string => '<i class="fa-solid fa-triangle-exclamation fa-fw"></i> ' . htmlspecialchars((string) $value),
])
;

View file

@ -62,7 +62,7 @@ class PartDataTableHelper
}
if ($context->getBuiltProject() instanceof Project) {
$icon = sprintf('<i class="fa-solid fa-box-archive fa-fw me-1" title="%s"></i>',
$this->translator->trans('part.info.projectBuildPart.hint').': '.$context->getBuiltProject()->getName());
$this->translator->trans('part.info.projectBuildPart.hint').': '.htmlspecialchars($context->getBuiltProject()->getName()));
}

View file

@ -25,6 +25,7 @@ namespace App\DataTables;
use App\DataTables\Column\EnumColumn;
use App\Entity\LogSystem\LogTargetType;
use Symfony\Bundle\SecurityBundle\Security;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\LogEntryExtraColumn;
@ -59,7 +60,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class LogDataTable implements DataTableTypeInterface
final readonly class LogDataTable implements DataTableTypeInterface
{
protected LogEntryRepository $logRepo;
@ -95,6 +96,11 @@ class LogDataTable implements DataTableTypeInterface
public function configure(DataTable $dataTable, array $options): void
{
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
@ -104,10 +110,10 @@ class LogDataTable implements DataTableTypeInterface
'render' => fn($value, AbstractLogEntry $context) => $this->logLevelHelper->logLevelToTableColorClass($context->getLevelString()),
]);
$dataTable->add('symbol', TextColumn::class, [
$dataTable->add('symbol', HTMLColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => fn($value, AbstractLogEntry $context): string => sprintf(
'data' => fn(AbstractLogEntry $context): string => sprintf(
'<i class="fas fa-fw %s" title="%s"></i>',
$this->logLevelHelper->logLevelToIconClass($context->getLevelString()),
$context->getLevelString()
@ -128,10 +134,10 @@ class LogDataTable implements DataTableTypeInterface
)
]);
$dataTable->add('type', TextColumn::class, [
$dataTable->add('type', HTMLColumn::class, [
'label' => 'log.type',
'propertyPath' => 'type',
'render' => function (string $value, AbstractLogEntry $context) {
'data' => function (AbstractLogEntry $context, string $value) {
$text = $this->translator->trans('log.type.'.$value);
if ($context instanceof PartStockChangedLogEntry) {
@ -149,20 +155,20 @@ class LogDataTable implements DataTableTypeInterface
'label' => 'log.level',
'visible' => 'system_log' === $options['mode'],
'propertyPath' => 'levelString',
'render' => fn(string $value, AbstractLogEntry $context) => $this->translator->trans('log.level.'.$value),
'data' => fn(AbstractLogEntry $context, string $value) => $this->translator->trans('log.level.'.$value),
]);
$dataTable->add('user', TextColumn::class, [
$dataTable->add('user', HTMLColumn::class, [
'label' => 'log.user',
'orderField' => 'NATSORT(user.name)',
'render' => function ($value, AbstractLogEntry $context): string {
'data' => function (AbstractLogEntry $context): string {
$user = $context->getUser();
//If user was deleted, show the info from the username field
if (!$user instanceof User) {
if ($context->isCLIEntry()) {
return sprintf('%s [%s]',
htmlentities((string) $context->getCLIUsername()),
htmlspecialchars((string) $context->getCLIUsername()),
$this->translator->trans('log.cli_user')
);
}
@ -170,7 +176,7 @@ class LogDataTable implements DataTableTypeInterface
//Else we just deal with a deleted user
return sprintf(
'@%s [%s]',
htmlentities($context->getUsername()),
htmlspecialchars($context->getUsername()),
$this->translator->trans('log.target_deleted'),
);
}
@ -182,7 +188,7 @@ class LogDataTable implements DataTableTypeInterface
$img_url,
$this->userAvatarHelper->getAvatarMdURL($user),
$this->urlGenerator->generate('user_info', ['id' => $user->getID()]),
htmlentities($user->getFullName(true))
htmlspecialchars($user->getFullName(true))
);
},
]);
@ -194,7 +200,7 @@ class LogDataTable implements DataTableTypeInterface
'render' => function (LogTargetType $value, AbstractLogEntry $context) {
$class = $value->toClass();
if (null !== $class) {
return $this->elementTypeNameGenerator->getLocalizedTypeLabel($class);
return $this->elementTypeNameGenerator->typeLabel($class);
}
return '';
@ -216,9 +222,9 @@ class LogDataTable implements DataTableTypeInterface
'icon' => 'fas fa-fw fa-eye',
'href' => function ($value, AbstractLogEntry $context) {
if (
$context instanceof CollectionElementDeleted ||
($context instanceof TimeTravelInterface
&& $context->hasOldDataInformation())
|| $context instanceof CollectionElementDeleted
) {
try {
$target = $this->logRepo->getTargetElement($context);

View file

@ -25,6 +25,7 @@ namespace App\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
@ -58,7 +59,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
final class PartsDataTable implements DataTableTypeInterface
final readonly class PartsDataTable implements DataTableTypeInterface
{
public const LENGTH_MENU = [[10, 25, 50, 100, 250, 500, -1], [10, 25, 50, 100, 250, 500, "All"]];
@ -94,6 +95,11 @@ final class PartsDataTable implements DataTableTypeInterface
* When adding columns here, add them also to PartTableColumns enum, to make them configurable in the settings!
*************************************************************************************************************/
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$this->csh
//Color the table rows depending on the review and favorite status
->add('row_color', RowClassColumn::class, [
@ -109,23 +115,23 @@ final class PartsDataTable implements DataTableTypeInterface
},
], visibility_configurable: false)
->add('select', SelectColumn::class, visibility_configurable: false)
->add('picture', TextColumn::class, [
->add('picture', HTMLColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderPicture($context),
], visibility_configurable: false)
->add('name', TextColumn::class, [
->add('name', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderName($context),
'orderField' => 'NATSORT(part.name)'
])
->add('si_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.si_value'),
'render' => function ($value, Part $context): string {
'data' => function (Part $context): string {
$siValue = SiValueSort::sqliteSiValue($context->getName());
if ($siValue !== null) {
//Output it as scientific number with a big E
return htmlspecialchars(sprintf('%G', $siValue));
return sprintf('%G', $siValue);
}
return '';
},
@ -156,38 +162,38 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'NATSORT(_manufacturer.name)'
])
->add('storelocation', TextColumn::class, [
->add('storelocation', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
], alias: 'storage_location')
->add('amount', TextColumn::class, [
->add('amount', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.amount'),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderAmount($context),
'orderField' => 'amountSum'
])
->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'),
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
'data' => fn(Part $context, $value): string => $this->amountFormatter->format(
$value,
$context->getPartUnit()
)),
),
])
->add('partUnit', TextColumn::class, [
'label' => $this->translator->trans('part.table.partUnit'),
'orderField' => 'NATSORT(_partUnit.name)',
'render' => function ($value, Part $context): string {
'data' => function (Part $context): string {
$partUnit = $context->getPartUnit();
if ($partUnit === null) {
return '';
}
$tmp = htmlspecialchars($partUnit->getName());
$tmp = $partUnit->getName();
if ($partUnit->getUnit()) {
$tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')';
$tmp .= ' (' . $partUnit->getUnit() . ')';
}
return $tmp;
}
@ -195,14 +201,14 @@ final class PartsDataTable implements DataTableTypeInterface
->add('partCustomState', TextColumn::class, [
'label' => $this->translator->trans('part.table.partCustomState'),
'orderField' => 'NATSORT(_partCustomState.name)',
'render' => function($value, Part $context): string {
'data' => function(Part $context): string {
$partCustomState = $context->getPartCustomState();
if ($partCustomState === null) {
return '';
}
return htmlspecialchars($partCustomState->getName());
return $partCustomState->getName();
}
])
->add('addedDate', LocaleDateTimeColumn::class, [
@ -248,25 +254,25 @@ final class PartsDataTable implements DataTableTypeInterface
])
->add('eda_reference', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_reference'),
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getReferencePrefix() ?? ''),
'data' => static fn(Part $context) => $context->getEdaInfo()->getReferencePrefix() ?? '',
'orderField' => 'NATSORT(part.eda_info.reference_prefix)'
])
->add('eda_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_value'),
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getValue() ?? ''),
'data' => static fn(Part $context) => $context->getEdaInfo()->getValue() ?? '',
'orderField' => 'NATSORT(part.eda_info.value)'
])
->add('eda_status', TextColumn::class, [
->add('eda_status', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.eda_status'),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderEdaStatus($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderEdaStatus($context),
'className' => 'text-center',
]);
//Add a column to list the projects where the part is used, when the user has the permission to see the projects
if ($this->security->isGranted('read', Project::class)) {
$this->csh->add('projects', TextColumn::class, [
$this->csh->add('projects', HTMLColumn::class, [
'label' => $this->translator->trans('project.labelp'),
'render' => function ($value, Part $context): string {
'data' => function (Part $context): string {
//Only show the first 5 projects names
$projects = $context->getProjects();
$tmp = "";
@ -286,7 +292,7 @@ final class PartsDataTable implements DataTableTypeInterface
}
return $tmp;
}
},
]);
}

View file

@ -25,6 +25,7 @@ namespace App\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
@ -48,7 +49,7 @@ use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBomEntriesDataTable implements DataTableTypeInterface
final readonly class ProjectBomEntriesDataTable implements DataTableTypeInterface
{
public function __construct(
protected EntityURLGenerator $entityURLGenerator,
@ -63,17 +64,22 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
public function configure(DataTable $dataTable, array $options): void
{
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$dataTable
//->add('select', SelectColumn::class)
->add('picture', TextColumn::class, [
->add('picture', HTMLColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
if(!$context->getPart() instanceof Part) {
return '';
}
return $this->partDataTableHelper->renderPicture($context->getPart());
}
},
])
->add('id', TextColumn::class, [
@ -85,27 +91,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('project.bom.quantity'),
'className' => 'text-center',
'orderField' => 'bom_entry.quantity',
'render' => function ($value, ProjectBOMEntry $context): float|string {
'data' => function (ProjectBOMEntry $context): float|string {
//If we have a non-part entry, only show the rounded quantity
if (!$context->getPart() instanceof Part) {
return round($context->getQuantity());
}
//Otherwise use the unit of the part to format the quantity
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
return $this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit());
},
])
->add('partId', TextColumn::class, [
'label' => $this->translator->trans('project.bom.part_id'),
'visible' => true,
'orderField' => 'part.id',
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : '';
},
])
->add('name', TextColumn::class, [
->add('name', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'orderField' => 'NATSORT(part.name)',
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
if(!$context->getPart() instanceof Part) {
return htmlspecialchars((string) $context->getName());
}
@ -123,11 +129,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.ipn'),
'orderField' => 'NATSORT(part.ipn)',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
if($context->getPart() instanceof Part) {
return $context->getPart()->getIpn();
}
}
'data' => fn (ProjectBOMEntry $context) => $context->getPart()?->getIpn()
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
@ -172,9 +174,9 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
},
])
->add('mountnames', TextColumn::class, [
->add('mountnames', HTMLColumn::class, [
'label' => 'project.bom.mountnames',
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
$html = '';
foreach (explode(',', $context->getMountnames()) as $mountname) {
@ -184,34 +186,34 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
},
])
->add('instockAmount', TextColumn::class, [
->add('instockAmount', HTMLColumn::class, [
'label' => 'project.bom.instockAmount',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderAmount($context->getPart());
}
return '';
}
},
])
->add('storelocation', TextColumn::class, [
->add('storelocation', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderStorageLocations($context->getPart());
}
return '';
}
},
])
->add('price', TextColumn::class, [
'label' => 'project.bom.price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::Up)->toFloat(), null, 2, true);
},
@ -219,7 +221,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('ext_price', TextColumn::class, [
'label' => 'project.bom.ext_price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format(
$price->multipliedBy(BigDecimal::fromFloatShortest($context->getQuantity()))

View file

@ -27,6 +27,7 @@ use App\Doctrine\Functions\SiValueSort;
use App\Exceptions\InvalidRegexException;
use Doctrine\DBAL\Driver\Connection;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Pdo\Sqlite;
/**
* This middleware is used to add the regexp operator to the SQLite platform.
@ -44,17 +45,30 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
if ($params['driver'] === 'pdo_sqlite') {
$native_connection = $connection->getNativeConnection();
//Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation
if($native_connection instanceof \PDO) {
$native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
//Use the new PDO::createFunction and PDO::createCollation methods if available (PHP 8.4+)
if (is_a($native_connection, Sqlite::class)) { #TODO: Remove this check when PHP 8.4 is the minimum requirement
$native_connection->createFunction('REGEXP', self::regexp(...), 2, Sqlite::DETERMINISTIC);
$native_connection->createFunction('FIELD', self::field(...), -1, Sqlite::DETERMINISTIC);
$native_connection->createFunction('FIELD2', self::field2(...), 2, Sqlite::DETERMINISTIC);
//Create a function for SI prefix value sorting
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting
$native_connection->createCollation('NATURAL_CMP', strnatcmp(...));
//Create a function for SI prefix value sorting
$native_connection->createFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, Sqlite::DETERMINISTIC);
} else {
$native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
//Create a function for SI prefix value sorting
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
}
}
}
@ -118,4 +132,4 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
return $index + 1;
}
}
}

View file

@ -35,8 +35,10 @@ class SetSQLModeMiddlewareDriver extends AbstractDriverMiddleware
{
//Only set this on MySQL connections, as other databases don't support this parameter
if($params['driver'] === 'pdo_mysql') {
//1002 is \PDO::MYSQL_ATTR_INIT_COMMAND constant value
$params['driverOptions'][\PDO::MYSQL_ATTR_INIT_COMMAND] = 'SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))';
//PDO::MYSQL_ATTR_INIT_COMMAND is deprecated since PHP 8.5 in favor of Pdo\Mysql::ATTR_INIT_COMMAND,
//but the Pdo\Mysql class only exists since PHP 8.4. Both constants have the same value (1002).
$initCommandAttr = class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_INIT_COMMAND : \PDO::MYSQL_ATTR_INIT_COMMAND;
$params['driverOptions'][$initCommandAttr] = 'SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))';
}
return parent::connect($params);

View file

@ -65,21 +65,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/attachment_types/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: AttachmentType::class)],
openapi: new Operation(summary: 'Retrieves the children elements of an attachment type.'),
security: 'is_granted("@attachment_types.read")'
),
],
normalizationContext: ['groups' => ['attachment_type:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['attachment_type:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/attachment_types/{id}/children.{_format}',
operations: [
new GetCollection(openapi: new Operation(summary: 'Retrieves the children elements of an attachment type.'),
security: 'is_granted("@attachment_types.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: AttachmentType::class)
],
normalizationContext: ['groups' => ['attachment_type:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -68,23 +68,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/categories/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Category::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a category.'),
security: 'is_granted("@categories.read")'
),
],
normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['category:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/categories/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a category.'),
security: 'is_granted("@categories.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Category::class)
],
normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -67,23 +67,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/footprints/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Footprint::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a footprint.'),
security: 'is_granted("@footprints.read")'
),
],
normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['footprint:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/footprints/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a footprint.'),
security: 'is_granted("@footprints.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Footprint::class)
],
normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -28,9 +28,11 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Embeddable;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* This class represents a reference to a info provider inside a part.
* This class represents a reference to an info provider inside a part.
* @see \App\Tests\Entity\Parts\InfoProviderReferenceTest
*/
#[Embeddable]
@ -157,4 +159,44 @@ class InfoProviderReference
$ref->last_updated = new \DateTimeImmutable();
return $ref;
}
/**
* Creates a reference to an info provider based on the given parameters.
* @param string|null $provider_key
* @param string|null $provider_id
* @param string|null $provider_url
* @param \DateTimeImmutable|null $last_updated
* @return self
*/
public static function create(?string $provider_key, ?string $provider_id, ?string $provider_url, ?\DateTimeImmutable $last_updated): self
{
$ref = new InfoProviderReference();
$ref->provider_key = $provider_key;
$ref->provider_id = $provider_id;
$ref->provider_url = $provider_url;
$ref->last_updated = $last_updated;
return $ref;
}
#[Assert\Callback()]
public function validate(ExecutionContextInterface $context, mixed $payload): void
{
if ($this->provider_key === null && $this->provider_id !== null) {
$context->buildViolation('info_providers.validation.provider_id_without_key')
->atPath('provider_key')
->addViolation();
}
if ($this->provider_key === null && $this->provider_url !== null) {
$context->buildViolation('info_providers.validation.provider_url_without_key')
->atPath('provider_url')
->addViolation();
}
if ($this->provider_key !== null && $this->provider_id === null) {
$context->buildViolation('info_providers.validation.provider_key_without_id')
->atPath('provider_id')
->addViolation();
}
}
}

View file

@ -66,23 +66,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/manufacturers/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a manufacturer.'),
security: 'is_granted("@manufacturers.read")'
),
],
normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['manufacturer:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/manufacturers/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a manufacturer.'),
security: 'is_granted("@manufacturers.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
],
normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -71,23 +71,16 @@ use Symfony\Component\Validator\Constraints\Length;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/measurement_units/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: MeasurementUnit::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a MeasurementUnit.'),
security: 'is_granted("@measurement_units.read")'
),
],
normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['measurement_unit:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/measurement_units/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a MeasurementUnit.'),
security: 'is_granted("@measurement_units.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: MeasurementUnit::class)
],
normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "unit"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -75,6 +75,7 @@ trait AdvancedPropertyTrait
*/
#[ORM\Embedded(class: InfoProviderReference::class, columnPrefix: 'provider_reference_')]
#[Groups(['full', 'part:read'])]
#[Assert\Valid()]
protected InfoProviderReference $providerReference;
/**

View file

@ -67,23 +67,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/storage_locations/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: StorageLocation::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a storage location.'),
security: 'is_granted("@storelocations.read")'
),
],
normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['location:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/storage_locations/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a storage location.'),
security: 'is_granted("@storelocations.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
],
normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -71,21 +71,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/suppliers/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Supplier::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a supplier.'),
security: 'is_granted("@manufacturers.read")'
),
],
normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['supplier:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/suppliers/{id}/children.{_format}',
operations: [new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a supplier.'),
security: 'is_granted("@manufacturers.read")'
)],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Supplier::class)
],
normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -71,23 +71,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/currencies/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Currency::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a currency.'),
security: 'is_granted("@currencies.read")'
),
],
normalizationContext: ['groups' => ['currency:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['currency:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/currencies/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a currency.'),
security: 'is_granted("@currencies.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Currency::class)
],
normalizationContext: ['groups' => ['currency:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "iso_code"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -71,23 +71,17 @@ use Symfony\Component\Validator\Constraints\Length;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/parts/{id}/orderdetails.{_format}',
uriVariables: ['id' => new Link(toProperty: 'part', fromClass: Part::class)],
normalizationContext: ['groups' => ['orderdetail:read', 'pricedetail:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
openapi: new Operation(summary: 'Retrieves the orderdetails of a part.'),
security: 'is_granted("@parts.read")'
),
],
normalizationContext: ['groups' => ['orderdetail:read', 'orderdetail:read:standalone', 'api:basic:read', 'pricedetail:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['orderdetail:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/parts/{id}/orderdetails.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the orderdetails of a part.'),
security: 'is_granted("@parts.read")'
)
],
uriVariables: [
'id' => new Link(toProperty: 'part', fromClass: Part::class)
],
normalizationContext: ['groups' => ['orderdetail:read', 'pricedetail:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["supplierpartnr", "supplier_product_url"])]

View file

@ -66,23 +66,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/projects/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Project::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a project.'),
security: 'is_granted("@projects.read")'
),
],
normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['project:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/projects/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a project.'),
security: 'is_granted("@projects.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Project::class)
],
normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
@ -117,7 +110,7 @@ class Project extends AbstractStructuralDBElement
/**
* @var string|null The current status of the project
*/
#[Assert\Choice(['draft', 'planning', 'in_production', 'finished', 'archived'])]
#[Assert\Choice(choices: ['draft', 'planning', 'in_production', 'finished', 'archived'])]
#[Groups(['extended', 'full', 'project:read', 'project:write', 'import'])]
#[ORM\Column(type: Types::STRING, length: 64, nullable: true)]
protected ?string $status = null;

View file

@ -63,23 +63,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
new Post(uriTemplate: '/project_bom_entries.{_format}', securityPostDenormalize: 'is_granted("create", object)',),
new Patch(uriTemplate: '/project_bom_entries/{id}.{_format}', security: 'is_granted("edit", object)',),
new Delete(uriTemplate: '/project_bom_entries/{id}.{_format}', security: 'is_granted("delete", object)',),
new GetCollection(
uriTemplate: '/projects/{id}/bom.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'bom_entries', fromClass: Project::class)],
openapi: new Operation(summary: 'Retrieves the BOM entries of the given project.'),
security: 'is_granted("@projects.read")'
),
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['bom_entry:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/projects/{id}/bom.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the BOM entries of the given project.'),
security: 'is_granted("@projects.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'bom_entries', fromClass: Project::class)
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", 'mountnames'])]
#[ApiFilter(RangeFilter::class, properties: ['quantity'])]

View file

@ -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

View file

@ -0,0 +1,79 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
/**
* Catches timezone mismatches between a DateTimeInterface model value and the effective
* model_timezone configured on the field.
*
* Doctrine's UTCDateTimeImmutableType always returns UTC DateTimeImmutable objects, so any
* date/datetime field that omits `model_timezone: 'UTC'` will silently corrupt stored values
* (the transformer treats the UTC instant as if it were in the user's local timezone).
* This extension throws a \LogicException early so the mistake is caught at development time.
*/
class DateTimeModelTimezoneExtension extends AbstractTypeExtension
{
public static function getExtendedTypes(): iterable
{
return [DateTimeType::class, DateType::class];
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use ($options): void {
$data = $event->getData();
if (!$data instanceof \DateTimeInterface) {
return;
}
// Resolve the effective model timezone: explicit option or the PHP default set at build time.
// This mirrors what BaseDateTimeTransformer does in its constructor.
$modelTimezone = $options['model_timezone'] ?? date_default_timezone_get();
$dataOffset = $data->getTimezone()->getOffset($data);
$modelOffset = (new \DateTimeZone($modelTimezone))->getOffset($data);
if ($dataOffset !== $modelOffset) {
throw new \LogicException(sprintf(
'Form field "%s" received a %s with timezone "%s" (UTC offset %+d s), '
. 'but the effective model_timezone is "%s" (UTC offset %+d s). '
. 'Set the "model_timezone" option to match the timezone of your data source.',
$event->getForm()->getName(),
get_debug_type($data),
$data->getTimezone()->getName(),
$dataOffset,
$modelTimezone,
$modelOffset
));
}
});
}
}

View file

@ -0,0 +1,113 @@
<?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\Form\InfoProviderSystem;
use App\Entity\Parts\InfoProviderReference;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class InfoProviderReferenceType extends AbstractType implements DataMapperInterface
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->setDataMapper($this)
->add('provider_key', ProviderSelectType::class, [
'label' => 'info_providers.provider_key',
'input' => 'string',
'multiple' => false,
'required' => false,
'only_active' => false,
])
->add('provider_id', TextType::class, [
'label' => 'info_providers.provider_id',
'required' => false,
])
->add('provider_url', UrlType::class, [
'label' => 'info_providers.provider_url',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => InfoProviderReference::class,
]);
}
public function mapDataToForms(mixed $viewData, \Traversable $forms): void
{
if ($viewData === null) {
return;
}
if (!$viewData instanceof InfoProviderReference) {
return;
}
/** @var FormInterface[] $forms */
$forms = iterator_to_array($forms);
$forms['provider_key']->setData($viewData->getProviderKey());
$forms['provider_id']->setData($viewData->getProviderId());
$forms['provider_url']->setData($viewData->getProviderUrl());
}
public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
{
/** @var FormInterface[] $forms */
$forms = iterator_to_array($forms);
$providerKey = $forms['provider_key']->getData();
$providerId = $forms['provider_id']->getData();
$providerUrl = $forms['provider_url']->getData();
if ($viewData === null) {
$viewData = InfoProviderReference::noProvider();
}
if (!$viewData instanceof InfoProviderReference) {
return;
}
$oldDate = $viewData->getLastUpdated();
//If all fields are empty, we set the view data to a new instance without provider information
if ($providerKey === null && $providerId === null && $providerUrl === null) {
$viewData = InfoProviderReference::noProvider();
return;
}
$viewData = InfoProviderReference::create($providerKey, $providerId, $providerUrl, $oldDate);
}
}

View file

@ -31,12 +31,12 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Translation\StaticMessage;
use Symfony\Component\Translation\TranslatableMessage;
class ProviderSelectType extends AbstractType
{
public function __construct(private readonly ProviderRegistry $providerRegistry)
{
}
public function getParent(): string
@ -46,17 +46,22 @@ class ProviderSelectType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$providers = $this->providerRegistry->getActiveProviders();
$resolver->setDefault('input', 'object');
$resolver->setAllowedTypes('input', 'string');
//Either the form returns the provider objects or their keys
$resolver->setAllowedValues('input', ['object', 'string']);
$resolver->setDefault('multiple', true);
$resolver->setDefault('choices', function (Options $options) use ($providers) {
//Only show active providers in the list, or also inactive ones
$resolver->setDefault('only_active', true);
$resolver->setAllowedTypes('only_active', 'bool');
$resolver->setDefault('choices', function (Options $options) {
$providers = $options['only_active'] ? $this->providerRegistry->getActiveProviders() : $this->providerRegistry->getProviders();
if ('object' === $options['input']) {
return $this->providerRegistry->getActiveProviders();
return $providers;
}
$tmp = [];
@ -69,20 +74,35 @@ class ProviderSelectType extends AbstractType
});
//The choice_label and choice_value only needs to be set if we want the objects
$resolver->setDefault('choice_label', function (Options $options){
$resolver->setDefault('choice_label', function (Options $options) {
if ('object' === $options['input']) {
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => new StaticMessage($choice?->getProviderInfo()['name']));
return ChoiceList::label($this, static fn(?InfoProviderInterface $choice
) => new StaticMessage($choice?->getProviderInfo()['name']));
}
return static fn ($choice, $key, $value) => new StaticMessage($key);
return static fn($choice, $key, $value) => new StaticMessage($key);
});
$resolver->setDefault('choice_value', function (Options $options) {
if ('object' === $options['input']) {
return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
return ChoiceList::value($this,
static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
}
return null;
});
$resolver->setDefault('group_by', function (Options $options) {
//Do not show groups when only active providers are shown, because then all providers are active and the group would be useless
if ($options['only_active']) {
return null;
}
return function ($choice, $key, string $value) {
if ($this->providerRegistry->getProviderByKey($value)->isActive()) {
return new TranslatableMessage('info_providers.providers_list.active');
}
return new TranslatableMessage('info_providers.providers_list.disabled');
};
});
}
}

View file

@ -33,6 +33,7 @@ use App\Entity\Parts\Part;
use App\Entity\Parts\PartCustomState;
use App\Entity\PriceInformations\Orderdetail;
use App\Form\AttachmentFormType;
use App\Form\InfoProviderSystem\InfoProviderReferenceType;
use App\Form\ParameterType;
use App\Form\Part\EDA\EDAPartInfoType;
use App\Form\Type\MasterPictureAttachmentType;
@ -225,6 +226,10 @@ class PartBaseType extends AbstractType
'empty_data' => null,
'label' => 'part.gtin',
])
->add('providerReference', InfoProviderReferenceType::class, [
'label' => false,
'required' => false,
])
;
//Comment section

View file

@ -115,8 +115,10 @@ class PartLotType extends AbstractType
$builder->add('last_stocktake_at', DateTimeType::class, [
'label' => 'part_lot.edit.last_stocktake_at',
'widget' => 'single_text',
'model_timezone' => 'UTC', // The database stores the datetime in UTC, so we need to set the model timezone to UTC
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
'required' => false,
'with_seconds' => true,
]);
}

View file

@ -38,7 +38,7 @@ class AttachmentTypeType extends AbstractType
return StructuralEntityType::class;
}
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null);

View file

@ -84,8 +84,10 @@ final class ProjectBuildRequest
$remaining_amount = $this->getNeededAmountForBOMEntry($bom_entry);
foreach($this->getPartLotsForBOMEntry($bom_entry) as $lot) {
//If the lot has instock use it for the build
$this->withdraw_amounts[$lot->getID()] = min($remaining_amount, $lot->getAmount());
$remaining_amount -= max(0, $this->withdraw_amounts[$lot->getID()]);
$id = $lot->getID() ?? throw new \RuntimeException("Part lot needs to have an ID!");
$this->withdraw_amounts[$id] = min($remaining_amount, $lot->getAmount());
$remaining_amount -= max(0, $this->withdraw_amounts[$id]);
}
}
}
@ -176,6 +178,10 @@ final class ProjectBuildRequest
{
$lot_id = $lot instanceof PartLot ? $lot->getID() : $lot;
if ($lot_id === null) {
throw new \InvalidArgumentException('The given lot must have an ID!');
}
if (! array_key_exists($lot_id, $this->withdraw_amounts)) {
throw new \InvalidArgumentException('The given lot is not in the withdraw amounts array!');
}
@ -192,10 +198,12 @@ final class ProjectBuildRequest
{
if ($lot instanceof PartLot) {
$lot_id = $lot->getID();
} elseif (is_int($lot)) {
$lot_id = $lot;
} else {
throw new \InvalidArgumentException('The given lot must be an instance of PartLot or an ID of a PartLot!');
$lot_id = $lot;
}
if ($lot_id === null) {
throw new \InvalidArgumentException('The given lot must have an ID!');
}
$this->withdraw_amounts[$lot_id] = $amount;
@ -296,7 +304,7 @@ final class ProjectBuildRequest
* @param bool $dont_check_quantity
* @return $this
*/
public function setDontCheckQuantity(bool $dont_check_quantity): ProjectBuildRequest
public function setDontCheckQuantity(bool $dont_check_quantity): self
{
$this->dont_check_quantity = $dont_check_quantity;
return $this;

View file

@ -158,7 +158,6 @@ class DBElementRepository extends EntityRepository
{
$reflection = new ReflectionClass($element::class);
$property = $reflection->getProperty($field);
$property->setAccessible(true);
$property->setValue($element, $new_value);
}
}

View file

@ -63,7 +63,9 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
'eda_exclude_bom' => 'eda_exclude_from_bom',
'eda_exclude_board' => 'eda_exclude_from_board',
'eda_exclude_sim' => 'eda_exclude_from_sim',
'eda_invisible' => 'eda_visibility',
//NOTE: "eda_invisible" is intentionally NOT mapped here: it is the *inverse* of
//"eda_visibility", so a plain key rename would store the wrong value. It is handled
//(and inverted) explicitly in applyEdaFields().
];
public function __construct(
@ -235,6 +237,9 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
}
if (isset($data['eda_visibility']) && $data['eda_visibility'] !== '') {
$edaInfo->setVisibility(filter_var($data['eda_visibility'], FILTER_VALIDATE_BOOLEAN));
} elseif (isset($data['eda_invisible']) && $data['eda_invisible'] !== '') {
//"eda_invisible" is the inverse convenience alias of "eda_visibility"
$edaInfo->setVisibility(!filter_var($data['eda_invisible'], FILTER_VALIDATE_BOOLEAN));
}
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Services\AI;
use App\Settings\AISettings\LMStudioSettings;
use App\Settings\AISettings\OllamaSettings;
use App\Settings\AISettings\OpenRouterSettings;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@ -32,6 +33,7 @@ enum AIPlatforms: string implements TranslatableInterface
{
case OPENROUTER = 'openrouter';
case LMSTUDIO = 'lmstudio';
case OLLAMA = 'ollama';
/**
* Returns the name attribute of the service tag for this platform, which is used to register the platform in the AIPlatformRegistry
@ -52,6 +54,7 @@ enum AIPlatforms: string implements TranslatableInterface
return match ($this) {
self::LMSTUDIO => LMStudioSettings::class,
self::OPENROUTER => OpenRouterSettings::class,
self::OLLAMA => OllamaSettings::class,
};
}

View file

@ -69,7 +69,7 @@ class AttachmentSubmitHandler
protected const BLACKLISTED_EXTENSIONS = ['php', 'phtml', 'php3', 'ph3', 'php4', 'ph4', 'php5', 'ph5', 'phtm', 'sh',
'asp', 'cgi', 'py', 'pl', 'exe', 'aspx', 'js', 'mjs', 'jsp', 'css', 'jar', 'html', 'htm', 'shtm', 'shtml', 'htaccess',
'htpasswd', ''];
'htpasswd', 'phar', 'phps', ''];
public function __construct(
protected AttachmentPathResolver $pathResolver,
@ -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
@ -543,8 +590,10 @@ class AttachmentSubmitHandler
return $attachment;
}
$guessed_mime_type = $this->mimeTypes->guessMimeType($path);
//Check if the file is an SVG
if ($attachment->getExtension() === "svg") {
if ($guessed_mime_type === "image/svg+xml" || $attachment->getExtension() === "svg") {
$this->SVGSanitizer->sanitizeFile($path);
}

View file

@ -29,13 +29,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Service for validating BOM import data with comprehensive validation rules
* and user-friendly error messages.
* and user-friendly error messages. The results are not HTML safe, and must be escaped before display!
*/
class BOMValidationService
readonly class BOMValidationService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator
private EntityManagerInterface $entityManager,
private TranslatorInterface $translator
) {
}
@ -473,4 +473,4 @@ class BOMValidationService
: 0,
];
}
}
}

View file

@ -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,

Some files were not shown because too many files have changed in this diff Show more