From 6e5a33b307401ef809e6f156440f975ed242adce Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Mon, 12 Oct 2026 12:51:18 +0200 Subject: [PATCH 01/16] =?UTF-8?q?chore(proyectos):=20subir=20min=5Fversion?= =?UTF-8?q?=20a=202026.7=20para=20los=20grupos=20de=20pesta=C3=B1as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- facturascripts.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/facturascripts.ini b/facturascripts.ini index 6d98ddd..c9ca02c 100644 --- a/facturascripts.ini +++ b/facturascripts.ini @@ -1,5 +1,5 @@ name = 'Proyectos' description = 'Permite gestionar proyectos, tareas, vincular usuarios, documentos de compra y de venta.' version = 5.21 -min_version = 2026.65 +min_version = 2026.7 compatible = 'FacturasProgramadas' \ No newline at end of file From 85f7ea8821ed7bb0c524a62363d65afc87ae8b6a Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Mon, 12 Oct 2026 12:51:18 +0200 Subject: [PATCH 02/16] =?UTF-8?q?feat(EditProyecto):=20agrupar=20las=20pes?= =?UTF-8?q?ta=C3=B1as=20de=20ventas=20y=20compras?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Controller/EditProyecto.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Controller/EditProyecto.php b/Controller/EditProyecto.php index 8ad0afb..043e366 100644 --- a/Controller/EditProyecto.php +++ b/Controller/EditProyecto.php @@ -252,7 +252,8 @@ protected function createViews() $this->createViewSales('FacturaCliente', 'customer-invoices'); } if ($this->user->can('EditReciboCliente')) { - $this->createReceiptView('ListReciboCliente', 'ReciboCliente'); + $this->createReceiptView('ListReciboCliente', 'ReciboCliente') + ->setSettings('group', 'sales'); } $this->createViewsAccountEntries(); @@ -322,7 +323,8 @@ protected function createViewPurchases(string $modelName, string $label): void ->addOrderBy(['total'], 'total') ->addSearchFields(['cifnif', 'codigo', 'nombre', 'numproveedor', 'observaciones']) ->setSettings('btnPrint', true) - ->setSettings('btnNew', false); + ->setSettings('btnNew', false) + ->setSettings('group', 'purchases'); // filtros $this->addCommonViewFilters($viewName, $modelName); @@ -348,7 +350,8 @@ protected function createViewSales(string $modelName, string $label): void ->addOrderBy(['total'], 'total') ->addSearchFields(['cifnif', 'codigo', 'codigoenv', 'nombrecliente', 'numero2', 'observaciones']) ->setSettings('btnPrint', true) - ->setSettings('btnNew', false); + ->setSettings('btnNew', false) + ->setSettings('group', 'sales'); if ($modelName === 'PresupuestoCliente') { $this->listView($viewName)->addOrderBy(['finoferta'], 'expiration'); From 4a53737a0c0e7b44cbaa03b4c17944f3365cc045 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Mon, 12 Oct 2026 12:58:15 +0200 Subject: [PATCH 03/16] refactor(proyectos): usar mainTab() y mainTabModelValue() del core --- Controller/EditProyecto.php | 6 +++--- Controller/EditTareaProyecto.php | 10 +++++----- Extension/Controller/EditCliente.php | 2 +- Extension/Controller/EditProducto.php | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Controller/EditProyecto.php b/Controller/EditProyecto.php index 043e366..5ca9329 100644 --- a/Controller/EditProyecto.php +++ b/Controller/EditProyecto.php @@ -217,7 +217,7 @@ protected function createViews() // disable company column if there is only one company if ($this->empresa->count() < 2) { - $this->tab($this->mainTabName())->disableColumn('company'); + $this->mainTab()->disableColumn('company'); } $this->createViewsTasks(); @@ -785,7 +785,7 @@ protected function linkUpAction(string $modelName): bool protected function loadData($viewName, $view) { $mainViewName = $this->mainTabName(); - $idproyecto = $this->tabModelValue($mainViewName, 'idproyecto'); + $idproyecto = $this->mainTabModelValue('idproyecto'); switch ($viewName) { case $mainViewName: @@ -832,7 +832,7 @@ protected function loadData($viewName, $view) // añadimos el botón de nuevo documento, no para asientos if ($viewName !== 'ListAsiento') { - $codcliente = $this->tabModelValue($mainViewName, 'codcliente'); + $codcliente = $this->mainTabModelValue('codcliente'); $url = $view->model->url('edit') . '?idproyecto=' . $idproyecto; if (false === empty($codcliente) && in_array($view->model->modelClassName(), ['PresupuestoCliente', 'PedidoCliente', 'AlbaranCliente', 'FacturaCliente'])) { diff --git a/Controller/EditTareaProyecto.php b/Controller/EditTareaProyecto.php index 04875ff..49701ee 100644 --- a/Controller/EditTareaProyecto.php +++ b/Controller/EditTareaProyecto.php @@ -61,7 +61,7 @@ protected function createViews() ? $this->getModel()->idproyecto : $this->request->queryOrInput('idproyecto', ''); - $this->tab($this->mainTabName())->addButton([ + $this->mainTab()->addButton([ 'type' => 'link', 'action' => 'KanbanProyectos?idproyecto=' . $idproyecto, 'icon' => 'fa-brands fa-trello', @@ -133,7 +133,7 @@ protected function loadData($viewName, $view) $this->setTemplate('Error/AccessDenied'); } elseif (false === $view->model->getProject()->editable) { $this->disableTaskColumns($view); - $this->tab('EditTareaProyecto')->disableColumn('code'); + $this->mainTab()->disableColumn('code'); } // Si el modelo no existe, desactivamos la pestaña de archivos @@ -144,15 +144,15 @@ protected function loadData($viewName, $view) break; case 'EditNotaProyecto': - $where = [Where::eq('idtarea', $this->tabModelValue($mainViewName, 'idtarea'))]; + $where = [Where::eq('idtarea', $this->mainTabModelValue('idtarea'))]; $view->loadData('', $where, ['fecha' => 'DESC']); if (false === $view->model->exists()) { - $view->model->idproyecto = $this->tabModelValue($mainViewName, 'idproyecto'); + $view->model->idproyecto = $this->mainTabModelValue('idproyecto'); } break; case 'docfiles': - $this->loadDataDocFiles($view, $this->getModelClassName(), $this->tabModelValue($mainViewName, 'idtarea')); + $this->loadDataDocFiles($view, $this->getModelClassName(), $this->mainTabModelValue('idtarea')); break; } } diff --git a/Extension/Controller/EditCliente.php b/Extension/Controller/EditCliente.php index c9576cf..0730850 100644 --- a/Extension/Controller/EditCliente.php +++ b/Extension/Controller/EditCliente.php @@ -48,7 +48,7 @@ public function loadData(): Closure { return function ($viewName, $view) { if ($viewName === 'ListProyecto') { - $codcliente = $this->tabModelValue($this->mainTabName(), 'codcliente'); + $codcliente = $this->mainTabModelValue('codcliente'); $where = [Where::eq('codcliente', $codcliente)]; $view->loadData('', $where); } diff --git a/Extension/Controller/EditProducto.php b/Extension/Controller/EditProducto.php index 45730de..fdddfc1 100644 --- a/Extension/Controller/EditProducto.php +++ b/Extension/Controller/EditProducto.php @@ -50,7 +50,7 @@ public function loadData(): Closure { return function ($viewName, $view) { if ($viewName === 'ListStockProyecto') { - $idproducto = $this->tabModelValue($this->mainTabName(), 'idproducto'); + $idproducto = $this->mainTabModelValue('idproducto'); $where = [Where::eq('idproducto', $idproducto)]; $view->loadData('', $where); } From 01e84934b263a49f71efa0c83b3c79d508cab2b0 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Mon, 12 Oct 2026 13:17:00 +0200 Subject: [PATCH 04/16] refactor(EditProyecto): simplificar autocompleteAction y quitar DataBaseWhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atención: cambio a revisar y probar con cuidado. autocompleteAction() era una copia completa del método de BaseController para añadir la búsqueda por numproveedor/numero2 al vincular documentos. Ahora sigue el mismo patrón que Modelo303 y Servicios: solo trata el campo linkupcode y para todo lo demás delega en parent::autocompleteAction(). Se elimina lo que en este caso no tenía efecto: - el bucle de fieldfilter con DataBaseWhere::applyOperation(), ya que ningún widget linkupcode define fieldfilter. DataBaseWhere está obsoleta y no tiene sustituto en Where (Where::parseFieldFilter() se añadió y se revirtió en el core el 13/05/2026). - la rama de source vacío, que ahora resuelve el parent. - la rama de strict, porque ningún widget lo cambia y por defecto es true. Probar los 9 modales de vincular documento (cliente, proveedor y asiento) y el resto de autocompletados de la ficha del proyecto. --- Controller/EditProyecto.php | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/Controller/EditProyecto.php b/Controller/EditProyecto.php index 5ca9329..27332f9 100644 --- a/Controller/EditProyecto.php +++ b/Controller/EditProyecto.php @@ -19,7 +19,6 @@ namespace FacturaScripts\Plugins\Proyectos\Controller; -use FacturaScripts\Core\Base\DataBase\DataBaseWhere; use FacturaScripts\Core\Cache; use FacturaScripts\Core\DataSrc\Agentes; use FacturaScripts\Core\DataSrc\Almacenes; @@ -155,38 +154,21 @@ protected function addCommonViewFilters(string $viewName, string $modelName): vo protected function autocompleteAction(): array { - // recogemos los datos - $data = $this->requestGet(['field', 'fieldcode', 'fieldfilter', 'fieldtitle', 'formname', 'source', 'strict', 'term']); - // si no es una vinculación de documentos de compra o venta, ejecutamos el parent - if ($data['field'] !== 'linkupcode') { + $data = $this->requestGet(['field', 'fieldcode', 'fieldtitle', 'source', 'term']); + if ($data['field'] !== 'linkupcode' || empty($data['source'])) { return parent::autocompleteAction(); } - if ($data['source'] == '') { - return $this->getAutocompleteValues($data['formname'], $data['field']); - } - - $where = []; - foreach (DataBaseWhere::applyOperation($data['fieldfilter'] ?? '') as $field => $operation) { - // validar nombre de campo para prevenir SQL injection - if (1 !== preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?$/', $field)) { - Tools::log()->warning('autocomplete: invalid field filter name'); - return []; - } - $value = $this->request->queryOrInput($field); - $where[] = Where::column($field, $value, '=', $operation); - } - $results = []; - foreach ($this->codeModel->search($data['source'], $data['fieldcode'], $data['fieldtitle'], $data['term'], $where) as $value) { + foreach ($this->codeModel->search($data['source'], $data['fieldcode'], $data['fieldtitle'], $data['term']) as $value) { $results[] = ['key' => Tools::fixHtml($value->code), 'value' => Tools::fixHtml($value->description)]; } // en documentos, buscamos también por numproveedor o numero2 según el tipo if (str_ends_with($data['source'], 'prov') || str_ends_with($data['source'], 'clie')) { $additionalField = str_ends_with($data['source'], 'prov') ? 'numproveedor' : 'numero2'; - foreach ($this->codeModel->search($data['source'], $data['fieldcode'], $additionalField, $data['term'], $where) as $value) { + foreach ($this->codeModel->search($data['source'], $data['fieldcode'], $additionalField, $data['term']) as $value) { $key = Tools::fixHtml($value->code); // evitamos duplicados $found = false; @@ -202,13 +184,9 @@ protected function autocompleteAction(): array } } - if (empty($results) && '0' == $data['strict']) { - $results[] = ['key' => $data['term'], 'value' => $data['term']]; - } elseif (empty($results)) { - $results[] = ['key' => null, 'value' => Tools::trans('no-data')]; - } - - return $results; + return empty($results) + ? [['key' => null, 'value' => Tools::trans('no-data')]] + : $results; } protected function createViews() From 62e2e5b5ee6b5b21fe082c5179e932182bde98df Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Mon, 12 Oct 2026 13:20:26 +0200 Subject: [PATCH 05/16] fix(EditProyecto): validar permisos, token y acceso en todas las acciones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atención: cambio de seguridad que requiere pruebas exhaustivas. - Todas las acciones propias validan allowUpdate y el token del formulario; vincular y desvincular comprueban además el permiso de modificación sobre el tipo de documento (Edit). - rebuild-stock no comprobaba nada: ahora exige ser administrador, permiso de modificación y token válido. - Antes de ejecutar cualquier acción se comprueba que el usuario puede ver el proyecto, porque la denegación de acceso de loadData() llega después de las acciones y permitía modificar proyectos privados ajenos. - Importar tareas exige poder ver también el proyecto de origen. - Vincular rechaza peticiones sin documento seleccionado. - Se usa la clave de traducción not-allowed-modify (not-allowed-to-update no existe) y desvincular devuelve true si falla el SQL para que la página cargue y muestre el error. --- Controller/EditProyecto.php | 117 ++++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 19 deletions(-) diff --git a/Controller/EditProyecto.php b/Controller/EditProyecto.php index 27332f9..69ccb71 100644 --- a/Controller/EditProyecto.php +++ b/Controller/EditProyecto.php @@ -586,6 +586,12 @@ protected function disableProjectColumns(&$view): void */ protected function execPreviousAction($action) { + // no permitimos ejecutar acciones sobre proyectos que el usuario no puede ver + if (false === in_array($action, ['', 'autocomplete'], true) && false === $this->userCanSeeProject()) { + Tools::log()->warning('access-denied'); + return true; + } + switch ($action) { case 'autocomplete': $this->setTemplate(false); @@ -644,6 +650,27 @@ protected function execPreviousAction($action) } } + /** + * Carga el proyecto de la petición. Devuelve null si no existe o el usuario no puede verlo. + * + * @return Proyecto|null + */ + private function getProjectFromRequest(): ?Proyecto + { + $project = new Proyecto(); + if (false === $project->load($this->request->queryOrInput('code', ''))) { + Tools::log()->warning('record-not-found'); + return null; + } + + if (false === $project->userCanSee($this->user)) { + Tools::log()->warning('access-denied'); + return null; + } + + return $project; + } + private function getSelectExercise(): array { $companyFilter = $this->request->input('filteridempresa', 0); @@ -668,16 +695,13 @@ private function getSelectExercise(): array protected function importTaskAction(): bool { - if (false === $this->permissions->allowUpdate) { - Tools::log()->warning('not-allowed-to-update'); - return true; - } elseif (false === $this->validateFormToken()) { + if (false === $this->validateUpdateAction()) { return true; } // cargamos el proyecto actual - $origProject = new Proyecto(); - if (false === $origProject->load($this->request->queryOrInput('code', ''))) { + $origProject = $this->getProjectFromRequest(); + if (null === $origProject) { return true; } @@ -686,6 +710,9 @@ protected function importTaskAction(): bool if (false === $copyProject->load($this->request->queryOrInput('idproyecto', '')) || $origProject->idproyecto === $copyProject->idproyecto) { return true; + } elseif (false === $copyProject->userCanSee($this->user)) { + Tools::log()->warning('access-denied'); + return true; } $newTransaction = $this->dataBase->inTransaction(); @@ -728,10 +755,18 @@ protected function importTaskAction(): bool protected function linkUpAction(string $modelName): bool { - if (false === $this->permissions->allowUpdate) { - Tools::log()->warning('not-allowed-to-update'); + if (false === $this->validateUpdateAction($modelName)) { + return true; + } + + $project = $this->getProjectFromRequest(); + if (null === $project) { return true; - } elseif (false === $this->validateFormToken()) { + } + + $code = $this->request->request->get('linkupcode', ''); + if (empty($code)) { + Tools::log()->warning('no-selected-item'); return true; } @@ -739,8 +774,7 @@ protected function linkUpAction(string $modelName): bool $model = new $modelClass(); $modelTable = $model->tableName(); $modelKey = $model->primaryColumn(); - $code = $this->request->request->get('linkupcode', ''); - $idproyecto = $this->request->queryOrInput('code', ''); + $idproyecto = $project->idproyecto; $sql = "UPDATE $modelTable SET idproyecto = " . $this->dataBase->var2str($idproyecto) . " WHERE " . $this->dataBase->escapeColumn($modelKey) . " = " . $this->dataBase->var2str($code) . ';'; @@ -840,8 +874,20 @@ protected function loadData($viewName, $view) protected function rebuildStockAction(?int $idproyecto = null): void { + // el botón solo se muestra a administradores + if (false === $this->user->admin) { + Tools::log()->warning('not-allowed-modify'); + return; + } elseif (false === $this->validateUpdateAction()) { + return; + } + if (empty($idproyecto)) { - $idproyecto = (int)$this->request->query->get('code'); + $project = $this->getProjectFromRequest(); + if (null === $project) { + return; + } + $idproyecto = $project->idproyecto; } if (ProjectStockManager::rebuild($idproyecto)) { @@ -865,16 +911,13 @@ private function tableColToNumber(string $name): string protected function unlinkUpAction(string $modelName): bool { - if (false === $this->permissions->allowUpdate) { - Tools::log()->warning('not-allowed-to-update'); - return true; - } elseif (false === $this->validateFormToken()) { + if (false === $this->validateUpdateAction($modelName)) { return true; } // obtenemos el proyecto - $project = $this->getModel(); - if (false === $project->load($this->request->queryOrInput('code', ''))) { + $project = $this->getProjectFromRequest(); + if (null === $project) { return true; } @@ -893,7 +936,7 @@ protected function unlinkUpAction(string $modelName): bool . $this->dataBase->escapeColumn($modelKey) . " = " . $this->dataBase->var2str($code) . ';'; if (false === $this->dataBase->exec($sql)) { Tools::log()->error('record-save-error'); - return false; + return true; } } @@ -903,4 +946,40 @@ protected function unlinkUpAction(string $modelName): bool Tools::log()->info('record-updated-correctly'); return true; } + + /** + * Comprueba si el usuario puede ver el proyecto de la petición. Si es un proyecto nuevo, devuelve true. + * + * @return bool + */ + private function userCanSeeProject(): bool + { + $code = $this->request->queryOrInput('code', ''); + if (empty($code)) { + return true; + } + + $project = new Proyecto(); + return false === $project->load($code) || $project->userCanSee($this->user); + } + + /** + * Comprueba que el usuario puede modificar el proyecto y, si se indica, el tipo de documento, + * y que el token del formulario es válido. + * + * @param string $modelName + * @return bool + */ + private function validateUpdateAction(string $modelName = ''): bool + { + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-modify'); + return false; + } elseif ($modelName !== '' && false === $this->user->can('Edit' . $modelName, 'update')) { + Tools::log()->warning('not-allowed-modify'); + return false; + } + + return $this->validateFormToken(); + } } From e917f0ed6b6e0f92825e399bbe4eb03875106c36 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Mon, 12 Oct 2026 13:22:08 +0200 Subject: [PATCH 06/16] fix(KanbanProyectos): validar token y acceso al mover tareas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atención: cambio de seguridad que requiere pruebas exhaustivas. - move-task valida el token del formulario además de allowUpdate. - Solo se mueven tareas de proyectos que el usuario puede ver y que son editables. - Cada respuesta devuelve un token nuevo, que el JS usa en el siguiente movimiento, ya que el core no permite reutilizar un token. - Abrir el tablero con ?idproyecto= de un proyecto privado ajeno ya no muestra sus tareas. Probar varios movimientos seguidos sin recargar, usuario sin permiso de modificación, proyecto no editable y proyecto privado ajeno por URL. --- Controller/KanbanProyectos.php | 28 ++++++++++++++++++++++------ View/KanbanProyectos.html.twig | 5 +++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Controller/KanbanProyectos.php b/Controller/KanbanProyectos.php index 818e5ec..c70da5d 100644 --- a/Controller/KanbanProyectos.php +++ b/Controller/KanbanProyectos.php @@ -21,6 +21,7 @@ use FacturaScripts\Core\Template\Controller; use FacturaScripts\Core\Where; +use FacturaScripts\Dinamic\Lib\MultiRequestProtection; use FacturaScripts\Dinamic\Model\EstadoProyecto; use FacturaScripts\Dinamic\Model\FaseTarea; use FacturaScripts\Dinamic\Model\Proyecto; @@ -74,20 +75,32 @@ private function moveTaskAction(): void if (false === $this->permissions->allowUpdate) { $this->response()->json(['ok' => false, 'error' => 'not-allowed']); return; + } elseif (false === $this->validateFormToken()) { + $this->response()->json(['ok' => false, 'error' => 'invalid-request']); + return; } + // devolvemos un token nuevo para el siguiente movimiento + $token = (new MultiRequestProtection())->newToken(); + $idtarea = (int) $this->request()->input('idtarea', '0'); $idfase = (int) $this->request()->input('idfase', '0'); $task = new TareaProyecto(); - if ($idtarea > 0 && $task->load($idtarea)) { - $task->idfase = $idfase; - $task->save(); - $this->response()->json(['ok' => true]); + if ($idtarea <= 0 || false === $task->load($idtarea)) { + $this->response()->json(['ok' => false, 'token' => $token]); + return; + } + + // el usuario debe poder ver el proyecto y este debe ser editable + $project = $task->getProject(); + if (false === $project->userCanSee($this->user) || false === $project->editable) { + $this->response()->json(['ok' => false, 'error' => 'not-allowed', 'token' => $token]); return; } - $this->response()->json(['ok' => false]); + $task->idfase = $idfase; + $this->response()->json(['ok' => $task->save(), 'token' => $token]); } private function loadProjects(): void @@ -108,7 +121,10 @@ private function loadSelectedProject(): void $this->project = new Proyecto(); if ($idproyecto > 0) { - $this->project->load($idproyecto); + // si el usuario no puede ver el proyecto, no lo cargamos + if ($this->project->load($idproyecto) && false === $this->project->userCanSee($this->user)) { + $this->project = new Proyecto(); + } } elseif (!empty($this->projects)) { $this->project = $this->projects[0]; } diff --git a/View/KanbanProyectos.html.twig b/View/KanbanProyectos.html.twig index 352ca1b..5be7716 100644 --- a/View/KanbanProyectos.html.twig +++ b/View/KanbanProyectos.html.twig @@ -206,6 +206,7 @@